Skip to content

Quick start: an API gateway with authorization

Authentication answers who is this request; authorization answers may this request do this. This guide builds a gateway that authenticates every caller through OIDC, then layers two kinds of authorization on top: a built-in policy that requires group membership, and a Rego rule that blocks write methods for anyone who isn't an admin — composed per route and per route group.

Prerequisites

  • An OIDC provider that issues Authorization: Bearer access tokens for two test users: one in an admins group, one in staff only. This guide tests with Bearer tokens directly (no browser), the same way as Enforce authorization.
  • A JSON backend reachable at http://backend:9000 — see Aggregate multiple backends for a two-line stand-in, or bring your own.
  • Docker, and the hog-runtime image — pulled straight from GHCR (no local build needed): docker pull ghcr.io/paulopiriquito/hog-runtime:v2.0.0, or just let docker build pull it on demand. See Installation and images for the full image family.

Folder structure

api-gateway/
├── gateway.yaml         # Gateway + session + IdP + Routes + Policy resources
├── policies/
│   └── writes.rego      # a Rego policy
└── Dockerfile

1. Write the Rego policy

Authorization denies are OPA/Rego evaluated at the data.hog.authz.deny entrypoint: an empty deny set allows, any non-empty set denies. This rule blocks write methods for anyone not in admins, regardless of what other policy already let them through. Save this as policies/writes.rego:

package hog.authz
import rego.v1

deny contains msg if {
    input.request.method in {"POST", "PUT", "DELETE"}
    not "admins" in input.groups
    msg := "writes require admins"
}

2. Write gateway.yaml

kind: Gateway
metadata: { name: hog }
spec:
  listen: ":8080"
  session:
    key: ${SESSION_KEY}
    ttl: 8h
  identity:
    groups:
      source: groups
      match: [admins, staff]
      render: dn
      as: groups
---
kind: IdP
metadata: { name: default }
spec:
  type: oidc
  issuer: ${OIDC_ISSUER}
  clientID: ${OIDC_CLIENT_ID}
  clientSecret: ${OIDC_CLIENT_SECRET}
  redirectURL: http://localhost:8080/auth/callback
---
kind: Policy
metadata: { name: admins }
spec:
  require:
    groups: [admins]
---
kind: Policy
metadata: { name: staff }
spec:
  require:
    groups: [staff, admins]
---
kind: Policy
metadata: { name: writes }
spec:
  rego:
    path: /etc/hog/policies/writes.rego
---
kind: Route
metadata: { name: users-api, labels: { tier: api } }
spec:
  match: /api/users
  handler:
    type: reverse-proxy
    upstream: http://backend:9000
    stripPrefix: /api
  access:
    auth: required
    authorize: [staff]
---
kind: Route
metadata: { name: admin-api, labels: { tier: api } }
spec:
  match: /api/admin/
  handler:
    type: reverse-proxy
    upstream: http://backend:9000
    stripPrefix: /api
  access:
    auth: required
    authorize: [admins]
---
kind: RouteGroup
metadata: { name: api-writes }
spec:
  selector: { matchLabels: { tier: api } }
  access:
    authorize: [writes]
  • admins and staff are attached directly to a route's own access.authorize: — users-api accepts anyone in staff or admins (broader, read-heavy endpoint); admin-api accepts only admins (narrower).
  • writes is attached to every route the api-writes RouteGroup selects — here, any route labeled tier: api — so it's configured once and inherited by both routes without repeating it per-route.
  • The effective policy set for a request is the union of the route's own access.authorize and every matching RouteGroup's, deduplicated; all of them must pass. A staff member passes users-api's own staff policy but is still denied a POST there by writes — group membership on the route doesn't override the narrower Rego rule.

3. Write the Dockerfile

FROM ghcr.io/paulopiriquito/hog-runtime:v2.0.0
COPY --chown=hog:hog gateway.yaml /etc/hog/gateway.yaml
COPY --chown=hog:hog policies/    /etc/hog/policies/

Rendering config with kustomize

For a config that varies per environment (dev/staging/prod), keep a base/ + overlays/<env>/ kustomize layout instead of hand-editing gateway.yaml, and render it in a build stage with hog-builder (which ships kustomize) before handing the result to hog-runtime:

FROM ghcr.io/paulopiriquito/hog-builder:v2.0.0 AS build
COPY config/ /work/config/
WORKDIR /work/config
RUN kustomize build overlays/prod > /out/gateway.yaml

FROM ghcr.io/paulopiriquito/hog-runtime:v2.0.0
COPY --from=build --chown=hog:hog /out/gateway.yaml /etc/hog/gateway.yaml
COPY --chown=hog:hog policies/ /etc/hog/policies/

See Rendering config with kustomize for the base/overlay layout and a full worked example.

4. Build and run it

docker build -t api-gateway .

export SESSION_KEY=$(openssl rand -hex 16)
export OIDC_ISSUER=https://idp.example.com
export OIDC_CLIENT_ID=hog-example
export OIDC_CLIENT_SECRET=your-client-secret

docker run --read-only --tmpfs /tmp \
  -p 8080:8080 \
  -e SESSION_KEY -e OIDC_ISSUER -e OIDC_CLIENT_ID -e OIDC_CLIENT_SECRET \
  api-gateway

Expected result

"hog listening" addr=:8080 on stdout.

5. Send an allowed request

A GET from a token whose subject is in admins: admins passes (group present), and writes never fires (GET isn't a write method).

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://localhost:8080/api/admin/users

Expected result

200 — HOG proxies the request to backend:9000.

6. Send two denied requests

A GET from a token in staff only, against the admin-only route, fails the route's own admins policy:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $STAFF_TOKEN" \
  http://localhost:8080/api/admin/users

That same staff token passes users-api's broader staff policy — but a POST there is still denied by the writes Rego rule, because the caller isn't in admins:

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H "Authorization: Bearer $STAFF_TOKEN" \
  http://localhost:8080/api/users

Expected result

Both return 403 Forbidden with a generic body — no policy name or reason. HOG logs which policy denied the request and why:

{"time":"...","level":"INFO","msg":"authz denied","policy":"admins","reason":"require not satisfied","subject":"staff-user","route":"/api/admin/","method":"GET"}

(An $ADMIN_TOKEN POST to /api/users returns 200 — the same Rego rule allows writes once the caller is in admins.)

Where groups come from

identity.groups maps a claim on the ID token or userinfo response into the session's Groups, which authorization then reads:

identity:
  groups:
    source: groups   # claim name carrying the group list
    match: [admins, staff]
    render: dn        # keep each value as-is
    as: groups        # session field name (also the default X-User-Groups header)
  • source is the claim holding the array — a plain groups: ["admins", "staff"] claim, common on Keycloak/Auth0/Okta, works directly with render: dn (keep the raw string). If your provider instead issues LDAP-style DNs (an isMemberOf claim, common behind on-prem AD), use render: cn to extract the cn= value — see Enforce authorization for that shape.
  • match is a case-insensitive substring allowlist, not optional: an empty (or omitted) match keeps nothing, so every request's group list comes back empty and every group-gated policy fails closed. List every group name (or a safe, non-overlapping substring of it) you actually reference in a Policy.
  • The result lands on session.Principal.Groups, which both the built-in require: { groups: [...] } check and input.groups in Rego read.

How authorization decides

  • require semantics: groups is any-of (the principal needs at least one listed group); claims is all-of across different claim keys, each with its own any-of value list.
  • Additive, default-allow: a route with no access.authorize: and no matching RouteGroup has no authorization gate at all — only the auth (required vs public) check applies.
  • Deny-overrides, fail-closed: every policy in the effective set must pass; the first deny wins, and a Rego evaluation error is itself treated as a deny (never a silent allow).
  • Bearer and browser auth both populate the same Principal — a cookie session and a verified Authorization: Bearer token project identically through identity.claims/identity.groups, so the same Policy resources gate both a browser-driven app route and an API-client service route without change.
  • The 403 body never carries the policy name or reason — only the access log does, alongside the subject, route, and (when tracing is on) the trace ID, so operators can debug a denial without exposing why to the caller.

Next steps