ADR 0012: Authorization enforcement on every route
- Status: accepted
- Date: 2026-09-30
- Supersedes: nothing (extends the
identity/IAM surface; the per-endpointauth/min_role/authz/wherekeys keep working and keep winning)
Context
The README roadmap's second item. Today an app with identity declared has
authentication (JWT/OIDC verification, which stamps the verified
role/user onto the request) and per-endpoint opt-in authorization: a
contributed REST endpoint is checked only when the author wrote
auth: required (authn), min_role (RBAC level), authz
(can(role, action, resource)), or where (ABAC). Everything else is open.
The gap, found by auditing the generated router: in a full-stack app the
derived and platform routes carry no authorization decision at all —
GET /api/{aggregate}[/{id}], projections, workflow run/preview/
runs/pauses/resume, the generic callable-ruleset routes, the whole
knowledge/vectordb surface (incl. ingest/delete mutations),
/api/flags (incl. set), /api/audit, /api/events (SSE leaks event
payloads), /api/events/list, /metrics, /api/iam, /api/sync (incl.
once), the MCP server, and voice. An app that declares users and roles
still answers a bare GET /api/orders with its data. That is not
"authorization enforcement on every route."
The decision vocabulary already exists in the generated app: the role
ladder, ROLE_GRANTS/ROLE_DENIES (explicit deny wins over bypass),
can(role, action, resource_type), the dynamic resource types
(aggregates + domains), and the verified-identity headers. What is missing
is a decision for every route.
Decision
Enforcement is on when identity is declared (the app has
authentication); an app without identity is byte-identical (an internal
tool with no identity model has no principal to authorize — no middleware,
no table is emitted at all).
One middleware (authz_middleware, via from_fn_with_state) runs on every
route of both lenses (app and web) and makes the decision, in order:
-
Public routes pass.
route_policy(path, method)returnsNonefor: a fixed infra set (/health,/openapi.json,/api/auth/login, the/voicewidget page), contributed endpoints declaredauth: none(no table row at all), and two documented families that keep their own model: triggers (theirx-mosaic-tokenauthn is their contract; machine-to-machine) and the gateway (ADR 0006 byte-passthrough; the backend enforces). Plus the app's declaredpublic_routes— a top-levelpublic_routes: [<prefix>…]list of route prefixes kept open (exact or/-bounded prefix match; e.g. a public order-status read). Nothing else is public by default. -
Authn — three ways.
- A verified Bearer token: a local JWT (HS256, the secret from
MOAIC_JWT_SECRETor the identity'sjwt.secret_env) or, when an OIDC IdP is configured, anid_token(RS256). - The app's api key (
x-api-keyor Bearer, matching theapi_key_envsecret): a machine credential that authorizes asDEFAULT_ROLE. - Otherwise, on a route whose policy marks it anonymous-allowed
(
auth: optionalendpoints): the caller is admitted and authorizes asguest. - No credential and no anonymous allowance →
401(audited when the audit part is on).
- A verified Bearer token: a local JWT (HS256, the secret from
-
Authz by a generated route table. Codegen emits
route_policy(path, method) -> Option<(&str, &str, bool)>—(action, resource type, anonymous allowed)— one static entry per route, built from the same inventory as the router (route_chunks): every.route(...)in the generated chain is paired with its decision in one struct, so a new route family without a policy cannot be rendered. The default mapping (theappresource type is a synthetic entry covering app-level surfaces; scoped roles must include it,*-scoped roles cover it automatically):route family decision aggregate list/get, projections,ruleset-evaluatesourcesviewon the aggregate's resource typeCQRS command-delegate sources createon the aggregate's resource typeworkflow-source endpoints, workflowrun/resume, voice session upgradeupdateonappcommand_groupsourcescreateonappcallable rulesets ( POST /api/rulesets/{kebab}),rulesetsourcesviewonappworkflow preview/runs/runs/{id}/pauses,/api/resources/{name}, knowledge read ops (search/stats/docs/graph/citations/profile/books/report/index),/api/assistant/chat, the MCP server,/api/flags(get),/api/events,/api/events/list,/metrics,/api/iam,/api/sync(status),/api/auth/meviewonappknowledge write ops ( ingest/reindex/cite/docdelete)updateonapp/api/sync/{mirror}/once,/api/flags/{name}set (PUT/POST)administrateonapp/api/auditviewonauditany other contributed-endpoint source family administrateonapp— fail closedauth: optionalcontributes only the third element (anonymous allowed) — the (action, resource) decision still applies to the admitted guest. The per-endpoint keys keep their meaning and win over the table:min_role/authz/whereon a contributed endpoint still run in the handler (ABAC needs the body);auth: requiredremains authn-only and the table still applies on top. -
Stamp the effective principal. After the decision, the middleware sets
x-authz-role/x-authz-user/x-authz-verified: 1, replacing any client-declaredx-role— handler-level checks (andauth_me) see the verified principal, closing the role-spoofing hole for api-key and anonymous callers. -
Fail closed. Unknown route pattern in the table (should not happen — it is generated from the same inventory), an unknown role (no grant row), or a JS error in a
wherecondition: deny. Denied decisions go to the audit trail (authz.deny) when the audit platform part is on.
The table is generated, not configured: the enforcement completeness check
is structural (one RouteChunk per route, policy attached), and a codegen
test asserts the emitted table covers the router's routes.
Testing the matrix — the e2e harness (mosaic test) gains a per-scenario
token: {user, role} field: it signs a local JWT (secret read from the
same e2e env: block the server gets, via the plan's jwt_secret_env) and
sends it as authorization: Bearer …, so scenarios can assert the full
401/403/200 matrix against the generated middleware.
Consequences
- An
identityapp denies by default: bare requests get401, wrong-role requests403, and every route — derived, contributed, or platform — carries a decision. Public surface is declared, never implicit. public_routesis the only new DSL key (top-level path-prefix list); the e2etokenfield is test tooling. The rest reusesidentity,can, and the endpoint keys.- Existing apps without
identityare byte-identical (no middleware, no table — verified against the pinned goldens); apps withidentitythat relied on open derived reads must either addpublic_routesor issue tokens — intended, and visible at the first request, not in the data model. (No shipped example declaredidentitybefore this ADR.) - Both lenses enforce (the web server serves the same API for SSR); the
generated CLI/TUI pass a Bearer token they obtain from
/api/auth/loginor carry as-is — client convenience, not enforcement. - The proof (ADR gate) is a new small example (
examples/authorized): one aggregate,identitywith two local users (aviewerand aneditor), oneauth: noneendpoint, onepublic_routesentry, themetricsplatform part. Its e2e block (CI: thee2e-appjob) asserts:/health+/api/auth/loginopen (login 401 on bad credentials, 200 + token on good);/metricsand/api/iam401 without a token, 200 with aviewertoken;public_routesmatch → 200 with no token; the CQRS delegate 401 without a token, 403 forviewer(nocreateon the aggregate), 200 foreditor;auth: noneendpoint 200 with no token.