61 lines
3.0 KiB
Markdown
61 lines
3.0 KiB
Markdown
|
|
# ADR-003: Composable Three-Middleware Auth Stack
|
||
|
|
|
||
|
|
**Status:** Accepted
|
||
|
|
**Date:** 2026-03-18
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
HTTP authentication and authorisation involve at least three distinct concerns:
|
||
|
|
|
||
|
|
1. **Token verification**: Is this JWT valid and non-revoked? Who issued it?
|
||
|
|
2. **Identity enrichment**: Given the verified UID, what role and tenant does this
|
||
|
|
user have in the application?
|
||
|
|
3. **Permission enforcement**: Does this identity have the required permission on
|
||
|
|
this resource?
|
||
|
|
|
||
|
|
A single "auth middleware" that handles all three concerns is a common pattern, but
|
||
|
|
it creates problems:
|
||
|
|
|
||
|
|
- Enrichment requires a user-store lookup. Not every route needs enrichment (e.g.
|
||
|
|
public endpoints that only verify the token is structurally valid).
|
||
|
|
- Permission enforcement is per-resource and per-route. A single monolithic
|
||
|
|
middleware cannot vary by route without routing logic inside it.
|
||
|
|
- Testing each concern in isolation becomes difficult when they are combined.
|
||
|
|
|
||
|
|
## Decision
|
||
|
|
|
||
|
|
Three separate middleware functions are provided:
|
||
|
|
|
||
|
|
- **`AuthMiddleware(verifier, publicPaths)`**: Verifies the Bearer token using
|
||
|
|
Firebase. Stores `uid` and `claims` in context. Returns 401 on missing or invalid
|
||
|
|
token. Routes matching `publicPaths` (glob patterns) are bypassed entirely.
|
||
|
|
- **`EnrichmentMiddleware(enricher, opts...)`**: Reads `uid` + `claims` from context.
|
||
|
|
Calls `IdentityEnricher.Enrich` (app-provided). Stores `rbac.Identity` in context.
|
||
|
|
Returns 401 if no UID is present (i.e. `AuthMiddleware` was not in the chain).
|
||
|
|
Optionally reads a tenant ID from a configurable request header.
|
||
|
|
- **`AuthzMiddleware(provider, resource, required)`**: Reads `rbac.Identity` from
|
||
|
|
context. Calls `PermissionProvider.ResolveMask` to get the permission mask for the
|
||
|
|
identity's UID on the named resource. Returns 401 if no identity is present, 403
|
||
|
|
if the mask does not include the required permission.
|
||
|
|
|
||
|
|
Each function returns a `func(http.Handler) http.Handler`, composable with any
|
||
|
|
standard middleware stack.
|
||
|
|
|
||
|
|
## Consequences
|
||
|
|
|
||
|
|
- Each middleware can be tested in isolation. `AuthMiddleware` tests do not need a
|
||
|
|
real user store; `EnrichmentMiddleware` tests do not need a Firebase client;
|
||
|
|
`AuthzMiddleware` tests do not need either.
|
||
|
|
- Routes can apply only the subset they need. A webhook endpoint that only verifies
|
||
|
|
token validity can use `AuthMiddleware` alone. A read-only endpoint can use
|
||
|
|
`AuthMiddleware` + `EnrichmentMiddleware` without `AuthzMiddleware`.
|
||
|
|
- The order constraint is enforced by dependency: `EnrichmentMiddleware` depends on
|
||
|
|
values written by `AuthMiddleware`; `AuthzMiddleware` depends on values written by
|
||
|
|
`EnrichmentMiddleware`. Incorrect ordering produces 401 (missing UID or identity)
|
||
|
|
rather than a silent bug.
|
||
|
|
- `publicPaths` in `AuthMiddleware` uses `path.Match` for glob matching. Patterns
|
||
|
|
like `/health`, `/metrics/*` can be listed to skip token verification entirely.
|
||
|
|
- `IdentityEnricher` and `PermissionProvider` are interfaces injected at
|
||
|
|
construction time, making the middleware fully testable without a real Firebase
|
||
|
|
connection or database.
|