feat(authz): add Cedar-based authorization tier with deferred API-key authn#10
Merged
Conversation
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>
…c package todo-only
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>
…mpose curl comment
jmgilman
added a commit
that referenced
this pull request
Jun 24, 2026
jmgilman
added a commit
that referenced
this pull request
Jun 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
cedar-go, committed as the engine (no vendor-neutral portability layer) — we expose Cedar's real types (Request/Decision/Diagnostic/PolicySet) behind a thininternal/authzpackage.internal/<domain>/authz, merged into onePolicySet+ composite entity getter at the composition root — the same vertical-slice pattern as the HTTP registrar.authz.Require(...)orauthz.Public(); undeclared → 403.Requirealso stamps the OpenAPI security requirement so protection is visible in the generated docs.Resource = Todo::"<id>"with no DB load; attribute/relationship policies load entities lazily through a request-scoped, fail-closed getter.Authenticatoris a replaceable API-key adapter (X-API-Key / Bearer) backed by a PostgreSQLapi_keystable. Integrators swap it for real JWT/OIDC/session authn — seeDELETE_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:
internal/authzpackage + API-key authn +api_keysmigration + config flags + mockery doubles (wired inert;--authz-enabledoff until routes were tagged).todo/authzslice, route tagging, URL-id binding, the composite-getter precedence guard, and flipping--authz-enabledto true. Caught and fixed a latent enforcement bug (Huma freezes per-operation middleware at register time, so authz had to be installed beforehuma.Registerand the OpenAPI security stamped after).APIKeyStoreadapter and an end-to-end functional authz suite (no key → 401, wrong role → 403, user/admin → allowed) ininternal/integration.hack/sqlmock-keys seed, README/DELETE_ME/docs, and a livedocker compose upfunctional check of the day-one demo.Day-one demo
docker compose up --buildbrings uppostgres → migrate → seed → apiand seeds two dev-only mock keys. The seed lives inhack/sql/(applied only to the ephemeral compose DB), never in a migration, so the mock credentials can never reach a real deployment.Config
--authz-enabled(defaulttrue) — master switch / escape hatch.--authz-policy-dir— load.cedarfiles from a directory instead of the embedded set (fails fast on a missing/empty/invalid dir).api_keystable (--database-urlis already required).Testing
moon run root:checkgreen (build, lint, unit tests, openapi-check, sqlc-check, mockery-check, docs).moon run test-integrationgreen against a realpostgres:17-alpinecontainer (the postgresAPIKeyStoreadapter + the full e2e authz matrix). Defaultgo test ./...stays hermetic (integration tests are build-tagged).Removal
DELETE_ME.mddocuments removing the tier surgically: replace the API-key authenticator (#1), then deleteinternal/authz(incl.apikey),internal/todo/authz, the00002migration +0002seed, untag the routes, and drop the--authz-*config. No sqlc regeneration is needed (omit_unused_structskeeps the todo sqlc package free of theapi_keysmodel).Follow-up (pre-existing, unchanged)
test-integrationis still not wired into CI — the GitHub workflows remain.disabledpending a Docker-capable runner.🤖 Generated with Claude Code