Documentation
¶
Overview ¶
Package claims provides the canonical 3-header identity contract for every Base-derived service. There is exactly ONE way to read the authenticated caller's identity: FromHeaders.
The Hanzo Gateway validates the IAM JWT upstream and re-emits exactly three headers on the forwarded request. Services MUST NOT read any other variant (no X-Hanzo-*, no X-IAM-*, no singular X-User-Role, no X-Tenant-Id alias):
X-User-Id <- JWT "sub" X-Org-Id <- JWT "owner" X-Roles <- JWT "roles" (comma-joined if array)
Services MUST call StripIdentityHeaders on every inbound request before the JWT middleware re-injects trusted values. A client that sets any of these headers directly is rejected at the gateway; services defend in depth by stripping again locally in case a sidecar/mesh bypasses the gateway.
Gateway-upstream assertion and request-context helpers.
Every Base-derived service MUST sit behind hanzoai/gateway. The gateway is the sole JWT verifier in the stack. Services refuse to serve tenant-scoped routes unless this invariant is explicitly acknowledged at boot.
Usage in a service:
if err := claims.AssertGatewayUpstream(); err != nil {
log.Fatal(err) // boot refuses
}
mux.Use(claims.Strip) // defense-in-depth: drop forged headers
mux.Use(claims.RequireGateway) // 503 if headers are missing on tenant routes
mux.Use(claims.Inject) // pull Claims into ctx
Index ¶
- Constants
- Variables
- func AssertGatewayUpstream() error
- func Chain(next http.Handler) http.Handler
- func HasRole(ctx context.Context, role ...string) bool
- func Inject(next http.Handler) http.Handler
- func OrgID(ctx context.Context) string
- func RequireGateway(next http.Handler) http.Handler
- func RequireRole(role ...string) func(http.Handler) http.Handler
- func Strip(next http.Handler) http.Handler
- func StripIdentityHeaders(h http.Header)
- func UserID(ctx context.Context) string
- type Claims
Constants ¶
const ( HeaderUserID = "X-User-Id" HeaderOrgID = "X-Org-Id" HeaderRoles = "X-Roles" )
The canonical 3 identity headers. These are the ONLY headers a handler may read to determine the authenticated principal.
const EnvGatewayUpstream = "HANZO_GATEWAY_UPSTREAM"
EnvGatewayUpstream is the environment variable that acknowledges the service is reachable only through hanzoai/gateway. Any value other than "1" / "true" triggers a boot refusal.
const MaxIdentityValueLen = 256
MaxIdentityValueLen caps every header value consumed by this package. Chosen at 256 bytes: generous for ULIDs/UUIDs, IAM usernames, and the longest realistic comma-joined role list (~20 role names @ 12 chars). A value longer than this is either a bug or an exhaustion attempt and is discarded — the caller observes "no identity" and RequireGateway 503s.
Variables ¶
var ErrGatewayBypass = errors.New("claims: gateway bypass detected — canonical identity headers missing")
ErrGatewayBypass is returned / surfaced when a tenant-scoped request reaches a handler without the canonical identity headers. It maps to HTTP 503; 401 would suggest the client can recover by authenticating, which is wrong — the deployment topology is broken, not the caller.
var ErrGatewayNotAsserted = errors.New("claims: HANZO_GATEWAY_UPSTREAM must be set to 1 — apps never re-verify JWT")
ErrGatewayNotAsserted is returned at boot when HANZO_GATEWAY_UPSTREAM is unset or falsy.
Functions ¶
func AssertGatewayUpstream ¶ added in v0.44.0
func AssertGatewayUpstream() error
AssertGatewayUpstream returns an error if the gateway-upstream acknowledgement is missing. Call exactly once at service boot, before listening.
func Chain ¶ added in v0.44.0
Chain is the canonical tenant-route middleware chain: Strip → Inject → RequireGateway → next. This is the ONLY approved way to mount a tenant-scoped handler. Services MUST NOT compose Strip, Inject, and RequireGateway by hand — the PHILOSOPHY.md "one and only one way" principle applies, and a misordered hand-wired chain silently defeats the forged-header defense (verified by Red probe P7-H3).
Public routes (/healthz, /readyz, /metrics) MUST be mounted on a separate mux that does not pass through Chain; the chain would 503 every probe when gateway headers are absent.
func HasRole ¶ added in v0.44.0
HasRole reports whether the caller holds any of the requested roles.
func Inject ¶ added in v0.44.0
Inject is a middleware that parses the canonical 3 identity headers and attaches the resulting Claims to the request context. It does NOT validate presence — pair it with RequireGateway for tenant-scoped routes.
func RequireGateway ¶ added in v0.44.0
RequireGateway is a middleware for tenant-scoped routes. If either X-User-Id or X-Org-Id is missing after Strip + Inject have run, the gateway was bypassed (misconfigured ingress, direct pod access, etc.) and the handler MUST NOT serve. Returns 503 with a neutral body (no hint about which header was missing — that is an attacker oracle).
func RequireRole ¶ added in v0.44.0
RequireRole returns a middleware that enforces the caller holds at least one of the requested roles. On failure, the response is a 404 — not 403 — so probing for authorized endpoints does not leak their existence. If you want an explicit 403 for a user that IS in the tenant but lacks a role, check HasRole inside the handler instead.
func Strip ¶
Strip is a net/http middleware that calls StripIdentityHeaders on every inbound request before delegating to next. Use at the outermost layer of a service, before any JWT middleware that populates the canonical 3 headers.
func StripIdentityHeaders ¶
StripIdentityHeaders removes every inbound identity-bearing header from h. Call this before JWT validation re-injects the canonical values. It also unconditionally drops every header whose name starts with "X-Hanzo-" or "X-IAM-" (case-insensitive), closing the "clever-new-prefix" attack vector.
Types ¶
type Claims ¶
Claims is the verified identity of the current request as asserted by the upstream gateway's JWT validation. All three fields may be empty strings / empty slices when the request is unauthenticated (public endpoints).
func FromContext ¶ added in v0.44.0
FromContext returns the verified Claims attached by Inject. Returns the zero Claims{} if none were attached (e.g. public route).
func FromHeaders ¶
FromHeaders returns the canonical Claims for the request. It reads ONLY the three canonical headers; any legacy variant set by a client is ignored by design (and should have been stripped upstream).
Header values are sanitized: any value that contains a control character (byte < 0x20 or byte == 0x7f) or exceeds MaxIdentityValueLen bytes is discarded and the corresponding field becomes empty. This makes log / path / response-splitting injection unreachable through this parser, and makes RequireGateway fail closed (503) on a poisoned identity instead of forwarding hostile bytes into handlers.
Roles are decoded from a comma-separated list; empty roles, roles that exceed the length cap individually, and roles that contain control characters are each dropped.