Policies are per-route middleware functions applied during route registration. They execute in declaration order (first listed = outermost) and can short-circuit the request by writing a response without calling next.
Type: type Policy func(http.Handler) http.Handler
File: internal/core/policy/policy.go
SuperAPI enforces policy invariants at registration time.
- Every
r.Handle(...)call is validated viapolicy.MustValidateRoute(...). - Invalid policy order/dependencies panic immediately with
invalid route config: .... - No warning-only mode and no compatibility fallback paths.
go run ./cmd/superapi-verify ./...(ormake verify) applies the same checks statically.
The policy.Chain() function wraps the handler with policies. For policies [P1, P2, P3]:
- Request path: P1 → P2 → P3 → handler
- Response path: handler → P3 → P2 → P1
If P2 short-circuits (writes a response without calling next), P3 and handler never execute.
r.Handle(method, pattern, handler,
// 1. Authentication (outermost — reject unauthenticated early, add auth context for downstream policies)
policy.AuthRequired(authEngine, mode),
// 2. Tenant scope (after auth — needs AuthContext)
policy.TenantRequired(),
// 3. Tenant path match (optional — for routes with tenant_id in URL)
policy.TenantMatchFromPath("tenant_id"),
// 4. RBAC (after tenant — needs AuthContext)
policy.RequirePerm("project.write"),
// or: policy.RequireAnyPerm("project.write", "project.admin"),
// 5. Rate limit (after auth — so user/tenant scope is available for keying)
policy.RateLimit(limiter, rule),
// 6. Cache (innermost — closest to handler)
policy.CacheRead(cacheMgr, cacheConfig),
// or for writes:
policy.CacheInvalidate(cacheMgr, invalidateConfig),
// 7. Browser/proxy cache directives (optional)
policy.CacheControl(policy.CacheControlConfig{Public: true, MaxAge: 60 * time.Second}),
)File: internal/core/policy/auth.go
Extracts Bearer token from Authorization header, validates it using goAuth middleware guard, and injects AuthContext into the request context.
policy.AuthRequired(m.authEngine, m.authMode)Behavior:
- Missing/empty
Authorizationheader → 401unauthorized - Invalid or non-
Bearerformat → 401unauthorized - goAuth validation failure → 401
unauthorized - Success:
auth.AuthContextinjected into context viaauth.WithContext()
Auth modes (passed to goAuth guard):
| Mode | Constant | Behavior |
|---|---|---|
| JWT-only | auth.ModeJWTOnly |
Validates JWT signature and claims only. No Redis session check. Fastest, but cannot detect revoked tokens. |
| Hybrid | auth.ModeHybrid |
Validates JWT first; if Redis is available, also checks session. Falls back to JWT-only if Redis is down. |
| Strict | auth.ModeStrict |
Requires both valid JWT and active Redis session. Fails closed if Redis is unavailable. Most secure. |
Hybrid guarantee (goAuth v0.4.0). A per-route mode wins over the engine's
default. AuthRequired(engine, ModeHybrid) passes ModeInherit to the guard, so
the route validates per the engine's configured ValidationMode; an explicit
ModeJWTOnly or ModeStrict on the route overrides it for that route only.
Security caveat —
ModeJWTOnlyis a downgrade. A route that opts intoauth.ModeJWTOnlyvalidates the JWT signature and claims only and skips the Redis session check. That bypasses revocation, token-version, device-binding, and account-status checks — a logged-out or disabled user's un-expired access token will still pass. Only downgrade routes that are safe to serve from the JWT alone (short-TTL, low-sensitivity reads). Never useModeJWTOnlyfor logout-sensitive, account-state-sensitive, or mutating routes. The logout endpoint deliberately usesLogoutByAccessToken, which accepts an expired-but-authentic token so a user can always end a session.
Injected AuthContext:
type AuthContext struct {
UserID string // Always present on success
TenantID string // Present if user has tenant scope
Role string // "user", "admin", etc.
Permissions []string // e.g., ["system.whoami", "project.write"]
}Reading it in handlers/services:
principal, ok := auth.FromContext(r.Context())
if !ok {
// Not authenticated (should not happen after AuthRequired policy)
}Checks that the authenticated user has all of the specified permissions.
policy.RequirePerm("project.read", "project.write")Behavior:
- No AuthContext → 401
unauthorized - Missing any required permission → 403
forbidden - All permissions present → passes through
Checks that the authenticated user has at least one of the specified permissions.
policy.RequireAnyPerm("project.write", "project.admin")Behavior:
- No AuthContext → 401
unauthorized - No matching permission → 403
forbidden - Any permission matches → passes through
- Empty perms list → startup panic (
invalid route config)
File: internal/core/policy/tenant.go
Tenancy is optional (gated by
TENANCY_ENABLED, defaultfalse). When tenancy is disabled, the preset chains do not default to tenant scoping/keying (authenticated cache reads vary by user id instead), and the route validator treats a{tenant_id}path segment as an ordinary parameter rather than forcingTenantRequired+TenantMatchFromPathonto the route. The tenant policies below are still available and enforce correctly whenever you attach them explicitly; the dependency rule "TenantMatchFromPathrequiresTenantRequired" holds regardless of the flag. WhenTENANCY_ENABLED=true,{tenant_id}routes must carry the tenant policies. To remove tenancy entirely, see docs/removing-tenancy.md.
Ensures the authenticated user has a non-empty tenant_id in their AuthContext.
policy.TenantRequired()Behavior:
- No AuthContext → 401
unauthorized - AuthContext present but
tenant_idis empty → 403forbidden("tenant scope required") - Tenant present → passes through
When to use: For any endpoint that should only be accessible to users who belong to a tenant.
Compares the tenant ID from the URL path parameter with the authenticated user's tenant_id.
policy.TenantMatchFromPath("tenant_id")Behavior:
- No AuthContext → 401
unauthorized - Path param missing or empty → 400
bad_request - User has no
tenant_id→ 403forbidden - User's
tenant_id!= path param value → 404not_found(intentional: prevents tenant enumeration) - Match → passes through
Mismatch strategy: 404 (not 403)
Returning 404 instead of 403 on tenant mismatch is a deliberate security decision. If we returned 403, an attacker could enumerate which tenant IDs exist by checking which IDs return 403 vs 404. By returning 404 for both "doesn't exist" and "exists but not yours", we prevent this information leak.
When to use: For routes like /api/v1/tenants/{tenant_id}/projects where the tenant ID is in the URL and you need to verify the user belongs to that tenant.
For routes like /api/v1/tenants/self where the tenant ID comes from the auth context (not the URL), use TenantRequired() and resolve the tenant ID in the handler:
tenantID, ok := tenant.TenantIDFromContext(r.Context())File: internal/core/policy/ratelimit.go
Basic rate limiting with automatic scope resolution.
policy.RateLimit(limiter, ratelimit.Rule{
Limit: 100,
Window: time.Minute,
Scope: ratelimit.ScopeUser,
})Rate limiting with a custom key function.
policy.RateLimitWithKeyer(limiter, "projects.list", rule, ratelimit.KeyByTenant())| Scope | Constant | Key based on | When to use |
|---|---|---|---|
| Auto | ScopeAuto |
User → Tenant → Token hash → Anonymous | Default. Tries the most specific scope available. |
| Anon | ScopeAnon |
Static "anonymous" | Public endpoints, no identity available |
| IP | ScopeIP |
Resolved client IP (trusted proxy headers when configured) | Public endpoints where IP is meaningful |
| User | ScopeUser |
AuthContext.UserID |
Authenticated endpoints, per-user limits |
| Tenant | ScopeTenant |
AuthContext.TenantID |
Tenant-scoped endpoints, shared limit across tenant users |
| Token | ScopeToken |
SHA-256 hash prefix of Bearer token | When you want per-token limits (e.g., API keys) |
Built-in keyers:
ratelimit.KeyByIP()— key by resolved client IPratelimit.KeyByUser()— key by user ID from auth contextratelimit.KeyByTenant()— key by tenant ID from auth contextratelimit.KeyByTokenHash(prefixLen)— key by token hash prefixratelimit.KeyByUserOrTenantOrTokenHash(prefixLen)— cascading: user → tenant → token → anonratelimit.KeyByAnonymous()— static "anonymous" key
Custom keyers can be provided as func(r *http.Request) (Scope, string).
rl:{env}:{route_pattern}:{scope}:{identifier}
Example: rl:prod:/api/v1/projects:user:usr_abc123
Client IP note:
- IP scoping trusts
Forwarded/X-Forwarded-Foronly whenHTTP_TRUSTED_PROXIESis configured. OtherwiseRemoteAddris used.
Controlled by RATELIMIT_FAIL_OPEN (default: true in non-prod, false in prod).
In prod, startup lint rejects RATELIMIT_FAIL_OPEN=true when rate limiting is enabled.
- Fail-open: When Redis is unavailable, requests are allowed through. The decision outcome is recorded as
fail_open. - Fail-closed: When Redis is unavailable, the rate limiter returns an error and the policy responds with 500.
When a request is rate-limited (429), the Retry-After header is set with the number of seconds until the window resets.
- Rate limit keys use low-cardinality values. Route patterns (not raw URLs), scopes, and sanitized identifiers.
- Bearer tokens are never stored in keys — only a SHA-256 hash prefix (16 hex chars by default).
RateLimit(...)andRateLimitWithKeyer(...)require a non-nil limiter and a valid rule; invalid config panics at registration.
File: internal/core/policy/cache.go
Serves cached responses for matching requests and stores responses on cache miss.
policy.CacheRead(cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
},
VaryBy: cache.CacheVaryBy{
TenantID: true,
PathParams: []string{"id"},
QueryParams: []string{"limit", "cursor"},
},
})CacheReadConfig fields:
| Field | Type | Default | Description |
|---|---|---|---|
Key |
string |
route pattern | Optional custom cache key prefix when you want tighter control than the route pattern |
TTL |
time.Duration |
(required) | Cache entry time-to-live |
MaxBytes |
int |
CACHE_DEFAULT_MAX_BYTES (256 KiB) |
Max response body size to cache |
TagSpecs |
[]CacheTagSpec |
— | Dynamic invalidation scopes included in key (version-bumped on write) |
Methods |
[]string |
["GET", "HEAD"] |
HTTP methods eligible for caching |
CacheStatuses |
[]int |
[200] |
HTTP status codes to cache |
VaryBy |
CacheVaryBy |
— | Dimensions that differentiate cache entries |
FailOpen |
*bool |
Global CACHE_FAIL_OPEN |
Per-route fail-open override |
AllowAuthenticated |
bool |
false |
Enables authenticated caching behavior in the cache layer; does not override validator safety rules |
CacheTagSpec fields:
| Field | Type | Description |
|---|---|---|
Name |
string |
Base tag family name |
PathParams |
[]string |
Path params appended to tag scope |
TenantID |
bool |
Include auth tenant id in tag scope |
UserID |
bool |
Include auth user id in tag scope |
Literals |
[]CacheTagLiteral |
Constant key/value dimensions for scope splits |
CacheVaryBy fields:
| Field | Type | Description |
|---|---|---|
Method |
bool |
Include HTTP method in key |
TenantID |
bool |
Include tenant ID from AuthContext |
UserID |
bool |
Include user ID from AuthContext |
Role |
bool |
Include role from AuthContext |
PathParams |
[]string |
Include named path parameters |
QueryParams |
[]string |
Include specific query parameters (hash of values) |
Headers |
[]string |
Include specific request headers |
Behavior flow:
- Check if HTTP method is allowed (default: GET/HEAD only)
- Enforce authenticated cache safety rules (see below)
- Resolve tag names from TagSpecs, fetch their versions, and build cache key
- Attempt cache GET
- Hit: Serve cached response directly, return
- Miss: Continue to handler
- Capture handler response
- If response is cacheable, store in Redis with TTL
Authenticated caching safety (strict):
For authenticated routes (those with AuthRequired), CacheRead must include at least one identity boundary:
VaryBy.UserID = true, orVaryBy.TenantID = true
If neither is set, validation fails and route registration panics. AllowAuthenticated does not bypass this requirement.
Not cached:
- Streaming responses (flushed or hijacked)
- Responses larger than MaxBytes
- Responses with
Set-Cookieheader - Non-matching status codes
Bumps versions for resolved TagSpecs after a successful write operation, causing matching cached entries to miss on next read.
policy.CacheInvalidate(cacheMgr, cache.CacheInvalidateConfig{
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
{Name: "project-list", TenantID: true},
},
})Behavior:
- Passes request to handler
- If handler returns a 2xx status code, resolves tag names from request/auth context
- Bumps all resolved tag versions
- If handler returns non-2xx, no invalidation occurs
Production notes:
- Invalidation uses
INCRon tag version keys (cver:{env}:{tag}), which is O(1) - This is NOT mass key deletion — it's cheap and fast
- Multiple scoped tags can be invalidated in a single Redis pipeline
CacheInvalidate(...)requires a non-nil manager and at least one tag spec; invalid config panics at registration
| Default | Value | Why |
|---|---|---|
| Cache methods | GET, HEAD |
Prevent caching side effects from write methods |
| Cache statuses | 200 |
Avoid caching error responses by default |
| Set-Cookie handling | Skip responses with Set-Cookie |
Prevent session and identity leakage |
| Max body guard | CACHE_DEFAULT_MAX_BYTES |
Avoid unbounded Redis memory usage |
| Authenticated key isolation | Require VaryBy.UserID or VaryBy.TenantID |
Prevent cross-user cache data leaks |
| Redis error handling | Fail-open in non-prod, fail-closed in prod by default | Balance availability in dev/test with safer prod posture |
File: internal/core/policy/cachecontrol.go
Use this policy to attach explicit Cache-Control and optional Vary headers to a route.
policy.CacheControl(policy.CacheControlConfig{
Public: true,
MaxAge: 60 * time.Second,
SharedMaxAge: 120 * time.Second,
Immutable: true,
Vary: []string{"Accept-Encoding"},
})| Field | Header directive |
|---|---|
Public |
public |
Private |
private |
NoStore |
no-store |
NoCache |
no-cache |
MustRevalidate |
must-revalidate |
Immutable |
immutable |
MaxAge |
max-age=<seconds> |
SharedMaxAge |
s-maxage=<seconds> |
StaleWhileRevalidate |
stale-while-revalidate=<seconds> |
StaleIfError |
stale-if-error=<seconds> |
- Durations must be
>= 0. PublicandPrivatecannot both be set.NoStorecannot be combined with max-age/s-maxage/stale/immutable directives.- Policy must set at least one cache directive or one
Varyvalue.
- Place
CacheControl(...)after auth/tenant/rbac/rate-limit/cache policies so it applies consistently to both fresh and cached responses. - Use conservative values for authenticated routes; avoid
publicunless the response is intentionally shared.
Ensures Content-Type: application/json on requests with bodies (POST/PUT/PATCH).
policy.RequireJSON()Behavior:
- GET/HEAD/DELETE without body → passes through
- POST/PUT/PATCH without
application/jsonContent-Type → 415 Unsupported Media Type (standard error envelope) - Correct Content-Type → passes through
Adds a response header.
policy.WithHeader("X-Custom", "value")Does nothing. Useful as a placeholder.
policy.Noop()The strict validator enforces:
- Policy order: auth -> tenant -> RBAC -> rate-limit -> cache.
- Auth dependency: RBAC and tenant policies require
AuthRequired. - Tenant path safety: routes containing
{tenant_id}must includeTenantRequiredandTenantMatchFromPath("tenant_id"). - Cache safety: authenticated routes using
CacheReadmust vary by user or tenant.
go run ./cmd/superapi-verify ./...
# or
make verifyUse built-in validated presets when possible:
policy.TenantRead(...)policy.TenantWrite(...)policy.PublicRead(...)
Example:
r.Handle(http.MethodGet, "/api/v1/projects/{id}", handler,
policy.TenantRead(
policy.WithAuthEngine(authEngine, auth.ModeStrict),
policy.WithLimiter(limiter),
policy.WithCacheManager(cacheMgr),
policy.WithCache(30*time.Second, cache.CacheTagSpec{Name: "project", PathParams: []string{"id"}}),
policy.WithCacheVaryBy(cache.CacheVaryBy{TenantID: true, PathParams: []string{"id"}}),
)...,
)Example: GET /api/v1/status
r.Handle(http.MethodGet, "/api/v1/status", handler,
policy.RateLimitWithKeyer(limiter, "status", ratelimit.Rule{
Limit: 60, Window: time.Minute, Scope: ratelimit.ScopeIP,
}, ratelimit.KeyByIP()),
policy.CacheRead(cacheMgr, cache.CacheReadConfig{
TTL: 10 * time.Second,
}),
)Policies:
- Rate limit by IP (no auth context available)
- CacheRead if safe (no user-specific data)
- No auth or tenant policies
Example: GET /api/v1/system/whoami
r.Handle(http.MethodGet, "/api/v1/system/whoami", handler,
policy.AuthRequired(authEngine, mode),
policy.RateLimitWithKeyer(limiter, "whoami", ratelimit.Rule{
Limit: 30, Window: time.Minute, Scope: ratelimit.ScopeUser,
}, ratelimit.KeyByUserOrTenantOrTokenHash(16)),
)Policies:
- AuthRequired (hybrid or strict)
- Rate limit by user/token (auth context available after AuthRequired)
- CacheRead only when
VaryBy.UserIDorVaryBy.TenantIDis set
Example: GET /api/v1/projects/{id}
r.Handle(http.MethodGet, "/api/v1/projects/{id}", handler,
policy.AuthRequired(authEngine, auth.ModeStrict),
policy.TenantRequired(),
policy.RateLimitWithKeyer(limiter, "projects.get", rule, ratelimit.KeyByTenant()),
policy.CacheRead(cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
},
VaryBy: cache.CacheVaryBy{
TenantID: true,
PathParams: []string{"id"},
},
}),
)Policies:
- AuthRequired strict (recommended for tenant data)
- TenantRequired
- Rate limit by tenant
- CacheRead with tenant + path param vary
Example: POST /api/v1/projects
r.Handle(http.MethodPost, "/api/v1/projects", handler,
policy.AuthRequired(authEngine, auth.ModeStrict),
policy.TenantRequired(),
policy.RequirePerm("project.write"),
policy.RateLimitWithKeyer(limiter, "projects.create", rule, ratelimit.KeyByTenant()),
policy.CacheInvalidate(cacheMgr, cache.CacheInvalidateConfig{
TagSpecs: []cache.CacheTagSpec{
{Name: "project-list", TenantID: true},
},
}),
)Policies:
- AuthRequired strict
- TenantRequired
- RequirePerm for write permission
- Rate limit by tenant
- CacheInvalidate to bump project-list scope
Example: DELETE /api/v1/projects/{id}
r.Handle(http.MethodDelete, "/api/v1/projects/{id}", handler,
policy.AuthRequired(authEngine, auth.ModeStrict),
policy.TenantRequired(),
policy.RequirePerm("project.delete"),
policy.CacheInvalidate(cacheMgr, cache.CacheInvalidateConfig{
TagSpecs: []cache.CacheTagSpec{
{Name: "project", PathParams: []string{"id"}},
{Name: "project-list", TenantID: true},
},
}),
)// BAD — cache is checked before auth, could serve cached data to unauthenticated users
r.Handle(method, pattern, handler,
policy.CacheRead(cacheMgr, cfg),
policy.AuthRequired(authEngine, mode),
)Fix: Always put AuthRequired before CacheRead.
// BAD — all authenticated users share the same cache entry
policy.CacheRead(cacheMgr, cache.CacheReadConfig{
TTL: 30 * time.Second,
// No VaryBy.TenantID or VaryBy.UserID!
})This is now a fail-fast configuration error. Authenticated routes require VaryBy.TenantID or VaryBy.UserID.
// BAD — rate limit uses anon scope because auth hasn't run yet
r.Handle(method, pattern, handler,
policy.RateLimit(limiter, ratelimit.Rule{Scope: ratelimit.ScopeUser}),
policy.AuthRequired(authEngine, mode),
)Fix: Auth must come first so the rate limiter can key by user.
If you cache GET /api/v1/projects with TagSpecs: [{Name:"project-list", TenantID:true}] but forget to add matching CacheInvalidate tag specs on writes, list cache stays stale until TTL expires.
// Route: /api/v1/tenants/{id}
policy.TenantMatchFromPath("tenant_id") // WRONG — param is "id", not "tenant_id"Fix: Match the chi path parameter name exactly.
policy.RequirePerm()
policy.RequireAnyPerm()Both constructors require at least one non-empty permission and panic on invalid input.
Required environment:
AUTH_ENABLED=trueAUTH_MODE=jwt_only|hybrid|strictREDIS_ENABLED=truePOSTGRES_ENABLED=true
If auth is disabled, routes with AuthRequired will always return 401.
Required environment:
RATELIMIT_ENABLED=trueREDIS_ENABLED=true
Optional tuning:
RATELIMIT_FAIL_OPEN(defaulttruein non-prod,falsein prod)RATELIMIT_DEFAULT_LIMITRATELIMIT_DEFAULT_WINDOW
Required environment:
CACHE_ENABLED=trueREDIS_ENABLED=true
Optional tuning:
CACHE_FAIL_OPEN(defaulttruein non-prod,falsein prod)CACHE_DEFAULT_MAX_BYTES
When adding a new policy:
- Keep it stateless and constructor-injected.
- Use centralized envelope responses via
response.Error. - Use typed app error codes from
internal/core/errors/errors.go. - Add focused tests under
internal/core/policy/*_test.go. - Document required env/config and exact failure behavior in this file.
This keeps the template copy-paste friendly and production-safe by default.