Mosaic one model, many lenses

ADR 0012: Authorization enforcement on every route

  • Status: accepted
  • Date: 2026-09-30
  • Supersedes: nothing (extends the identity/IAM surface; the per-endpoint auth/min_role/authz/where keys 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:

  1. Public routes pass. route_policy(path, method) returns None for: a fixed infra set (/health, /openapi.json, /api/auth/login, the /voice widget page), contributed endpoints declared auth: none (no table row at all), and two documented families that keep their own model: triggers (their x-mosaic-token authn is their contract; machine-to-machine) and the gateway (ADR 0006 byte-passthrough; the backend enforces). Plus the app's declared public_routes — a top-level public_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.

  2. Authn — three ways.

    • A verified Bearer token: a local JWT (HS256, the secret from MOAIC_JWT_SECRET or the identity's jwt.secret_env) or, when an OIDC IdP is configured, an id_token (RS256).
    • The app's api key (x-api-key or Bearer, matching the api_key_env secret): a machine credential that authorizes as DEFAULT_ROLE.
    • Otherwise, on a route whose policy marks it anonymous-allowed (auth: optional endpoints): the caller is admitted and authorizes as guest.
    • No credential and no anonymous allowance → 401 (audited when the audit part is on).
  3. 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 (the app resource type is a synthetic entry covering app-level surfaces; scoped roles must include it, *-scoped roles cover it automatically):

    route familydecision
    aggregate list/get, projections, ruleset-evaluate sourcesview on the aggregate's resource type
    CQRS command-delegate sourcescreate on the aggregate's resource type
    workflow-source endpoints, workflow run / resume, voice session upgradeupdate on app
    command_group sourcescreate on app
    callable rulesets (POST /api/rulesets/{kebab}), ruleset sourcesview on app
    workflow 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/meview on app
    knowledge write ops (ingest/reindex/cite/doc delete)update on app
    /api/sync/{mirror}/once, /api/flags/{name} set (PUT/POST)administrate on app
    /api/auditview on audit
    any other contributed-endpoint source familyadministrate on app — fail closed

    auth: optional contributes 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/where on a contributed endpoint still run in the handler (ABAC needs the body); auth: required remains authn-only and the table still applies on top.

  4. Stamp the effective principal. After the decision, the middleware sets x-authz-role / x-authz-user / x-authz-verified: 1, replacing any client-declared x-role — handler-level checks (and auth_me) see the verified principal, closing the role-spoofing hole for api-key and anonymous callers.

  5. 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 where condition: 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 identity app denies by default: bare requests get 401, wrong-role requests 403, and every route — derived, contributed, or platform — carries a decision. Public surface is declared, never implicit.
  • public_routes is the only new DSL key (top-level path-prefix list); the e2e token field is test tooling. The rest reuses identity, can, and the endpoint keys.
  • Existing apps without identity are byte-identical (no middleware, no table — verified against the pinned goldens); apps with identity that relied on open derived reads must either add public_routes or issue tokens — intended, and visible at the first request, not in the data model. (No shipped example declared identity before 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/login or carry as-is — client convenience, not enforcement.
  • The proof (ADR gate) is a new small example (examples/authorized): one aggregate, identity with two local users (a viewer and an editor), one auth: none endpoint, one public_routes entry, the metrics platform part. Its e2e block (CI: the e2e-app job) asserts: /health + /api/auth/login open (login 401 on bad credentials, 200 + token on good); /metrics and /api/iam 401 without a token, 200 with a viewer token; public_routes match → 200 with no token; the CQRS delegate 401 without a token, 403 for viewer (no create on the aggregate), 200 for editor; auth: none endpoint 200 with no token.