Skip to content

feat(authz): add Cedar-based authorization tier with deferred API-key authn#10

Merged
jmgilman merged 10 commits into
masterfrom
feat/authz-tier
Jun 24, 2026
Merged

feat(authz): add Cedar-based authorization tier with deferred API-key authn#10
jmgilman merged 10 commits into
masterfrom
feat/authz-tier

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

Summary

Adds an authorization tier to the template: a deny-by-default authorization decision expressed as middleware between endpoints, with authentication deliberately deferred to the integrator. The goal is a sensible, loosely-coupled starting point that morphs into whatever a consumer needs — not a bundled identity provider.

  • Engine: AWS Cedar via cedar-go, committed as the engine (no vendor-neutral portability layer) — we expose Cedar's real types (Request/Decision/Diagnostic/PolicySet) behind a thin internal/authz package.
  • Modular, per-resource authz slices: each domain contributes its policies, action identifiers, and a fact resolver from internal/<domain>/authz, merged into one PolicySet + composite entity getter at the composition root — the same vertical-slice pattern as the HTTP registrar.
  • One global Huma middleware, deny-by-default: every operation must declare authz.Require(...) or authz.Public(); undeclared → 403. Require also stamps the OpenAPI security requirement so protection is visible in the generated docs.
  • URL-fed resource identity: by-id routes bind the path param to Resource = Todo::"<id>" with no DB load; attribute/relationship policies load entities lazily through a request-scoped, fail-closed getter.
  • Authentication is deferred: the shipped Authenticator is a replaceable API-key adapter (X-API-Key / Bearer) backed by a PostgreSQL api_keys table. Integrators swap it for real JWT/OIDC/session authn — see DELETE_ME.md.

The domain core (internal/todo) stays Cedar-free.

How it was built (four gated phases)

Each phase ran implement → 3-lens adversarial review (correctness · doc-adherence · conventions) → fix → validate, with a human gate between phases:

  • A — base internal/authz package + API-key authn + api_keys migration + config flags + mockery doubles (wired inert; --authz-enabled off until routes were tagged).
  • B — the todo/authz slice, route tagging, URL-id binding, the composite-getter precedence guard, and flipping --authz-enabled to true. Caught and fixed a latent enforcement bug (Huma freezes per-operation middleware at register time, so authz had to be installed before huma.Register and the OpenAPI security stamped after).
  • C — container-backed integration tests: the real PostgreSQL APIKeyStore adapter and an end-to-end functional authz suite (no key → 401, wrong role → 403, user/admin → allowed) in internal/integration.
  • D — the dev-only hack/sql mock-keys seed, README/DELETE_ME/docs, and a live docker compose up functional check of the day-one demo.

Day-one demo

docker compose up --build brings up postgres → migrate → seed → api and seeds two dev-only mock keys. The seed lives in hack/sql/ (applied only to the ephemeral compose DB), never in a migration, so the mock credentials can never reach a real deployment.

curl -sS localhost:8080/todos                              # => 401 (deny-by-default)
curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # => 200

Config

  • --authz-enabled (default true) — master switch / escape hatch.
  • --authz-policy-dir — load .cedar files from a directory instead of the embedded set (fails fast on a missing/empty/invalid dir).
  • API keys live in the api_keys table (--database-url is already required).

Testing

  • moon run root:check green (build, lint, unit tests, openapi-check, sqlc-check, mockery-check, docs).
  • moon run test-integration green against a real postgres:17-alpine container (the postgres APIKeyStore adapter + the full e2e authz matrix). Default go test ./... stays hermetic (integration tests are build-tagged).
  • The compose day-one demo verified live end-to-end (401 without a key; 200/201 with the seeded keys; admin override; Bearer credential; by-id routes).

Removal

DELETE_ME.md documents removing the tier surgically: replace the API-key authenticator (#1), then delete internal/authz (incl. apikey), internal/todo/authz, the 00002 migration + 0002 seed, untag the routes, and drop the --authz-* config. No sqlc regeneration is needed (omit_unused_structs keeps the todo sqlc package free of the api_keys model).

Follow-up (pre-existing, unchanged)

test-integration is still not wired into CI — the GitHub workflows remain .disabled pending a Docker-capable runner.

🤖 Generated with Claude Code

jmgilman and others added 10 commits June 23, 2026 17:47
Introduce the cross-cutting authorization engine wrapping AWS Cedar
(cedar-go), with no portability layer:

- internal/authz: Authorizer over a merged PolicySet (slice-prefixed
  policy IDs), Contribution model, opaque Principal + context helpers,
  Authenticator seam, request-scoped lazy composite EntityGetter (caches
  per request, captures first load error for fail-closed handling),
  Require/Public declarations (Require also populates an OpenAPI Security
  requirement), embedded base.cedar (admin-role override), and a global
  Huma middleware (deny-by-default; 401/403/500; RFC 9457 via the shared
  problem writer). An always-present principal resolver projects role
  claims onto the principal entity's parents for `principal in Role::"…"`.
- internal/authz/apikey: API-key Authenticator over an APIKeyStore port
  with a self-contained PostgreSQL adapter (hand-written parameterized
  query, no second sqlc package); api_keys goose migration. The key is
  never logged; hashing + constant-time compare noted as the hardening
  path.
- config: --authz-enabled (default false this phase) and
  --authz-policy-dir. App wiring builds the Authorizer from an empty
  contribution set and the postgres-backed authenticator.
- access log documents that credential headers are never logged.
- mockery doubles for Authenticator, EntityResolver, APIKeyStore.

CRITICAL SEQUENCING: with an empty contribution set and deny-by-default,
enabling authz would 403 every untagged route. The middleware is inert
(pass-through) when --authz-enabled=false, the Phase A default, so the
app and all existing tests stay green. Phase B tags routes and flips the
default to true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire --authz-policy-dir through authz.New (WithPolicyDir), so a set policy
directory loads .cedar files instead of silently using embedded base.cedar;
a missing/empty/invalid directory fails startup rather than no-opping.

Populate Operation.Security via ApplySecurity at install time so Require'd
operations advertise their security scheme in the generated OpenAPI doc
(Metadata is yaml:"-" and never reaches the spec); drop the metadata
"security" key and the registrar-copies-it contract.

Make decisionError the zero value of outcome and add a fail-closed default
so the decision pipeline is deny-by-default by construction. Route the authn
middleware through WithPrincipal. Reclassify cedar-go as a direct dependency
(go mod tidy). Drop phase/design-doc/DELETE_ME pointers from godoc, fix the
Authorize ctx-param comment, soften the apikey self-contained claim, and note
authz wiring in the app package doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry-forward base-engine work for the todo authz slice (phase B):

- Type-ownership precedence fix: Contribution gains a static Types field;
  New() fails fast if two contributions claim the same Cedar entity type or a
  slice claims a reserved principal type (User/Anonymous). The composite getter
  routes by the declared Types, not the resolver's runtime Types(), so a slice
  resolver can never shadow the always-present principal resolver.
- URL-id -> Resource binding: resourceFor reads decl.idParam via ctx.Param and
  builds the instance Resource Todo::"<id>" from the matched route, with no load.
- Install/Finalize split: Huma snapshots the middleware stack into each operation
  at huma.Register time, so the authn/authz middleware must be installed BEFORE
  registration. Install now only registers the middleware; Finalize stamps the
  OpenAPI security after registration. The router installs pre-register and
  finalizes post-register.
- Server-less OpenAPI export gains a finalize hook so SpecYAML applies the
  security scheme + requirements (DocumentSecurity), keeping the committed spec
  in step with the enforced protection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- todo/authz slice (package authz): typed action UIDs (Action::"todo:*"),
  embedded coarse policy.cedar (grants todo actions to the "user" role; admin via
  base.cedar) with a commented attribute-policy example, a repo-backed lazy fact
  resolver mapping todo.Todo -> Todo cedar.Entity from existing fields only, and
  Contribution(repo).
- Tag every todo route with authz.Require; by-id ops bind the {id} path param so
  Resource = Todo::"<id>".
- Composition root builds the Authorizer from the todo Contribution and wires the
  repo for lazy loads; add app.WithAuthenticator test seam mirroring
  WithRepository so tests authenticate without a database.
- Flip --authz-enabled default to true now that routes are tagged.
- Refresh docs/docs/openapi.yaml: the tagged routes now carry the apiKey security
  scheme + per-operation requirements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- getter: route the base principal resolver under the principal's actual
  UID type, not just its static reserved types, so a custom Authenticator
  minting a non-User principal (e.g. Service::"x") still resolves and
  projects its role parents; chain it ahead of any slice owning the same
  custom type so neither shadows the other (restores pre-Phase-B behavior
  while keeping the shadow-prevention guarantee).
- authz: fail construction when a contribution supplies a Resolver but no
  Types (it would be routed under zero keys and never invoked).
- apikey: mint the principal under authz.PrincipalType instead of a
  duplicated local const, making the routing coupling explicit.
- middleware: an undeclared (deny-by-default) operation now returns 403
  unconditionally (never 401), matching the design's undeclared=deny-403
  framing — a credential can never satisfy a route with no requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rage (phase C)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r (phase D)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jmgilman jmgilman merged commit 13a1fe5 into master Jun 24, 2026
7 checks passed
@jmgilman jmgilman deleted the feat/authz-tier branch June 24, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant