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: Beareraccess tokens for two test users: one in anadminsgroup, one instaffonly. 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-runtimeimage — pulled straight from GHCR (no local build needed):docker pull ghcr.io/paulopiriquito/hog-runtime:v2.0.0, or just letdocker buildpull 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]
adminsandstaffare attached directly to a route's ownaccess.authorize:—users-apiaccepts anyone instafforadmins(broader, read-heavy endpoint);admin-apiaccepts onlyadmins(narrower).writesis attached to every route theapi-writesRouteGroupselects — here, any route labeledtier: 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.authorizeand every matchingRouteGroup's, deduplicated; all of them must pass. Astaffmember passesusers-api's ownstaffpolicy but is still denied aPOSTthere bywrites— 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)
sourceis the claim holding the array — a plaingroups: ["admins", "staff"]claim, common on Keycloak/Auth0/Okta, works directly withrender: dn(keep the raw string). If your provider instead issues LDAP-style DNs (anisMemberOfclaim, common behind on-prem AD), userender: cnto extract thecn=value — see Enforce authorization for that shape.matchis a case-insensitive substring allowlist, not optional: an empty (or omitted)matchkeeps 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 aPolicy.- The result lands on
session.Principal.Groups, which both the built-inrequire: { groups: [...] }check andinput.groupsin Rego read.
How authorization decides¶
requiresemantics:groupsis any-of (the principal needs at least one listed group);claimsis 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 matchingRouteGrouphas no authorization gate at all — only the auth (requiredvspublic) 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 verifiedAuthorization: Bearertoken project identically throughidentity.claims/identity.groups, so the samePolicyresources gate both a browser-drivenapproute and an API-clientserviceroute without change. - The
403body 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¶
- The full login flow this guide tests around with Bearer tokens: A BFF with OIDC login and A Vue SPA and backend behind auth.
- The LDAP-style
isMemberOfgroup mapping, and aRouteGroupexample with a hard-denied method: Enforce authorization. - Full
Policy/require/rego/identityfield list and the decision model: the configuration reference and configure authorization.