From edc53c17bec0a9b97a5fbc8f88fc13d53d5ee42a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 17:47:50 -0700 Subject: [PATCH 01/10] feat(authz): add base authz package + API-key authn (phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .mockery.yaml | 7 + go.mod | 2 + go.sum | 4 + internal/adapter/http/router.go | 12 + .../migrations/00002_create_api_keys.sql | 9 + internal/app/app.go | 47 +++- internal/authz/apikey/apikey.go | 124 +++++++++ internal/authz/apikey/apikey_test.go | 116 ++++++++ .../authz/apikey/mocks/mock_APIKeyStore.go | 111 ++++++++ internal/authz/apikey/store.go | 55 ++++ internal/authz/authn.go | 16 ++ internal/authz/authz.go | 93 +++++++ internal/authz/base.cedar | 14 + internal/authz/contribution.go | 45 ++++ internal/authz/declare.go | 130 +++++++++ internal/authz/declare_test.go | 106 ++++++++ internal/authz/getter.go | 124 +++++++++ internal/authz/getter_test.go | 105 ++++++++ internal/authz/middleware.go | 212 +++++++++++++++ internal/authz/middleware_test.go | 247 ++++++++++++++++++ internal/authz/mocks/mock_Authenticator.go | 98 +++++++ internal/authz/mocks/mock_EntityResolver.go | 143 ++++++++++ internal/authz/principal.go | 69 +++++ internal/authz/principal_resolver.go | 87 ++++++ internal/config/config.go | 28 ++ internal/config/config_test.go | 18 ++ internal/observability/requestlog.go | 5 + internal/todo/postgres/sqlc/models.go | 6 + moon.yml | 16 +- 29 files changed, 2046 insertions(+), 3 deletions(-) create mode 100644 internal/adapter/postgres/migrations/00002_create_api_keys.sql create mode 100644 internal/authz/apikey/apikey.go create mode 100644 internal/authz/apikey/apikey_test.go create mode 100644 internal/authz/apikey/mocks/mock_APIKeyStore.go create mode 100644 internal/authz/apikey/store.go create mode 100644 internal/authz/authn.go create mode 100644 internal/authz/authz.go create mode 100644 internal/authz/base.cedar create mode 100644 internal/authz/contribution.go create mode 100644 internal/authz/declare.go create mode 100644 internal/authz/declare_test.go create mode 100644 internal/authz/getter.go create mode 100644 internal/authz/getter_test.go create mode 100644 internal/authz/middleware.go create mode 100644 internal/authz/middleware_test.go create mode 100644 internal/authz/mocks/mock_Authenticator.go create mode 100644 internal/authz/mocks/mock_EntityResolver.go create mode 100644 internal/authz/principal.go create mode 100644 internal/authz/principal_resolver.go diff --git a/.mockery.yaml b/.mockery.yaml index dc3a200..455d74d 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -19,3 +19,10 @@ packages: github.com/meigma/template-go-api/internal/todo: interfaces: Repository: + github.com/meigma/template-go-api/internal/authz: + interfaces: + Authenticator: + EntityResolver: + github.com/meigma/template-go-api/internal/authz/apikey: + interfaces: + APIKeyStore: diff --git a/go.mod b/go.mod index f87d50d..44caa78 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cedar-policy/cedar-go v1.8.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -88,6 +89,7 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 8778786..9d2d36c 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cedar-policy/cedar-go v1.8.0 h1:9gcU7EHXwHC2RMdpph68yTAkdB3behTTssC+kt4GoS8= +github.com/cedar-policy/cedar-go v1.8.0/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -207,6 +209,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/adapter/http/router.go b/internal/adapter/http/router.go index 68026c1..52a5552 100644 --- a/internal/adapter/http/router.go +++ b/internal/adapter/http/router.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/danielgtaylor/huma/v2" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -38,6 +39,11 @@ type RouterDeps struct { Readiness []ReadinessCheck // Register mounts resource operations onto the Huma API. Register Registrar + // InstallAuthz installs the authentication/authorization Huma middleware on + // the API. It runs after the resource operations are registered. Nil (or a + // disabled middleware) leaves the API unauthenticated, which is the default + // until routes are tagged. + InstallAuthz func(huma.API) } // NewRouter assembles the chi router: the core middleware stack, RFC 9457 error @@ -82,6 +88,12 @@ func NewRouter(deps RouterDeps) http.Handler { if deps.Register != nil { deps.Register(api) } + // The authn/authz Huma middleware is installed after registration; it + // enforces per-operation declarations at request time, so registration order + // is irrelevant. It is a no-op when authorization is disabled. + if deps.InstallAuthz != nil { + deps.InstallAuthz(api) + } // Infrastructure routes stay raw chi and are excluded from the spec. mountInfra(mux, deps.Metrics, deps.Readiness, deps.ServeMetricsEndpoint) diff --git a/internal/adapter/postgres/migrations/00002_create_api_keys.sql b/internal/adapter/postgres/migrations/00002_create_api_keys.sql new file mode 100644 index 0000000..cc70f3c --- /dev/null +++ b/internal/adapter/postgres/migrations/00002_create_api_keys.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE TABLE api_keys ( + key text PRIMARY KEY, + subject text NOT NULL, + roles text[] NOT NULL DEFAULT '{}' +); + +-- +goose Down +DROP TABLE api_keys; diff --git a/internal/app/app.go b/internal/app/app.go index 2b9d14f..361d1d3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ package app import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -15,6 +16,8 @@ import ( adapterhttp "github.com/meigma/template-go-api/internal/adapter/http" "github.com/meigma/template-go-api/internal/adapter/postgres" + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/authz/apikey" "github.com/meigma/template-go-api/internal/config" "github.com/meigma/template-go-api/internal/observability" "github.com/meigma/template-go-api/internal/todo" @@ -74,6 +77,11 @@ func New( service := todo.NewService(repo, logger) metrics := observability.NewMetrics() + installAuthz, err := authzInstaller(cfg, pool, logger) + if err != nil { + return nil, err + } + // An empty metrics-addr co-locates /metrics on the API listener; otherwise a // dedicated metrics server (below) serves it off the API surface. serveMetricsInline := cfg.MetricsAddr == "" @@ -87,8 +95,9 @@ func New( TrustedProxyHeader: cfg.TrustedProxyHeader, // The postgres store contributes a real connectivity check here; an // injected repository (tests) contributes none, so /readyz is always ready. - Readiness: readiness, - Register: registerResources(service), + Readiness: readiness, + Register: registerResources(service), + InstallAuthz: installAuthz, }) server := &http.Server{ @@ -145,6 +154,40 @@ func resolveStore( return repo, pool, readiness, nil } +// authzInstaller builds the authorization engine and returns a hook that +// installs the authn/authz Huma middleware on the API. The Authorizer is built +// from an empty contribution set (only the base cross-cutting policies) this +// phase; a later phase passes each domain slice's Contribution. The API-key +// authenticator is PostgreSQL-backed, so it requires a pool when authorization +// is enabled. The middleware is inert when cfg.AuthzEnabled is false, which is +// the default until routes are tagged — keeping every untagged route working. +func authzInstaller( + cfg config.Config, + pool *pgxpool.Pool, + logger *slog.Logger, +) (func(huma.API), error) { + authorizer, err := authz.New(nil) + if err != nil { + return nil, fmt.Errorf("build authorizer: %w", err) + } + + // The authenticator resolves keys from PostgreSQL. When authorization is + // enabled it needs a pool (an injected-repository wiring has none); when + // disabled the middleware never runs, so a nil store is harmless. + if cfg.AuthzEnabled && pool == nil { + return nil, errors.New("authz-enabled requires a database connection for the api-key store") + } + + var authenticator authz.Authenticator + if pool != nil { + authenticator = apikey.NewAuthenticator(apikey.NewStore(pool)) + } + + return func(api huma.API) { + authz.NewMiddleware(api, authenticator, authorizer, logger, cfg.AuthzEnabled).Install() + }, nil +} + // Handler returns the assembled HTTP handler, primarily for functional tests. func (a *App) Handler() http.Handler { return a.server.Handler diff --git a/internal/authz/apikey/apikey.go b/internal/authz/apikey/apikey.go new file mode 100644 index 0000000..03e749f --- /dev/null +++ b/internal/authz/apikey/apikey.go @@ -0,0 +1,124 @@ +// Package apikey is the template's default Authenticator: it reads an API-key +// credential from a request and resolves it to an authz.Principal through the +// APIKeyStore port. It is the deferred-authn placeholder — a real but minimal +// mechanism that demonstrates the full authorization flow and is trivially +// removable (delete this package and the api_keys migration). Integrators +// replace it with a real verifier (JWT/OIDC/session). +// +// The shipped store is PostgreSQL-backed (store.go); keys live in an api_keys +// table since the template is postgres-only. The package is self-contained and +// hand-writes its single query rather than adding a second sqlc package, so +// removal stays surgical. +package apikey + +import ( + "context" + "errors" + "strings" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + + "github.com/meigma/template-go-api/internal/authz" +) + +// bearerPrefix is the scheme prefix of an Authorization: Bearer credential. +const bearerPrefix = "Bearer " + +// principalType is the Cedar entity type assigned to an API-key principal. Its +// id is the key's subject (for example User::"alice"). +const principalType types.EntityType = "User" + +// ErrInvalidKey is returned when a credential is present but does not resolve to +// a known principal. The authn middleware maps it to 401. A request with no +// credential is not an error — it yields the anonymous principal. +var ErrInvalidKey = errors.New("invalid api key") + +// APIKeyStore is the outbound port that resolves an API key to its principal. It +// is declared here, by its consumer, and implemented by adapters (the shipped +// PostgreSQL Store, or a mock in tests). +// +//nolint:revive // APIKeyStore is the name fixed by the authz design for this port. +type APIKeyStore interface { + // Lookup returns the subject and roles bound to key. The boolean is false + // when no row matches the key (an unknown key, not an error); err is + // non-nil only on a store failure. Implementations must never log the key. + Lookup(ctx context.Context, key string) (Identity, bool, error) +} + +// Identity is the principal data a store binds to an API key: the caller's +// subject and the roles granted to it. +type Identity struct { + // Subject identifies the caller (becomes the principal entity's id). + Subject string + // Roles are the caller's role names (projected onto the principal's parents + // as Role::"" and recorded under the roles claim). + Roles []string +} + +// Authenticator resolves an API-key credential to an authz.Principal via a +// store. It satisfies authz.Authenticator. +type Authenticator struct { + store APIKeyStore +} + +// NewAuthenticator constructs an Authenticator backed by store. +func NewAuthenticator(store APIKeyStore) *Authenticator { + return &Authenticator{store: store} +} + +// Authenticate reads the API key from the request and resolves it. With no +// credential it returns the anonymous principal and no error, so public +// operations still work; with an unknown or store-failed credential it returns +// an error, which the middleware maps to 401. +func (a *Authenticator) Authenticate(ctx huma.Context) (authz.Principal, error) { + key := credentialFrom(ctx) + if key == "" { + return authz.Anonymous(), nil + } + + identity, ok, err := a.store.Lookup(ctx.Context(), key) + if err != nil { + // Wrap without the key: the key must never reach a log line. + return authz.Anonymous(), errors.New("api key lookup failed") + } + if !ok { + return authz.Anonymous(), ErrInvalidKey + } + + return toPrincipal(identity), nil +} + +// credentialFrom extracts the API key from the request, preferring the X-API-Key +// header and falling back to an Authorization: Bearer credential. It returns the +// empty string when neither is present. +func credentialFrom(ctx huma.Context) string { + if key := strings.TrimSpace(ctx.Header(authz.APIKeyHeader)); key != "" { + return key + } + + authorization := strings.TrimSpace(ctx.Header("Authorization")) + if rest, found := strings.CutPrefix(authorization, bearerPrefix); found { + return strings.TrimSpace(rest) + } + + return "" +} + +// toPrincipal maps a resolved Identity to an authz.Principal: the subject +// becomes the User entity and the roles are recorded under the shared roles +// claim, which the base principal resolver projects onto the principal entity's +// Role parents so policies can match `principal in Role::"…"` with no load. +func toPrincipal(identity Identity) authz.Principal { + roleValues := make([]types.Value, 0, len(identity.Roles)) + for _, role := range identity.Roles { + roleValues = append(roleValues, types.String(role)) + } + + return authz.Principal{ + UID: types.NewEntityUID(principalType, types.String(identity.Subject)), + Claims: types.NewRecord(types.RecordMap{ + authz.RolesClaim: types.NewSet(roleValues...), + }), + } +} diff --git a/internal/authz/apikey/apikey_test.go b/internal/authz/apikey/apikey_test.go new file mode 100644 index 0000000..e5ff08e --- /dev/null +++ b/internal/authz/apikey/apikey_test.go @@ -0,0 +1,116 @@ +package apikey_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/authz/apikey" + "github.com/meigma/template-go-api/internal/authz/apikey/mocks" +) + +// contextWith builds a huma.Context carrying the given headers, so the +// authenticator's credential extraction can be exercised without a server. +func contextWith(headers map[string]string) huma.Context { + r := httptest.NewRequest(http.MethodGet, "/", nil) + for k, v := range headers { + r.Header.Set(k, v) + } + + return humatest.NewContext(&huma.Operation{}, r, httptest.NewRecorder()) +} + +func TestAuthenticateResolvesAPIKeyHeader(t *testing.T) { + t.Parallel() + + store := mocks.NewAPIKeyStore(t) + store.EXPECT().Lookup(mock.Anything, "secret-key"). + Return(apikey.Identity{Subject: "alice", Roles: []string{"admin"}}, true, nil) + + auth := apikey.NewAuthenticator(store) + principal, err := auth.Authenticate(contextWith(map[string]string{authz.APIKeyHeader: "secret-key"})) + require.NoError(t, err) + + assert.Equal(t, types.NewEntityUID("User", "alice"), principal.UID) + assert.False(t, principal.IsAnonymous()) + + roles, ok := principal.Claims.Get(authz.RolesClaim) + require.True(t, ok, "resolved roles must be recorded on the claims") + assert.Equal(t, types.NewSet(types.String("admin")), roles) +} + +func TestAuthenticateAcceptsBearerCredential(t *testing.T) { + t.Parallel() + + store := mocks.NewAPIKeyStore(t) + store.EXPECT().Lookup(mock.Anything, "bearer-key"). + Return(apikey.Identity{Subject: "bob"}, true, nil) + + auth := apikey.NewAuthenticator(store) + principal, err := auth.Authenticate(contextWith(map[string]string{"Authorization": "Bearer bearer-key"})) + require.NoError(t, err) + assert.Equal(t, types.NewEntityUID("User", "bob"), principal.UID) +} + +func TestAuthenticatePrefersAPIKeyHeaderOverBearer(t *testing.T) { + t.Parallel() + + store := mocks.NewAPIKeyStore(t) + store.EXPECT().Lookup(mock.Anything, "header-key"). + Return(apikey.Identity{Subject: "alice"}, true, nil) + + auth := apikey.NewAuthenticator(store) + _, err := auth.Authenticate(contextWith(map[string]string{ + authz.APIKeyHeader: "header-key", + "Authorization": "Bearer ignored", + })) + require.NoError(t, err) +} + +func TestAuthenticateWithoutCredentialIsAnonymous(t *testing.T) { + t.Parallel() + + // No Lookup is expected: absence of a credential must not hit the store. + store := mocks.NewAPIKeyStore(t) + + auth := apikey.NewAuthenticator(store) + principal, err := auth.Authenticate(contextWith(nil)) + require.NoError(t, err) + assert.True(t, principal.IsAnonymous()) +} + +func TestAuthenticateUnknownKeyIsInvalid(t *testing.T) { + t.Parallel() + + store := mocks.NewAPIKeyStore(t) + store.EXPECT().Lookup(mock.Anything, "nope").Return(apikey.Identity{}, false, nil) + + auth := apikey.NewAuthenticator(store) + principal, err := auth.Authenticate(contextWith(map[string]string{authz.APIKeyHeader: "nope"})) + require.ErrorIs(t, err, apikey.ErrInvalidKey) + assert.True(t, principal.IsAnonymous()) +} + +func TestAuthenticateStoreFailureIsError(t *testing.T) { + t.Parallel() + + store := mocks.NewAPIKeyStore(t) + store.EXPECT().Lookup(mock.Anything, "boom").Return(apikey.Identity{}, false, errors.New("db down")) + + auth := apikey.NewAuthenticator(store) + principal, err := auth.Authenticate(contextWith(map[string]string{authz.APIKeyHeader: "boom"})) + require.Error(t, err) + require.NotErrorIs(t, err, apikey.ErrInvalidKey) + assert.True(t, principal.IsAnonymous()) + // The returned error must not contain the key. + assert.NotContains(t, err.Error(), "boom") +} diff --git a/internal/authz/apikey/mocks/mock_APIKeyStore.go b/internal/authz/apikey/mocks/mock_APIKeyStore.go new file mode 100644 index 0000000..4931867 --- /dev/null +++ b/internal/authz/apikey/mocks/mock_APIKeyStore.go @@ -0,0 +1,111 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/meigma/template-go-api/internal/authz/apikey" + mock "github.com/stretchr/testify/mock" +) + +// NewAPIKeyStore creates a new instance of APIKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAPIKeyStore(t interface { + mock.TestingT + Cleanup(func()) +}) *APIKeyStore { + mock := &APIKeyStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// APIKeyStore is an autogenerated mock type for the APIKeyStore type +type APIKeyStore struct { + mock.Mock +} + +type APIKeyStore_Expecter struct { + mock *mock.Mock +} + +func (_m *APIKeyStore) EXPECT() *APIKeyStore_Expecter { + return &APIKeyStore_Expecter{mock: &_m.Mock} +} + +// Lookup provides a mock function for the type APIKeyStore +func (_mock *APIKeyStore) Lookup(ctx context.Context, key string) (apikey.Identity, bool, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for Lookup") + } + + var r0 apikey.Identity + var r1 bool + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (apikey.Identity, bool, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) apikey.Identity); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Get(0).(apikey.Identity) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Get(1).(bool) + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = returnFunc(ctx, key) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// APIKeyStore_Lookup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lookup' +type APIKeyStore_Lookup_Call struct { + *mock.Call +} + +// Lookup is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *APIKeyStore_Expecter) Lookup(ctx any, key any) *APIKeyStore_Lookup_Call { + return &APIKeyStore_Lookup_Call{Call: _e.mock.On("Lookup", ctx, key)} +} + +func (_c *APIKeyStore_Lookup_Call) Run(run func(ctx context.Context, key string)) *APIKeyStore_Lookup_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *APIKeyStore_Lookup_Call) Return(identity apikey.Identity, b bool, err error) *APIKeyStore_Lookup_Call { + _c.Call.Return(identity, b, err) + return _c +} + +func (_c *APIKeyStore_Lookup_Call) RunAndReturn(run func(ctx context.Context, key string) (apikey.Identity, bool, error)) *APIKeyStore_Lookup_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/authz/apikey/store.go b/internal/authz/apikey/store.go new file mode 100644 index 0000000..42cbceb --- /dev/null +++ b/internal/authz/apikey/store.go @@ -0,0 +1,55 @@ +package apikey + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// lookupQuery resolves an API key to its subject and roles. It is hand-written +// and parameterized (so the key is never interpolated) to keep this package +// self-contained and trivially removable — it deliberately does not introduce a +// second sqlc package. +// +// SECURITY: this day-one implementation stores and matches keys verbatim. The +// production hardening path is to store only a hash of the key (for example +// SHA-256) and compare in constant time (crypto/subtle.ConstantTimeCompare) +// after hashing the presented key, so a leaked table dump does not reveal usable +// credentials and lookups are not timing-distinguishable. See DELETE_ME. +const lookupQuery = `SELECT subject, roles FROM api_keys WHERE key = $1` + +// Store is the PostgreSQL-backed APIKeyStore. It resolves keys against the +// api_keys table using the shared pgx pool. +type Store struct { + pool *pgxpool.Pool +} + +// NewStore constructs a Store over the shared connection pool. +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Lookup resolves key to its subject and roles. It returns (Identity, false, nil) +// when no row matches (an unknown key, not an error) and a non-nil error only on +// a query failure. The key is passed as a bind parameter and never logged. +func (s *Store) Lookup(ctx context.Context, key string) (Identity, bool, error) { + var ( + subject string + roles []string + ) + + err := s.pool.QueryRow(ctx, lookupQuery, key).Scan(&subject, &roles) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Identity{}, false, nil + } + + // Do not include key in the error: it must never reach a log line. + return Identity{}, false, fmt.Errorf("query api key: %w", err) + } + + return Identity{Subject: subject, Roles: roles}, true, nil +} diff --git a/internal/authz/authn.go b/internal/authz/authn.go new file mode 100644 index 0000000..903a184 --- /dev/null +++ b/internal/authz/authn.go @@ -0,0 +1,16 @@ +package authz + +import "github.com/danielgtaylor/huma/v2" + +// Authenticator inspects an incoming request and resolves the caller's identity. +// It is the deferred-authentication seam: the template ships an API-key +// authenticator (internal/authz/apikey), and integrators swap in a real +// verifier (JWT/OIDC/session) without touching the authz engine. +type Authenticator interface { + // Authenticate inspects ctx and returns a verified Principal, or + // (Anonymous, nil) when no credentials are present — public operations must + // still work, so absence of credentials is not an error here. It returns an + // error only when a credential is present but malformed or invalid, which + // the authn middleware maps to 401. + Authenticate(ctx huma.Context) (Principal, error) +} diff --git a/internal/authz/authz.go b/internal/authz/authz.go new file mode 100644 index 0000000..d34311e --- /dev/null +++ b/internal/authz/authz.go @@ -0,0 +1,93 @@ +package authz + +import ( + _ "embed" + "fmt" + + "github.com/cedar-policy/cedar-go" +) + +// basePolicies holds the cross-cutting policies merged ahead of every slice's +// contribution. Embedded so the template authorizes out of the box with no +// external policy files. +// +//go:embed base.cedar +var basePolicies []byte + +// baseSlice is the synthetic slice name used to prefix policy IDs from +// base.cedar during the merge, keeping them distinct from any domain slice. +const baseSlice = "base" + +// Authorizer evaluates a Cedar request against the merged PolicySet. It is the +// single decision point the middleware calls; the resolver/getter supplies the +// entities Cedar dereferences on demand. +type Authorizer struct { + policies *cedar.PolicySet + // contributions is retained so the middleware can build the request-scoped + // composite getter from each slice's ResolverFactory. + contributions []Contribution +} + +// New merges base.cedar and every contribution's policies into one runtime +// PolicySet and returns an Authorizer. Policy IDs are re-assigned with a +// slice-prefixed, per-slice index ("#") so policies stay unique across +// slices after the merge. Passing no contributions yields an authorizer with +// only the base policies, which is the Phase A composition-root default. +func New(contributions []Contribution) (*Authorizer, error) { + merged := cedar.NewPolicySet() + + if err := mergePolicies(merged, baseSlice, basePolicies); err != nil { + return nil, fmt.Errorf("merge base policies: %w", err) + } + + for i, c := range contributions { + if len(c.Policies) == 0 { + continue + } + // Index the slice by position so two slices that omit a name still get + // distinct policy-ID prefixes. + slice := fmt.Sprintf("slice%d", i) + if err := mergePolicies(merged, slice, c.Policies); err != nil { + return nil, fmt.Errorf("merge contribution %d policies: %w", i, err) + } + } + + // Prepend the always-present principal resolver so cross-cutting and slice + // policies can test principal group membership (principal in Role::"…") + // without any slice contributing a principal resolver. + all := append([]Contribution{principalContribution()}, contributions...) + + return &Authorizer{policies: merged, contributions: all}, nil +} + +// mergePolicies parses document and adds each policy to dst under a +// slice-prefixed ID ("#"), so merged policy IDs are unique and trace +// back to their source slice. +func mergePolicies(dst *cedar.PolicySet, slice string, document []byte) error { + list, err := cedar.NewPolicyListFromBytes(slice+".cedar", document) + if err != nil { + return fmt.Errorf("parse policies: %w", err) + } + + for n, policy := range list { + id := cedar.PolicyID(fmt.Sprintf("%s#%d", slice, n)) + dst.Add(id, policy) + } + + return nil +} + +// Authorize evaluates req against the merged PolicySet using entities, returning +// Cedar's decision and diagnostic. entities is the request-scoped composite +// getter; Cedar pulls only the entities the applicable policies dereference. The +// ctx parameter is accepted for symmetry with the call site and future tracing; +// Cedar's evaluation itself takes no context. +func (a *Authorizer) Authorize(entities cedar.EntityGetter, req cedar.Request) (cedar.Decision, cedar.Diagnostic) { + return cedar.Authorize(a.policies, entities, req) +} + +// Contributions returns the slices the authorizer was built from, so the +// middleware can assemble the per-request composite getter. +func (a *Authorizer) Contributions() []Contribution { + return a.contributions +} diff --git a/internal/authz/base.cedar b/internal/authz/base.cedar new file mode 100644 index 0000000..13879ba --- /dev/null +++ b/internal/authz/base.cedar @@ -0,0 +1,14 @@ +// base.cedar holds the cross-cutting policies that belong to no single domain +// slice: the shared principal-role rules over the shared entity namespace. +// Slices contribute their own resource policies; these merge with the base set +// into one runtime PolicySet (see authz.New). + +// An admin role may do anything. Membership is carried on the principal's +// claims and projected onto the principal entity's parents at resolve time, so +// this evaluates with no entity load. This is the coarse, default reference +// rule; finer attribute/relationship policies live in each slice. +permit ( + principal in Role::"admin", + action, + resource +); diff --git a/internal/authz/contribution.go b/internal/authz/contribution.go new file mode 100644 index 0000000..c4f8b62 --- /dev/null +++ b/internal/authz/contribution.go @@ -0,0 +1,45 @@ +package authz + +import ( + "context" + + "github.com/cedar-policy/cedar-go/types" +) + +// Contribution is one domain slice's input to the unified authorization engine. +// Each slice ships its embedded Cedar policies, the action identifiers it +// declares (for validation and discovery), and a factory that builds its +// request-scoped entity resolver. The composition root collects all +// contributions and merges them in [New]. +type Contribution struct { + // Policies is the embedded .cedar source for this slice. Policy IDs are + // re-assigned slice-prefixed during the merge so they stay unique across + // slices. + Policies []byte + // Actions lists the action entities this slice declares (for example + // Action::"todo:read"). They are recorded for discovery and validation; the + // engine does not require them to evaluate. + Actions []types.EntityUID + // Resolver builds this slice's entity resolver, bound to a request. It is nil + // for slices that contribute only coarse policies needing no entity loads. + Resolver ResolverFactory +} + +// ResolverFactory builds a slice's EntityResolver for a single request, bound to +// the request context and the authenticated principal. Binding here is required +// because Cedar's pull interface (Get(uid) (Entity, bool)) carries neither a +// context nor an error — see [getter]. +type ResolverFactory func(ctx context.Context, p Principal) EntityResolver + +// EntityResolver sources the Cedar entities ("facts") a slice owns. It is +// narrower than Cedar's EntityGetter: the composite getter routes a lookup to +// the resolver that owns the entity's type and composes the per-request results. +type EntityResolver interface { + // Resolve returns the entity for uid and whether it was found. A miss + // (false) is distinct from a load error, which the resolver records on the + // bound context so the middleware can fail closed; see [getter]. + Resolve(uid types.EntityUID) (types.Entity, bool) + // Types lists the entity type names this resolver owns, so the composite + // getter can route lookups without trying every resolver. + Types() []string +} diff --git a/internal/authz/declare.go b/internal/authz/declare.go new file mode 100644 index 0000000..bcdb1af --- /dev/null +++ b/internal/authz/declare.go @@ -0,0 +1,130 @@ +package authz + +import ( + "strings" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" +) + +// metadataKey is the Operation.Metadata key under which a route's authorization +// declaration is recorded. The global middleware reads it to decide how to +// enforce the operation; an operation without it is denied (fail-closed). +const metadataKey = "authz" + +// SecuritySchemeName is the OpenAPI security-scheme identifier the API-key +// authenticator registers and that Require references, so protected operations +// advertise their requirement in the generated docs. +const SecuritySchemeName = "apiKey" + +// kind distinguishes the two authorization declarations a route may carry. +type kind int + +const ( + // kindRequire marks an operation that requires an authorized action. + kindRequire kind = iota + // kindPublic marks an operation explicitly opted out of authorization. + kindPublic +) + +// declaration is the parsed authorization intent for one operation, stored in +// Operation.Metadata by Require/Public and read by the middleware. +type declaration struct { + kind kind + // action is the Cedar action the operation requires (kindRequire only). + action types.EntityUID + // idParam, when non-empty, names the path parameter the middleware reads to + // build an instance-level Resource (Type::""); empty binds the + // type-level resource for collection operations. Used in Phase B. + idParam string +} + +// Require declares that an operation requires authorization for action. With an +// optional idParam, the middleware builds an instance-level resource from that +// path parameter (Type::""); without it, the resource is type-level (for +// collection operations). The returned map is assigned to Operation.Metadata; +// it also carries the OpenAPI Security requirement under the "security" key so a +// registrar can spread it onto Operation.Security, making the requirement +// visible in the generated docs. Only the first idParam is used. +func Require(action types.EntityUID, idParam ...string) map[string]any { + var id string + if len(idParam) > 0 { + id = idParam[0] + } + + return map[string]any{ + metadataKey: &declaration{kind: kindRequire, action: action, idParam: id}, + "security": SecurityRequirement(), + } +} + +// Public declares that an operation is reachable without authorization. It is +// the explicit opt-out that satisfies the deny-by-default posture: an operation +// with no declaration is denied, so a public route must say so. The returned map +// is assigned to Operation.Metadata. +func Public() map[string]any { + return map[string]any{ + metadataKey: &declaration{kind: kindPublic}, + } +} + +// SecurityRequirement returns the OpenAPI security requirement for a protected +// operation, referencing the API-key scheme registered by RegisterSecurityScheme. +// Require embeds it in the operation metadata; registrars may also assign it to +// Operation.Security directly. +func SecurityRequirement() []map[string][]string { + return []map[string][]string{{SecuritySchemeName: {}}} +} + +// RegisterSecurityScheme declares the API-key security scheme on api's OpenAPI +// document, so the Security requirement Require advertises resolves to a defined +// scheme. The composition root calls it once when authorization is enabled. +func RegisterSecurityScheme(api huma.API) { + components := api.OpenAPI().Components + if components.SecuritySchemes == nil { + components.SecuritySchemes = map[string]*huma.SecurityScheme{} + } + components.SecuritySchemes[SecuritySchemeName] = &huma.SecurityScheme{ + Type: "apiKey", + In: "header", + Name: APIKeyHeader, + Description: "API key supplied via the " + APIKeyHeader + " header or an Authorization: Bearer credential.", + } +} + +// resourceTypeFromAction derives the type-level Cedar resource for an action. +// By the naming convention (§8A) an action is Action::":"; the +// resource type is the PascalCased segment (todo -> Todo), with no +// instance id. Phase B refines this to an instance resource when an idParam is +// declared. An action without the ":" prefix yields a zero resource, +// which coarse principal-only policies (for example the admin override) ignore. +func resourceTypeFromAction(action types.EntityUID) types.EntityUID { + resource, _, found := strings.Cut(string(action.ID), ":") + if !found || resource == "" { + return types.EntityUID{} + } + + return types.EntityUID{Type: types.EntityType(pascalCase(resource))} +} + +// pascalCase upper-cases the first rune of s, mapping a lowercase resource +// segment to its PascalCase entity type (todo -> Todo). +func pascalCase(s string) string { + if s == "" { + return s + } + + return strings.ToUpper(s[:1]) + s[1:] +} + +// declarationFrom extracts the authorization declaration recorded on op by +// Require/Public. The boolean is false for an undeclared operation, which the +// middleware denies. +func declarationFrom(op *huma.Operation) (*declaration, bool) { + if op == nil || op.Metadata == nil { + return nil, false + } + decl, ok := op.Metadata[metadataKey].(*declaration) + + return decl, ok +} diff --git a/internal/authz/declare_test.go b/internal/authz/declare_test.go new file mode 100644 index 0000000..0fe5c85 --- /dev/null +++ b/internal/authz/declare_test.go @@ -0,0 +1,106 @@ +package authz + +import ( + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequireRecordsActionAndSecurity(t *testing.T) { + t.Parallel() + + action := types.NewEntityUID("Action", "todo:read") + meta := Require(action, "todoID") + + decl, ok := meta[metadataKey].(*declaration) + require.True(t, ok, "metadata must carry a declaration") + assert.Equal(t, kindRequire, decl.kind) + assert.Equal(t, action, decl.action) + assert.Equal(t, "todoID", decl.idParam) + + security, ok := meta["security"].([]map[string][]string) + require.True(t, ok, "Require must populate the OpenAPI security requirement") + assert.Equal(t, SecurityRequirement(), security) +} + +func TestRequireWithoutIDParam(t *testing.T) { + t.Parallel() + + decl, ok := Require(types.NewEntityUID("Action", "todo:list"))[metadataKey].(*declaration) + require.True(t, ok) + assert.Empty(t, decl.idParam, "a collection operation binds no instance id") +} + +func TestPublicRecordsOptOut(t *testing.T) { + t.Parallel() + + meta := Public() + decl, ok := meta[metadataKey].(*declaration) + require.True(t, ok) + assert.Equal(t, kindPublic, decl.kind) + assert.NotContains(t, meta, "security", "a public operation declares no security requirement") +} + +func TestDeclarationFrom(t *testing.T) { + t.Parallel() + + t.Run("present", func(t *testing.T) { + t.Parallel() + + op := &huma.Operation{Metadata: Public()} + decl, ok := declarationFrom(op) + require.True(t, ok) + assert.Equal(t, kindPublic, decl.kind) + }) + + t.Run("undeclared operation", func(t *testing.T) { + t.Parallel() + + _, ok := declarationFrom(&huma.Operation{}) + assert.False(t, ok, "an operation with no declaration must be reported as undeclared") + }) + + t.Run("nil operation", func(t *testing.T) { + t.Parallel() + + _, ok := declarationFrom(nil) + assert.False(t, ok) + }) +} + +func TestResourceTypeFromAction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action types.EntityUID + want types.EntityType + }{ + { + name: "resource verb maps to pascal-cased type", + action: types.NewEntityUID("Action", "todo:read"), + want: "Todo", + }, + { + name: "multi-segment verb keeps only the resource", + action: types.NewEntityUID("Action", "todo:list:all"), + want: "Todo", + }, + { + name: "action without a resource prefix yields a zero type", + action: types.NewEntityUID("Action", "ping"), + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, resourceTypeFromAction(tc.action).Type) + }) + } +} diff --git a/internal/authz/getter.go b/internal/authz/getter.go new file mode 100644 index 0000000..3dcc20b --- /dev/null +++ b/internal/authz/getter.go @@ -0,0 +1,124 @@ +package authz + +import ( + "context" + + "github.com/cedar-policy/cedar-go/types" +) + +// getter is the request-scoped composite EntityGetter handed to Cedar. It routes +// each lookup to the slice resolver that owns the entity's type, caches results +// for the life of the request (so an entity chain is read once), and captures +// the first load error. +// +// Cedar's pull interface is Get(uid) (Entity, bool): no context and no error. +// Two consequences are handled here: +// - Context is bound at construction (newGetter), because Cedar will not pass +// one to Get; slice resolvers close over the request context. +// - A resolver cannot signal failure through Get, so it records the first +// error via setErr; the middleware checks Err after Authorize and fails +// closed (500) rather than trusting a decision made on missing data. +// +// A getter is single-request scoped and is not safe for concurrent use, which +// matches Cedar's sequential evaluation of one request. +type getter struct { + // ctx is the request context, bound at construction and shared with the + // slice resolvers (which close over it via their ResolverFactory). + ctx context.Context + // byType routes a lookup to the resolver that owns the entity's type. + byType map[string]EntityResolver + // cache memoizes resolved entities (and misses) for the life of the request. + cache map[types.EntityUID]cacheEntry + // firstErr is the first load failure recorded by a resolver, if any. + firstErr error +} + +// cacheEntry memoizes one lookup, including a miss (found == false), so a +// repeated dereference of the same entity costs no second load. +type cacheEntry struct { + entity types.Entity + found bool +} + +// errorSinkKey is the context key under which the getter installs its +// error-recording sink. Slice resolvers retrieve it via RecordLoadError to +// report a load failure, since Cedar's Get signature carries no error. +type errorSinkKey struct{} + +// newGetter assembles the composite getter for one request. It first installs an +// error sink on ctx (so resolvers can report load failures via RecordLoadError), +// then builds each contribution's resolver bound to that context and the +// principal, indexing them by the entity types they own. Slices with no Resolver +// contribute nothing. +func newGetter(ctx context.Context, p Principal, contributions []Contribution) *getter { + g := &getter{ + byType: make(map[string]EntityResolver), + cache: make(map[types.EntityUID]cacheEntry), + } + + // Bind the error sink onto the context the resolvers receive, so a resolver + // can report a load failure even though Get cannot return an error. + g.ctx = context.WithValue(ctx, errorSinkKey{}, g.setErr) + + for _, c := range contributions { + if c.Resolver == nil { + continue + } + resolver := c.Resolver(g.ctx, p) + for _, t := range resolver.Types() { + g.byType[t] = resolver + } + } + + return g +} + +// RecordLoadError reports a fact-load failure to the request's getter so the +// middleware can fail closed. A slice resolver calls it from its Resolve method +// (Cedar's Get(uid) (Entity, bool) has no error return) using the context it was +// constructed with. It is a no-op when no sink is bound (for example, outside a +// request), so resolvers can call it unconditionally. +func RecordLoadError(ctx context.Context, err error) { + if err == nil { + return + } + if sink, ok := ctx.Value(errorSinkKey{}).(func(error)); ok { + sink(err) + } +} + +// setErr records err as the first load failure if none has been captured yet. +// Slice resolvers call it (via the bound context) when a load fails, since Get +// cannot return an error. +func (g *getter) setErr(err error) { + if g.firstErr == nil { + g.firstErr = err + } +} + +// Err returns the first load failure recorded during evaluation, or nil. The +// middleware checks it after Authorize to fail closed. +func (g *getter) Err() error { + return g.firstErr +} + +// Get resolves uid for Cedar, satisfying types.EntityGetter. It serves cached +// results (including misses), otherwise routes to the owning resolver and caches +// the outcome. An unowned type is a miss, not an error. +func (g *getter) Get(uid types.EntityUID) (types.Entity, bool) { + if hit, ok := g.cache[uid]; ok { + return hit.entity, hit.found + } + + resolver, ok := g.byType[string(uid.Type)] + if !ok { + g.cache[uid] = cacheEntry{} + + return types.Entity{}, false + } + + entity, found := resolver.Resolve(uid) + g.cache[uid] = cacheEntry{entity: entity, found: found} + + return entity, found +} diff --git a/internal/authz/getter_test.go b/internal/authz/getter_test.go new file mode 100644 index 0000000..ac6679c --- /dev/null +++ b/internal/authz/getter_test.go @@ -0,0 +1,105 @@ +package authz + +import ( + "context" + "errors" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/stretchr/testify/assert" +) + +// recordingResolver owns one entity type and counts how many times Resolve is +// called, so the getter's caching can be observed. +type recordingResolver struct { + typ string + uid types.EntityUID + calls int + err error + sinkOf context.Context +} + +func (r *recordingResolver) Types() []string { return []string{r.typ} } + +func (r *recordingResolver) Resolve(uid types.EntityUID) (types.Entity, bool) { + r.calls++ + if r.err != nil { + RecordLoadError(r.sinkOf, r.err) + + return types.Entity{}, false + } + if uid != r.uid { + return types.Entity{}, false + } + + return types.Entity{UID: uid}, true +} + +func TestGetterRoutesAndCaches(t *testing.T) { + t.Parallel() + + todoUID := types.NewEntityUID("Todo", "1") + resolver := &recordingResolver{typ: "Todo", uid: todoUID} + g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Resolver: func(ctx context.Context, _ Principal) EntityResolver { + resolver.sinkOf = ctx + + return resolver + }, + }}) + + entity, ok := g.Get(todoUID) + assert.True(t, ok) + assert.Equal(t, todoUID, entity.UID) + + // A second lookup of the same uid is served from the cache. + _, _ = g.Get(todoUID) + assert.Equal(t, 1, resolver.calls, "the entity should be loaded once and cached") + assert.NoError(t, g.Err()) +} + +func TestGetterCachesMisses(t *testing.T) { + t.Parallel() + + resolver := &recordingResolver{typ: "Todo", uid: types.NewEntityUID("Todo", "1")} + g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Resolver: func(ctx context.Context, _ Principal) EntityResolver { + resolver.sinkOf = ctx + + return resolver + }, + }}) + + missing := types.NewEntityUID("Todo", "404") + _, ok := g.Get(missing) + assert.False(t, ok) + _, _ = g.Get(missing) + assert.Equal(t, 1, resolver.calls, "a miss is cached so it is not retried") +} + +func TestGetterUnownedTypeIsMiss(t *testing.T) { + t.Parallel() + + g := newGetter(context.Background(), Anonymous(), nil) + _, ok := g.Get(types.NewEntityUID("Unknown", "x")) + assert.False(t, ok) + assert.NoError(t, g.Err(), "an unowned type is a miss, not an error") +} + +func TestGetterCapturesFirstLoadError(t *testing.T) { + t.Parallel() + + first := errors.New("first failure") + resolver := &recordingResolver{typ: "Todo", err: first} + g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Resolver: func(ctx context.Context, _ Principal) EntityResolver { + resolver.sinkOf = ctx + + return resolver + }, + }}) + + _, ok := g.Get(types.NewEntityUID("Todo", "1")) + assert.False(t, ok) + assert.ErrorIs(t, g.Err(), first, "the resolver's load error must be captured for fail-closed handling") +} diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go new file mode 100644 index 0000000..bb4b589 --- /dev/null +++ b/internal/authz/middleware.go @@ -0,0 +1,212 @@ +package authz + +import ( + "log/slog" + "net/http" + + "github.com/cedar-policy/cedar-go" + "github.com/danielgtaylor/huma/v2" +) + +// APIKeyHeader is the request header the API-key authenticator reads a key from, +// and the header named by the OpenAPI security scheme. It is defined here, in the +// base package, so the security-scheme declaration (declare.go) needs no +// dependency on the apikey adapter. +// +//nolint:gosec // G101: this is a header name, not a credential value. +const APIKeyHeader = "X-API-Key" + +// genericForbidden is the client-facing detail for any authorization denial. The +// specific reason (diagnostic, missing declaration) is logged, not returned, so +// the API does not leak its policy structure. +const genericForbidden = "you are not authorized to perform this action" + +// genericUnauthorized is the client-facing detail when an anonymous caller is +// denied — it signals that credentials are required. +const genericUnauthorized = "authentication is required to perform this action" + +// Middleware bundles the authentication and authorization Huma middleware behind +// one switch. When disabled it is inert (pass-through), so the template stays +// green before any route is tagged; Phase B tags routes and enables it. +type Middleware struct { + authenticator Authenticator + authorizer *Authorizer + api huma.API + logger *slog.Logger + enabled bool +} + +// NewMiddleware builds the authz middleware over authenticator and authorizer. +// api is the Huma API used to write RFC 9457 problem responses; logger records +// denials and fail-closed errors. When enabled is false the middleware is a +// pass-through (Install is a no-op), the escape hatch for incremental adoption. +func NewMiddleware( + api huma.API, + authenticator Authenticator, + authorizer *Authorizer, + logger *slog.Logger, + enabled bool, +) *Middleware { + if logger == nil { + logger = slog.Default() + } + + return &Middleware{ + authenticator: authenticator, + authorizer: authorizer, + api: api, + logger: logger, + enabled: enabled, + } +} + +// Install registers the authn and authz middleware on the API and declares the +// API-key security scheme. It is a no-op when the middleware is disabled, so all +// operations run unauthenticated and unauthorized — the Phase A default that +// keeps untagged routes (and their tests) working until Phase B tags them. +func (m *Middleware) Install() { + if !m.enabled { + return + } + + RegisterSecurityScheme(m.api) + m.api.UseMiddleware(m.authenticate, m.authorize) +} + +// authenticate runs the configured Authenticator and stores the resulting +// Principal in the request context for the downstream authz middleware. A +// missing credential yields an anonymous principal (authorization decides +// whether that is acceptable); a malformed credential is rejected with 401. +func (m *Middleware) authenticate(ctx huma.Context, next func(huma.Context)) { + principal, err := m.authenticator.Authenticate(ctx) + if err != nil { + // A credential was present but invalid. Do not log the credential + // itself; the access-log middleware redacts the carrying headers. + m.logger.WarnContext(ctx.Context(), "authentication failed", slog.Any("error", err)) + m.writeErr(ctx, http.StatusUnauthorized, genericUnauthorized) + + return + } + + next(huma.WithValue(ctx, principalKey{}, principal)) +} + +// authorize enforces the operation's authorization declaration. Deny-by-default: +// an operation with no declaration is denied and logged; Public proceeds; Require +// evaluates the Cedar request and proceeds only on Allow. A captured entity-load +// error fails closed with 500. +func (m *Middleware) authorize(ctx huma.Context, next func(huma.Context)) { + principal := m.principal(ctx) + + decl, ok := declarationFrom(ctx.Operation()) + if !ok { + // Fail-closed: a route that declared neither Require nor Public is a + // programming omission, not a public endpoint. + m.logger.WarnContext(ctx.Context(), "denying undeclared operation", + slog.String("operation", ctx.Operation().OperationID)) + m.deny(ctx, principal) + + return + } + + if decl.kind == kindPublic { + next(ctx) + + return + } + + allowed := m.evaluate(ctx, principal, decl) + switch allowed { + case decisionAllow: + next(ctx) + case decisionDeny: + m.deny(ctx, principal) + case decisionError: + m.writeErr(ctx, http.StatusInternalServerError, "authorization is temporarily unavailable") + } +} + +// outcome is the resolved authorization result the middleware acts on. +type outcome int + +const ( + decisionAllow outcome = iota + decisionDeny + decisionError +) + +// evaluate builds the Cedar request for decl, runs the authorizer over a +// request-scoped composite getter, and resolves the outcome. A getter load +// failure is reported as decisionError so the caller fails closed; a Cedar Deny +// (or an evaluation diagnostic) is decisionDeny. +func (m *Middleware) evaluate(ctx huma.Context, principal Principal, decl *declaration) outcome { + getter := newGetter(ctx.Context(), principal, m.authorizer.Contributions()) + + // Phase A binds the resource at the type level (Action's namespace is the + // resource type by convention). URL-id binding to an instance resource is + // Phase B; idParam is recorded on the declaration for that step. + req := cedar.Request{ + Principal: principal.UID, + Action: decl.action, + Resource: resourceFor(decl, ctx), + Context: principal.Claims, + } + + decision, diag := m.authorizer.Authorize(getter, req) + + if err := getter.Err(); err != nil { + m.logger.ErrorContext(ctx.Context(), "authorization entity load failed", + slog.String("operation", ctx.Operation().OperationID), + slog.Any("error", err)) + + return decisionError + } + + if decision == cedar.Allow { + return decisionAllow + } + + m.logger.InfoContext(ctx.Context(), "authorization denied", + slog.String("operation", ctx.Operation().OperationID), + slog.String("principal", principal.UID.String()), + slog.String("action", decl.action.String()), + slog.Any("reasons", diag.Reasons)) + + return decisionDeny +} + +// resourceFor builds the Cedar resource entity for decl. Phase A returns a +// type-level resource derived from the action's resource segment; Phase B will +// read decl.idParam from ctx to build an instance-level Type::"". +func resourceFor(decl *declaration, _ huma.Context) cedar.EntityUID { + return resourceTypeFromAction(decl.action) +} + +// principal returns the request principal, defaulting to anonymous when the +// authn middleware did not store one (for example, when authz runs without authn +// in a test). +func (m *Middleware) principal(ctx huma.Context) Principal { + if p, ok := PrincipalFrom(ctx.Context()); ok { + return p + } + + return Anonymous() +} + +// deny writes a 403 for an authenticated caller and a 401 for an anonymous one, +// signalling that credentials are required rather than insufficient. +func (m *Middleware) deny(ctx huma.Context, principal Principal) { + if principal.IsAnonymous() { + m.writeErr(ctx, http.StatusUnauthorized, genericUnauthorized) + + return + } + + m.writeErr(ctx, http.StatusForbidden, genericForbidden) +} + +// writeErr emits an RFC 9457 problem response through Huma's content negotiation, +// matching the error shape every other surface returns. +func (m *Middleware) writeErr(ctx huma.Context, status int, detail string) { + _ = huma.WriteErr(m.api, ctx, status, detail) +} diff --git a/internal/authz/middleware_test.go b/internal/authz/middleware_test.go new file mode 100644 index 0000000..e5a3445 --- /dev/null +++ b/internal/authz/middleware_test.go @@ -0,0 +1,247 @@ +package authz_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/authz/mocks" +) + +// actionRead is the action used across the middleware tests. +func actionRead() types.EntityUID { return types.NewEntityUID("Action", "todo:read") } + +// allowAlicePolicy permits the user alice to perform todo:read, so the tests can +// exercise an Allow decision distinct from the base admin override. +const allowAlicePolicy = `permit ( + principal == User::"alice", + action == Action::"todo:read", + resource +);` + +// user returns a non-anonymous principal with the given subject and roles. +func user(subject string, roles ...string) authz.Principal { + values := make([]types.Value, 0, len(roles)) + for _, role := range roles { + values = append(values, types.String(role)) + } + + return authz.Principal{ + UID: types.NewEntityUID("User", types.String(subject)), + Claims: types.NewRecord(types.RecordMap{ + authz.RolesClaim: types.NewSet(values...), + }), + } +} + +// newTestAPI builds a humatest API with the authz middleware installed over the +// given authenticator and a real authorizer carrying the alice policy, then +// registers three operations: a Require(todo:read), a Public, and an undeclared +// operation. +func newTestAPI(t *testing.T, authenticator authz.Authenticator) humatest.TestAPI { + t.Helper() + + authorizer, err := authz.New([]authz.Contribution{{Policies: []byte(allowAlicePolicy)}}) + require.NoError(t, err) + + _, api := humatest.New(t) + logger := slog.New(slog.DiscardHandler) + authz.NewMiddleware(api, authenticator, authorizer, logger, true).Install() + + huma.Register(api, huma.Operation{ + OperationID: "protected", + Method: http.MethodGet, + Path: "/protected", + Metadata: authz.Require(actionRead()), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "public", + Method: http.MethodGet, + Path: "/public", + Metadata: authz.Public(), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "undeclared", + Method: http.MethodGet, + Path: "/undeclared", + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + return api +} + +func TestMiddlewareAllowsAuthorizedPrincipal(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("alice"), nil) + + resp := newTestAPI(t, authn).Get("/protected") + assert.Equal(t, http.StatusNoContent, resp.Code) +} + +func TestMiddlewareAllowsAdminViaBasePolicy(t *testing.T) { + t.Parallel() + + // bob is not named in the alice policy, but the admin role grants the base + // override — exercising the merged base+slice policy set and the principal + // resolver's role projection. + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("bob", "admin"), nil) + + resp := newTestAPI(t, authn).Get("/protected") + assert.Equal(t, http.StatusNoContent, resp.Code) +} + +func TestMiddlewareForbidsAuthenticatedButUnauthorized(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("mallory"), nil) + + resp := newTestAPI(t, authn).Get("/protected") + assert.Equal(t, http.StatusForbidden, resp.Code) +} + +func TestMiddlewareRejectsAnonymousWith401(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(authz.Anonymous(), nil) + + resp := newTestAPI(t, authn).Get("/protected") + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestMiddlewareMapsInvalidCredentialTo401(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(authz.Anonymous(), errors.New("bad credential")) + + resp := newTestAPI(t, authn).Get("/protected") + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestMiddlewareAllowsPublicOperation(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(authz.Anonymous(), nil) + + resp := newTestAPI(t, authn).Get("/public") + assert.Equal(t, http.StatusNoContent, resp.Code) +} + +func TestMiddlewareDeniesUndeclaredOperation(t *testing.T) { + t.Parallel() + + // An authenticated caller hitting an undeclared route is denied 403 by the + // fail-closed default (a declared-but-unauthorized would be the same code; the + // point is that forgetting a declaration does not open the route). + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("alice"), nil) + + resp := newTestAPI(t, authn).Get("/undeclared") + assert.Equal(t, http.StatusForbidden, resp.Code) +} + +// failingResolver owns the Todo type and reports a load error whenever Cedar +// dereferences an entity, so the fail-closed path can be exercised. +type failingResolver struct { + ctx context.Context +} + +func (r *failingResolver) Types() []string { return []string{"Todo"} } + +func (r *failingResolver) Resolve(_ types.EntityUID) (types.Entity, bool) { + authz.RecordLoadError(r.ctx, errors.New("database unavailable")) + + return types.Entity{}, false +} + +func TestMiddlewareFailsClosedOnLoadError(t *testing.T) { + t.Parallel() + + // This policy dereferences a resource attribute, forcing Cedar to load the + // resource entity — which the resolver fails. The captured error must surface + // as a 500 rather than a (false) decision on missing data. + const attrPolicy = `permit ( + principal, + action == Action::"todo:read", + resource +) when { resource.owner == "anyone" };` + + contribution := authz.Contribution{ + Policies: []byte(attrPolicy), + Resolver: func(ctx context.Context, _ authz.Principal) authz.EntityResolver { + return &failingResolver{ctx: ctx} + }, + } + + authorizer, err := authz.New([]authz.Contribution{contribution}) + require.NoError(t, err) + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("alice"), nil) + + _, api := humatest.New(t) + logger := slog.New(slog.DiscardHandler) + authz.NewMiddleware(api, authn, authorizer, logger, true).Install() + + huma.Register(api, huma.Operation{ + OperationID: "protected", + Method: http.MethodGet, + Path: "/protected", + Metadata: authz.Require(actionRead()), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + resp := api.Get("/protected") + assert.Equal(t, http.StatusInternalServerError, resp.Code) +} + +func TestMiddlewareDisabledIsPassThrough(t *testing.T) { + t.Parallel() + + authorizer, err := authz.New(nil) + require.NoError(t, err) + + // A disabled middleware never authenticates or authorizes: the undeclared + // route (which would be denied when enabled) succeeds, proving Install is a + // no-op. The authenticator must not be called. + authn := mocks.NewAuthenticator(t) + + _, api := humatest.New(t) + logger := slog.New(slog.DiscardHandler) + authz.NewMiddleware(api, authn, authorizer, logger, false).Install() + + huma.Register(api, huma.Operation{ + OperationID: "undeclared", + Method: http.MethodGet, + Path: "/undeclared", + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + resp := api.Get("/undeclared") + assert.Equal(t, http.StatusNoContent, resp.Code) +} diff --git a/internal/authz/mocks/mock_Authenticator.go b/internal/authz/mocks/mock_Authenticator.go new file mode 100644 index 0000000..dd842d4 --- /dev/null +++ b/internal/authz/mocks/mock_Authenticator.go @@ -0,0 +1,98 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "github.com/danielgtaylor/huma/v2" + "github.com/meigma/template-go-api/internal/authz" + mock "github.com/stretchr/testify/mock" +) + +// NewAuthenticator creates a new instance of Authenticator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthenticator(t interface { + mock.TestingT + Cleanup(func()) +}) *Authenticator { + mock := &Authenticator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Authenticator is an autogenerated mock type for the Authenticator type +type Authenticator struct { + mock.Mock +} + +type Authenticator_Expecter struct { + mock *mock.Mock +} + +func (_m *Authenticator) EXPECT() *Authenticator_Expecter { + return &Authenticator_Expecter{mock: &_m.Mock} +} + +// Authenticate provides a mock function for the type Authenticator +func (_mock *Authenticator) Authenticate(ctx huma.Context) (authz.Principal, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Authenticate") + } + + var r0 authz.Principal + var r1 error + if returnFunc, ok := ret.Get(0).(func(huma.Context) (authz.Principal, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(huma.Context) authz.Principal); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(authz.Principal) + } + if returnFunc, ok := ret.Get(1).(func(huma.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Authenticator_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate' +type Authenticator_Authenticate_Call struct { + *mock.Call +} + +// Authenticate is a helper method to define mock.On call +// - ctx huma.Context +func (_e *Authenticator_Expecter) Authenticate(ctx any) *Authenticator_Authenticate_Call { + return &Authenticator_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx)} +} + +func (_c *Authenticator_Authenticate_Call) Run(run func(ctx huma.Context)) *Authenticator_Authenticate_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 huma.Context + if args[0] != nil { + arg0 = args[0].(huma.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Authenticator_Authenticate_Call) Return(principal authz.Principal, err error) *Authenticator_Authenticate_Call { + _c.Call.Return(principal, err) + return _c +} + +func (_c *Authenticator_Authenticate_Call) RunAndReturn(run func(ctx huma.Context) (authz.Principal, error)) *Authenticator_Authenticate_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/authz/mocks/mock_EntityResolver.go b/internal/authz/mocks/mock_EntityResolver.go new file mode 100644 index 0000000..fa716a7 --- /dev/null +++ b/internal/authz/mocks/mock_EntityResolver.go @@ -0,0 +1,143 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "github.com/cedar-policy/cedar-go/types" + mock "github.com/stretchr/testify/mock" +) + +// NewEntityResolver creates a new instance of EntityResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewEntityResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *EntityResolver { + mock := &EntityResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// EntityResolver is an autogenerated mock type for the EntityResolver type +type EntityResolver struct { + mock.Mock +} + +type EntityResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *EntityResolver) EXPECT() *EntityResolver_Expecter { + return &EntityResolver_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type EntityResolver +func (_mock *EntityResolver) Resolve(uid types.EntityUID) (types.Entity, bool) { + ret := _mock.Called(uid) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 types.Entity + var r1 bool + if returnFunc, ok := ret.Get(0).(func(types.EntityUID) (types.Entity, bool)); ok { + return returnFunc(uid) + } + if returnFunc, ok := ret.Get(0).(func(types.EntityUID) types.Entity); ok { + r0 = returnFunc(uid) + } else { + r0 = ret.Get(0).(types.Entity) + } + if returnFunc, ok := ret.Get(1).(func(types.EntityUID) bool); ok { + r1 = returnFunc(uid) + } else { + r1 = ret.Get(1).(bool) + } + return r0, r1 +} + +// EntityResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type EntityResolver_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - uid types.EntityUID +func (_e *EntityResolver_Expecter) Resolve(uid any) *EntityResolver_Resolve_Call { + return &EntityResolver_Resolve_Call{Call: _e.mock.On("Resolve", uid)} +} + +func (_c *EntityResolver_Resolve_Call) Run(run func(uid types.EntityUID)) *EntityResolver_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 types.EntityUID + if args[0] != nil { + arg0 = args[0].(types.EntityUID) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *EntityResolver_Resolve_Call) Return(entity types.Entity, b bool) *EntityResolver_Resolve_Call { + _c.Call.Return(entity, b) + return _c +} + +func (_c *EntityResolver_Resolve_Call) RunAndReturn(run func(uid types.EntityUID) (types.Entity, bool)) *EntityResolver_Resolve_Call { + _c.Call.Return(run) + return _c +} + +// Types provides a mock function for the type EntityResolver +func (_mock *EntityResolver) Types() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Types") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// EntityResolver_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types' +type EntityResolver_Types_Call struct { + *mock.Call +} + +// Types is a helper method to define mock.On call +func (_e *EntityResolver_Expecter) Types() *EntityResolver_Types_Call { + return &EntityResolver_Types_Call{Call: _e.mock.On("Types")} +} + +func (_c *EntityResolver_Types_Call) Run(run func()) *EntityResolver_Types_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *EntityResolver_Types_Call) Return(strings []string) *EntityResolver_Types_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *EntityResolver_Types_Call) RunAndReturn(run func() []string) *EntityResolver_Types_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/authz/principal.go b/internal/authz/principal.go new file mode 100644 index 0000000..22b2396 --- /dev/null +++ b/internal/authz/principal.go @@ -0,0 +1,69 @@ +// Package authz is the cross-cutting authorization engine: a thin app-owned +// wrapper over AWS Cedar (github.com/cedar-policy/cedar-go), committed as the +// authorization engine with no portability layer. It owns the merged runtime +// PolicySet, the per-request authentication seam and opaque Principal, the +// global Huma middleware that enforces a deny-by-default policy, and the +// per-operation Require/Public declarations recorded as Huma operation metadata. +// +// Authoring is modular: each domain slice contributes its policies, action +// identifiers, and lazy entity resolvers via a Contribution; the composition +// root merges them into one PolicySet and one request-scoped composite +// EntityGetter. Evaluation is unified over that single shared namespace. +package authz + +import ( + "context" + + "github.com/cedar-policy/cedar-go/types" +) + +// AnonymousType is the Cedar entity type assigned to an unauthenticated caller. +// The authn middleware stores an anonymous Principal when no credentials are +// present, letting public operations proceed and authz reject protected ones +// with 401 rather than 403. +const AnonymousType types.EntityType = "Anonymous" + +// AnonymousID is the identifier of the singleton anonymous principal. +const AnonymousID types.String = "anonymous" + +// Principal is the opaque caller identity handed from authentication to +// authorization. UID is the Cedar entity (for example User::"alice"); Claims +// carries roles, groups, scopes, and arbitrary verified attributes, opaque to +// the template. Group memberships are projected onto the principal entity's +// Parents at resolve time so Cedar's `principal in Group::"…"` works without a +// load. +type Principal struct { + // UID is the Cedar entity identifying the caller. + UID types.EntityUID + // Claims carries the caller's verified attributes (roles, groups, scopes). + Claims types.Record +} + +// Anonymous returns the principal used when a request carries no credentials. +func Anonymous() Principal { + return Principal{UID: types.NewEntityUID(AnonymousType, AnonymousID)} +} + +// IsAnonymous reports whether p is the unauthenticated principal. +func (p Principal) IsAnonymous() bool { + return p.UID.Type == AnonymousType +} + +// principalKey is the unexported context key under which the authn middleware +// stores the Principal, keeping the key private to this package. +type principalKey struct{} + +// WithPrincipal returns a copy of ctx carrying p. The authn middleware uses it +// to hand the verified principal to the downstream authz middleware. +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalKey{}, p) +} + +// PrincipalFrom returns the Principal stored in ctx. The boolean reports whether +// one was present; when absent, the caller should treat the request as +// anonymous (fail-closed). +func PrincipalFrom(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey{}).(Principal) + + return p, ok +} diff --git a/internal/authz/principal_resolver.go b/internal/authz/principal_resolver.go new file mode 100644 index 0000000..561dbc2 --- /dev/null +++ b/internal/authz/principal_resolver.go @@ -0,0 +1,87 @@ +package authz + +import ( + "context" + + "github.com/cedar-policy/cedar-go/types" +) + +// RoleType is the Cedar entity type for a role group. Roles carried on a +// principal's claims are projected onto the principal entity's parents as +// Role::"", so policies can test `principal in Role::"…"` with no load. +const RoleType types.EntityType = "Role" + +// RolesClaim is the claim key the principal resolver reads the caller's roles +// from, and the key an Authenticator writes them under, so role membership is +// projected onto the principal entity's parents for `principal in Role::"…"`. +const RolesClaim types.String = "roles" + +// principalContribution is the always-present base contribution that resolves +// the authenticated principal entity from its claims. It owns the principal +// entity types so the composite getter routes principal lookups here, letting +// cross-cutting policies (base.cedar's admin override) and slice policies test +// principal group membership without any database load. +func principalContribution() Contribution { + return Contribution{Resolver: newPrincipalResolver} +} + +// principalResolver materializes the request's principal entity (and its role +// parents) from the bound Principal. It owns the principal entity's own type so +// `principal in Role::"…"` checks resolve from the claims projected at +// authentication time — no load, fail-closed-safe. +type principalResolver struct { + principal Principal +} + +// newPrincipalResolver builds the per-request principal resolver. It satisfies +// ResolverFactory; the context is unused because the principal's identity and +// roles are already in hand from authentication. +func newPrincipalResolver(_ context.Context, p Principal) EntityResolver { + return &principalResolver{principal: p} +} + +// Types reports the principal entity's own type, so the composite getter routes +// a lookup of the principal entity to this resolver. Role entities themselves +// carry no attributes and need no resolver — Cedar reads ancestry from the +// principal entity's parents. +func (r *principalResolver) Types() []string { + return []string{string(r.principal.UID.Type)} +} + +// Resolve returns the principal entity with its role parents when uid is the +// bound principal, otherwise a miss. Roles recorded on the principal's claims +// become Role::"" parents so membership tests evaluate with no load. +func (r *principalResolver) Resolve(uid types.EntityUID) (types.Entity, bool) { + if uid != r.principal.UID { + return types.Entity{}, false + } + + return types.Entity{ + UID: r.principal.UID, + Parents: roleParents(r.principal.Claims), + Attributes: r.principal.Claims, + }, true +} + +// roleParents reads the roles claim and returns the principal's Role parents. +func roleParents(claims types.Record) types.EntityUIDSet { + value, ok := claims.Get(RolesClaim) + if !ok { + return types.NewEntityUIDSet() + } + roles, ok := value.(types.Set) + if !ok { + return types.NewEntityUIDSet() + } + + uids := make([]types.EntityUID, 0, roles.Len()) + for role := range roles.All() { + name, ok := role.(types.String) + if !ok { + continue + } + uids = append(uids, types.NewEntityUID(RoleType, name)) + } + + return types.NewEntityUIDSet(uids...) +} diff --git a/internal/config/config.go b/internal/config/config.go index dbd8c44..5d954ba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,11 @@ const ( defaultLogLevel = "info" defaultLogFormat = "json" defaultDBMaxConns = 0 + // defaultAuthzEnabled is false in this phase: the engine ships with an empty + // contribution set and a deny-by-default posture, so enabling it before + // routes are tagged would reject every untagged operation. A later phase + // tags routes and flips this to true. + defaultAuthzEnabled = false ) // Config holds runtime settings for the API server. @@ -62,6 +67,15 @@ type Config struct { // DBMaxConns caps the PostgreSQL connection pool size. Zero leaves the // driver default in place. DBMaxConns int32 + // AuthzEnabled is the authorization master switch. When false the authz + // middleware is inert (pass-through), the escape hatch for incremental + // adoption. It defaults to false this phase (the engine ships with no tagged + // routes, and deny-by-default would otherwise reject them all). + AuthzEnabled bool + // AuthzPolicyDir optionally loads .cedar policy files from a directory + // instead of the embedded set. Empty (the default) uses the embedded + // policies. + AuthzPolicyDir string } // RegisterFlags declares the server configuration flags on flags. Binding them @@ -89,6 +103,16 @@ func RegisterFlags(flags *pflag.FlagSet) { ) flags.String("database-url", "", "PostgreSQL connection URL (required)") flags.Int32("db-max-conns", defaultDBMaxConns, "maximum PostgreSQL pool connections; 0 uses the driver default") + flags.Bool( + "authz-enabled", + defaultAuthzEnabled, + "enable the authorization middleware (deny-by-default); false bypasses it entirely", + ) + flags.String( + "authz-policy-dir", + "", + "directory of .cedar policy files to load instead of the embedded policies; empty uses the embedded set", + ) } // Load reads the server configuration from vp, applying defaults for unset keys. @@ -110,6 +134,8 @@ func Load(vp *viper.Viper) Config { TrustedProxyHeader: vp.GetString("trusted-proxy-header"), DatabaseURL: vp.GetString("database-url"), DBMaxConns: vp.GetInt32("db-max-conns"), + AuthzEnabled: vp.GetBool("authz-enabled"), + AuthzPolicyDir: vp.GetString("authz-policy-dir"), } } @@ -152,4 +178,6 @@ func setDefaults(vp *viper.Viper) { vp.SetDefault("trusted-proxy-header", "") vp.SetDefault("database-url", "") vp.SetDefault("db-max-conns", defaultDBMaxConns) + vp.SetDefault("authz-enabled", defaultAuthzEnabled) + vp.SetDefault("authz-policy-dir", "") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 86c5854..7332131 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,6 +24,24 @@ func TestLoadDefaults(t *testing.T) { assert.Empty(t, cfg.TrustedProxyHeader) assert.Empty(t, cfg.DatabaseURL) assert.Zero(t, cfg.DBMaxConns) + assert.False(t, cfg.AuthzEnabled, "authz is disabled by default until routes are tagged") + assert.Empty(t, cfg.AuthzPolicyDir) +} + +func TestLoadAuthzFromFlags(t *testing.T) { + t.Parallel() + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + RegisterFlags(flags) + require.NoError(t, flags.Set("authz-enabled", "true")) + require.NoError(t, flags.Set("authz-policy-dir", "/etc/policies")) + + vp := viper.New() + require.NoError(t, vp.BindPFlags(flags)) + + cfg := Load(vp) + assert.True(t, cfg.AuthzEnabled) + assert.Equal(t, "/etc/policies", cfg.AuthzPolicyDir) } func TestLoadEnvOverride(t *testing.T) { diff --git a/internal/observability/requestlog.go b/internal/observability/requestlog.go index 5042365..86f9afa 100644 --- a/internal/observability/requestlog.go +++ b/internal/observability/requestlog.go @@ -31,6 +31,11 @@ func RequestLogger(base *slog.Logger) func(http.Handler) http.Handler { next.ServeHTTP(wrapped, r.WithContext(ctx)) + // The access log records request metadata only — never request + // headers or bodies. Credential-bearing headers (Authorization, + // X-API-Key) are therefore redacted by construction: they are never + // read here. Keep it that way when extending this line, so API keys + // and bearer tokens cannot leak into logs. logger.LogAttrs(ctx, slog.LevelInfo, "http request", slog.String("method", r.Method), slog.String("path", r.URL.Path), diff --git a/internal/todo/postgres/sqlc/models.go b/internal/todo/postgres/sqlc/models.go index 2bd0690..b811576 100644 --- a/internal/todo/postgres/sqlc/models.go +++ b/internal/todo/postgres/sqlc/models.go @@ -10,6 +10,12 @@ import ( "github.com/google/uuid" ) +type ApiKey struct { + Key string + Subject string + Roles []string +} + type Todo struct { ID uuid.UUID Title string diff --git a/moon.yml b/moon.yml index 1c147bb..86b5f5a 100644 --- a/moon.yml +++ b/moon.yml @@ -31,6 +31,9 @@ fileGroups: mockerySources: - '.mockery.yaml' - 'internal/todo/ports.go' + - 'internal/authz/authn.go' + - 'internal/authz/contribution.go' + - 'internal/authz/apikey/apikey.go' - '.prototools' - '.moon/proto/mockery.toml' lintConfig: @@ -147,6 +150,8 @@ tasks: - '@group(mockerySources)' outputs: - 'internal/todo/mocks/*.go' + - 'internal/authz/mocks/*.go' + - 'internal/authz/apikey/mocks/*.go' options: cache: false @@ -161,13 +166,22 @@ tasks: tmp="$(mktemp -d "${PWD}/.mockery-check.XXXXXX")" cfg="${tmp}.yaml" trap 'rm -rf "$tmp" "$cfg"' EXIT + # Regenerate every mock into one throwaway directory (filenames are unique + # across the mocked interfaces), then compare each committed mocks package + # against the freshly generated files of the same name. sed "s|dir: \"{{.InterfaceDir}}/mocks\"|dir: ${tmp#"${PWD}/"}|" .mockery.yaml > "$cfg" proto run mockery -- --config "$cfg" - git diff --no-index --exit-code -- internal/todo/mocks "$tmp" + for dir in internal/todo/mocks internal/authz/mocks internal/authz/apikey/mocks; do + for committed in "$dir"/*.go; do + git diff --no-index --exit-code -- "$committed" "$tmp/$(basename "$committed")" + done + done toolchains: ['go'] inputs: - '@group(mockerySources)' - 'internal/todo/mocks/*.go' + - 'internal/authz/mocks/*.go' + - 'internal/authz/apikey/mocks/*.go' options: cache: false From 752a69d90f732485103a935b64543c49be9c9d54 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 18:03:10 -0700 Subject: [PATCH 02/10] fix(authz): address phase A review findings 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) --- go.mod | 2 +- internal/app/app.go | 17 +++--- internal/authz/apikey/apikey.go | 8 +-- internal/authz/apikey/store.go | 4 +- internal/authz/authz.go | 91 +++++++++++++++++++++++++++++---- internal/authz/authz_test.go | 57 +++++++++++++++++++++ internal/authz/declare.go | 63 ++++++++++++++++++----- internal/authz/declare_test.go | 59 +++++++++++++++++++-- internal/authz/middleware.go | 40 +++++++++------ internal/config/config.go | 12 ++--- 10 files changed, 291 insertions(+), 62 deletions(-) create mode 100644 internal/authz/authz_test.go diff --git a/go.mod b/go.mod index 44caa78..aa59893 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/meigma/template-go-api go 1.26.4 require ( + github.com/cedar-policy/cedar-go v1.8.0 github.com/danielgtaylor/huma/v2 v2.38.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 @@ -23,7 +24,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cedar-policy/cedar-go v1.8.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 361d1d3..1b63c7d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,6 +1,6 @@ // Package app is the composition root: it wires the domain service, the -// PostgreSQL persistence adapter, observability, and the HTTP server into a -// runnable App. +// PostgreSQL persistence adapter, the authorization engine and API-key +// authenticator, observability, and the HTTP server into a runnable App. package app import ( @@ -156,17 +156,18 @@ func resolveStore( // authzInstaller builds the authorization engine and returns a hook that // installs the authn/authz Huma middleware on the API. The Authorizer is built -// from an empty contribution set (only the base cross-cutting policies) this -// phase; a later phase passes each domain slice's Contribution. The API-key -// authenticator is PostgreSQL-backed, so it requires a pool when authorization -// is enabled. The middleware is inert when cfg.AuthzEnabled is false, which is -// the default until routes are tagged — keeping every untagged route working. +// from an empty contribution set (only the base cross-cutting policies); a later +// slice passes each domain's Contribution. cfg.AuthzPolicyDir, when set, loads +// the base policies from that directory instead of the embedded base.cedar. The +// API-key authenticator is PostgreSQL-backed, so it requires a pool when +// authorization is enabled. The middleware is inert when cfg.AuthzEnabled is +// false, keeping every untagged route working. func authzInstaller( cfg config.Config, pool *pgxpool.Pool, logger *slog.Logger, ) (func(huma.API), error) { - authorizer, err := authz.New(nil) + authorizer, err := authz.New(nil, authz.WithPolicyDir(cfg.AuthzPolicyDir)) if err != nil { return nil, fmt.Errorf("build authorizer: %w", err) } diff --git a/internal/authz/apikey/apikey.go b/internal/authz/apikey/apikey.go index 03e749f..e7507c6 100644 --- a/internal/authz/apikey/apikey.go +++ b/internal/authz/apikey/apikey.go @@ -6,9 +6,11 @@ // replace it with a real verifier (JWT/OIDC/session). // // The shipped store is PostgreSQL-backed (store.go); keys live in an api_keys -// table since the template is postgres-only. The package is self-contained and -// hand-writes its single query rather than adding a second sqlc package, so -// removal stays surgical. +// table since the template is postgres-only. The package hand-writes its single +// query rather than adding a second sqlc package, so removal stays surgical for +// the Go code. The api_keys table lives in the shared migrations directory, so +// the todo sqlc package generates an unused ApiKey model from it; removing the +// feature also drops that table and regenerates the todo sqlc package. package apikey import ( diff --git a/internal/authz/apikey/store.go b/internal/authz/apikey/store.go index 42cbceb..06a815e 100644 --- a/internal/authz/apikey/store.go +++ b/internal/authz/apikey/store.go @@ -14,11 +14,11 @@ import ( // self-contained and trivially removable — it deliberately does not introduce a // second sqlc package. // -// SECURITY: this day-one implementation stores and matches keys verbatim. The +// SECURITY: this implementation stores and matches keys verbatim. The // production hardening path is to store only a hash of the key (for example // SHA-256) and compare in constant time (crypto/subtle.ConstantTimeCompare) // after hashing the presented key, so a leaked table dump does not reveal usable -// credentials and lookups are not timing-distinguishable. See DELETE_ME. +// credentials and lookups are not timing-distinguishable. const lookupQuery = `SELECT subject, roles FROM api_keys WHERE key = $1` // Store is the PostgreSQL-backed APIKeyStore. It resolves keys against the diff --git a/internal/authz/authz.go b/internal/authz/authz.go index d34311e..5728b26 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -3,6 +3,9 @@ package authz import ( _ "embed" "fmt" + "os" + "path/filepath" + "sort" "github.com/cedar-policy/cedar-go" ) @@ -28,15 +31,45 @@ type Authorizer struct { contributions []Contribution } -// New merges base.cedar and every contribution's policies into one runtime -// PolicySet and returns an Authorizer. Policy IDs are re-assigned with a -// slice-prefixed, per-slice index ("#") so policies stay unique across -// slices after the merge. Passing no contributions yields an authorizer with -// only the base policies, which is the Phase A composition-root default. -func New(contributions []Contribution) (*Authorizer, error) { +// Option configures how New builds the Authorizer. +type Option func(*config) + +type config struct { + // policyDir, when non-empty, replaces the embedded base.cedar with the + // .cedar files loaded from this directory. + policyDir string +} + +// WithPolicyDir loads the base policies from the .cedar files in dir instead of +// the embedded base.cedar. An empty dir keeps the embedded default. The files +// are read once at construction (startup), sorted by name for a deterministic +// merge order. +func WithPolicyDir(dir string) Option { + return func(c *config) { + c.policyDir = dir + } +} + +// New merges the base policies and every contribution's policies into one +// runtime PolicySet and returns an Authorizer. The base policies are the +// embedded base.cedar unless WithPolicyDir overrides them with a directory of +// .cedar files. Policy IDs are re-assigned with a slice-prefixed, per-slice +// index ("#") so policies stay unique across slices after the merge. +// Passing no contributions yields an authorizer with only the base policies. +func New(contributions []Contribution, opts ...Option) (*Authorizer, error) { + var cfg config + for _, opt := range opts { + opt(&cfg) + } + + base, err := loadBasePolicies(cfg.policyDir) + if err != nil { + return nil, err + } + merged := cedar.NewPolicySet() - if err := mergePolicies(merged, baseSlice, basePolicies); err != nil { + if err := mergePolicies(merged, baseSlice, base); err != nil { return nil, fmt.Errorf("merge base policies: %w", err) } @@ -60,6 +93,46 @@ func New(contributions []Contribution) (*Authorizer, error) { return &Authorizer{policies: merged, contributions: all}, nil } +// loadBasePolicies returns the base policy source: the .cedar files concatenated +// from dir when dir is non-empty, otherwise the embedded base.cedar. The files +// are read in sorted name order so the merge (and policy IDs) are deterministic. +// An empty or .cedar-free directory is an error, so a misconfigured policy +// directory fails startup rather than silently dropping every base policy. +func loadBasePolicies(dir string) ([]byte, error) { + if dir == "" { + return basePolicies, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read policy directory %q: %w", dir, err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".cedar" { + continue + } + names = append(names, entry.Name()) + } + if len(names) == 0 { + return nil, fmt.Errorf("policy directory %q contains no .cedar files", dir) + } + sort.Strings(names) + + var document []byte + for _, name := range names { + content, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("read policy file %q: %w", name, err) + } + document = append(document, content...) + document = append(document, '\n') + } + + return document, nil +} + // mergePolicies parses document and adds each policy to dst under a // slice-prefixed ID ("#"), so merged policy IDs are unique and trace // back to their source slice. @@ -79,9 +152,7 @@ func mergePolicies(dst *cedar.PolicySet, slice string, document []byte) error { // Authorize evaluates req against the merged PolicySet using entities, returning // Cedar's decision and diagnostic. entities is the request-scoped composite -// getter; Cedar pulls only the entities the applicable policies dereference. The -// ctx parameter is accepted for symmetry with the call site and future tracing; -// Cedar's evaluation itself takes no context. +// getter; Cedar pulls only the entities the applicable policies dereference. func (a *Authorizer) Authorize(entities cedar.EntityGetter, req cedar.Request) (cedar.Decision, cedar.Diagnostic) { return cedar.Authorize(a.policies, entities, req) } diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go new file mode 100644 index 0000000..6e9880a --- /dev/null +++ b/internal/authz/authz_test.go @@ -0,0 +1,57 @@ +package authz + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewUsesEmbeddedBasePoliciesByDefault(t *testing.T) { + t.Parallel() + + authorizer, err := New(nil) + require.NoError(t, err) + assert.NotNil(t, authorizer) +} + +func TestNewWithPolicyDirLoadsDirectoryPolicies(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + policy := `permit (principal, action, resource);` + require.NoError(t, os.WriteFile(filepath.Join(dir, "00_allow.cedar"), []byte(policy), 0o600)) + + authorizer, err := New(nil, WithPolicyDir(dir)) + require.NoError(t, err) + assert.NotNil(t, authorizer) +} + +func TestNewWithPolicyDirRejectsMissingDirectory(t *testing.T) { + t.Parallel() + + _, err := New(nil, WithPolicyDir(filepath.Join(t.TempDir(), "does-not-exist"))) + require.Error(t, err, "a missing policy directory must fail startup, not silently fall back") +} + +func TestNewWithPolicyDirRejectsDirectoryWithoutCedarFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a policy"), 0o600)) + + _, err := New(nil, WithPolicyDir(dir)) + require.Error(t, err, "a policy directory with no .cedar files must fail rather than drop all base policies") +} + +func TestNewWithPolicyDirRejectsInvalidPolicy(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.cedar"), []byte("this is not cedar"), 0o600)) + + _, err := New(nil, WithPolicyDir(dir)) + require.Error(t, err, "an unparseable policy file must fail startup") +} diff --git a/internal/authz/declare.go b/internal/authz/declare.go index bcdb1af..ee836ff 100644 --- a/internal/authz/declare.go +++ b/internal/authz/declare.go @@ -35,17 +35,18 @@ type declaration struct { action types.EntityUID // idParam, when non-empty, names the path parameter the middleware reads to // build an instance-level Resource (Type::""); empty binds the - // type-level resource for collection operations. Used in Phase B. + // type-level resource for collection operations. idParam string } // Require declares that an operation requires authorization for action. With an // optional idParam, the middleware builds an instance-level resource from that // path parameter (Type::""); without it, the resource is type-level (for -// collection operations). The returned map is assigned to Operation.Metadata; -// it also carries the OpenAPI Security requirement under the "security" key so a -// registrar can spread it onto Operation.Security, making the requirement -// visible in the generated docs. Only the first idParam is used. +// collection operations). The returned map is assigned to Operation.Metadata. +// ApplySecurity (run when the middleware is installed) reads this declaration +// and sets the operation's OpenAPI Security, so the requirement is visible in +// the generated docs without the registrar copying it by hand. Only the first +// idParam is used. func Require(action types.EntityUID, idParam ...string) map[string]any { var id string if len(idParam) > 0 { @@ -54,7 +55,6 @@ func Require(action types.EntityUID, idParam ...string) map[string]any { return map[string]any{ metadataKey: &declaration{kind: kindRequire, action: action, idParam: id}, - "security": SecurityRequirement(), } } @@ -70,12 +70,51 @@ func Public() map[string]any { // SecurityRequirement returns the OpenAPI security requirement for a protected // operation, referencing the API-key scheme registered by RegisterSecurityScheme. -// Require embeds it in the operation metadata; registrars may also assign it to -// Operation.Security directly. +// ApplySecurity assigns it to a required operation's Security field. func SecurityRequirement() []map[string][]string { return []map[string][]string{{SecuritySchemeName: {}}} } +// ApplySecurity sets the OpenAPI Security requirement on every operation that +// Require declared, so a protected route advertises its security scheme in the +// generated document. Public and undeclared operations are left untouched. It is +// run once when the middleware is installed, after the operations are +// registered, so a registrar only writes Metadata: Require(...) and the +// requirement still reaches the spec. +func ApplySecurity(api huma.API) { + for _, item := range api.OpenAPI().Paths { + for _, op := range pathOperations(item) { + decl, ok := declarationFrom(op) + if !ok || decl.kind != kindRequire { + continue + } + op.Security = SecurityRequirement() + } + } +} + +// pathOperations returns the non-nil operations defined on item, one per HTTP +// method, so callers can iterate a path's operations without repeating the +// method-by-method field access. +func pathOperations(item *huma.PathItem) []*huma.Operation { + if item == nil { + return nil + } + + candidates := []*huma.Operation{ + item.Get, item.Put, item.Post, item.Delete, + item.Options, item.Head, item.Patch, item.Trace, + } + ops := make([]*huma.Operation, 0, len(candidates)) + for _, op := range candidates { + if op != nil { + ops = append(ops, op) + } + } + + return ops +} + // RegisterSecurityScheme declares the API-key security scheme on api's OpenAPI // document, so the Security requirement Require advertises resolves to a defined // scheme. The composition root calls it once when authorization is enabled. @@ -93,11 +132,11 @@ func RegisterSecurityScheme(api huma.API) { } // resourceTypeFromAction derives the type-level Cedar resource for an action. -// By the naming convention (§8A) an action is Action::":"; the +// By the naming convention an action is Action::":"; the // resource type is the PascalCased segment (todo -> Todo), with no -// instance id. Phase B refines this to an instance resource when an idParam is -// declared. An action without the ":" prefix yields a zero resource, -// which coarse principal-only policies (for example the admin override) ignore. +// instance id. An action without the ":" prefix yields a zero +// resource, which coarse principal-only policies (for example the admin +// override) ignore. func resourceTypeFromAction(action types.EntityUID) types.EntityUID { resource, _, found := strings.Cut(string(action.ID), ":") if !found || resource == "" { diff --git a/internal/authz/declare_test.go b/internal/authz/declare_test.go index 0fe5c85..076066a 100644 --- a/internal/authz/declare_test.go +++ b/internal/authz/declare_test.go @@ -1,15 +1,18 @@ package authz import ( + "context" + "net/http" "testing" "github.com/cedar-policy/cedar-go/types" "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestRequireRecordsActionAndSecurity(t *testing.T) { +func TestRequireRecordsActionAndIDParam(t *testing.T) { t.Parallel() action := types.NewEntityUID("Action", "todo:read") @@ -20,10 +23,6 @@ func TestRequireRecordsActionAndSecurity(t *testing.T) { assert.Equal(t, kindRequire, decl.kind) assert.Equal(t, action, decl.action) assert.Equal(t, "todoID", decl.idParam) - - security, ok := meta["security"].([]map[string][]string) - require.True(t, ok, "Require must populate the OpenAPI security requirement") - assert.Equal(t, SecurityRequirement(), security) } func TestRequireWithoutIDParam(t *testing.T) { @@ -71,6 +70,56 @@ func TestDeclarationFrom(t *testing.T) { }) } +func TestApplySecurityPopulatesRequiredOperations(t *testing.T) { + t.Parallel() + + _, api := humatest.New(t) + + noop := func(_ context.Context, _ *struct{}) (*struct{}, error) { return &struct{}{}, nil } + huma.Register(api, huma.Operation{ + OperationID: "protected", + Method: http.MethodGet, + Path: "/protected", + Metadata: Require(types.NewEntityUID("Action", "todo:read")), + }, noop) + huma.Register(api, huma.Operation{ + OperationID: "public", + Method: http.MethodGet, + Path: "/public", + Metadata: Public(), + }, noop) + + ApplySecurity(api) + + protected := api.OpenAPI().Paths["/protected"].Get + assert.Equal(t, SecurityRequirement(), protected.Security, + "ApplySecurity must stamp the requirement onto a Require operation") + + public := api.OpenAPI().Paths["/public"].Get + assert.Empty(t, public.Security, "a public operation advertises no security requirement") +} + +func TestApplySecuritySurfacesInGeneratedSpec(t *testing.T) { + t.Parallel() + + _, api := humatest.New(t) + RegisterSecurityScheme(api) + + huma.Register(api, huma.Operation{ + OperationID: "protected", + Method: http.MethodGet, + Path: "/protected", + Metadata: Require(types.NewEntityUID("Action", "todo:read")), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { return &struct{}{}, nil }) + + ApplySecurity(api) + + spec, err := api.OpenAPI().YAML() + require.NoError(t, err) + assert.Contains(t, string(spec), SecuritySchemeName, + "the protected operation's security requirement must appear in the generated spec") +} + func TestResourceTypeFromAction(t *testing.T) { t.Parallel() diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go index bb4b589..7545320 100644 --- a/internal/authz/middleware.go +++ b/internal/authz/middleware.go @@ -27,7 +27,7 @@ const genericUnauthorized = "authentication is required to perform this action" // Middleware bundles the authentication and authorization Huma middleware behind // one switch. When disabled it is inert (pass-through), so the template stays -// green before any route is tagged; Phase B tags routes and enables it. +// green before any route carries an authorization declaration. type Middleware struct { authenticator Authenticator authorizer *Authorizer @@ -60,16 +60,19 @@ func NewMiddleware( } } -// Install registers the authn and authz middleware on the API and declares the -// API-key security scheme. It is a no-op when the middleware is disabled, so all -// operations run unauthenticated and unauthorized — the Phase A default that -// keeps untagged routes (and their tests) working until Phase B tags them. +// Install registers the authn and authz middleware on the API, declares the +// API-key security scheme, and stamps the OpenAPI Security requirement onto every +// operation that Require declared. It must run after the operations are +// registered so ApplySecurity sees them. It is a no-op when the middleware is +// disabled, so all operations run unauthenticated and unauthorized — the default +// that keeps untagged routes (and their tests) working until they are tagged. func (m *Middleware) Install() { if !m.enabled { return } RegisterSecurityScheme(m.api) + ApplySecurity(m.api) m.api.UseMiddleware(m.authenticate, m.authorize) } @@ -88,7 +91,7 @@ func (m *Middleware) authenticate(ctx huma.Context, next func(huma.Context)) { return } - next(huma.WithValue(ctx, principalKey{}, principal)) + next(huma.WithContext(ctx, WithPrincipal(ctx.Context(), principal))) } // authorize enforces the operation's authorization declaration. Deny-by-default: @@ -123,16 +126,22 @@ func (m *Middleware) authorize(ctx huma.Context, next func(huma.Context)) { m.deny(ctx, principal) case decisionError: m.writeErr(ctx, http.StatusInternalServerError, "authorization is temporarily unavailable") + default: + // Fail closed: an unrecognized outcome (including the zero value) is + // treated as an error rather than allowed. + m.writeErr(ctx, http.StatusInternalServerError, "authorization is temporarily unavailable") } } -// outcome is the resolved authorization result the middleware acts on. +// outcome is the resolved authorization result the middleware acts on. The zero +// value is decisionError so any unset or unhandled outcome fails closed, keeping +// the decision pipeline deny-by-default by construction. type outcome int const ( - decisionAllow outcome = iota + decisionError outcome = iota + decisionAllow decisionDeny - decisionError ) // evaluate builds the Cedar request for decl, runs the authorizer over a @@ -142,9 +151,9 @@ const ( func (m *Middleware) evaluate(ctx huma.Context, principal Principal, decl *declaration) outcome { getter := newGetter(ctx.Context(), principal, m.authorizer.Contributions()) - // Phase A binds the resource at the type level (Action's namespace is the - // resource type by convention). URL-id binding to an instance resource is - // Phase B; idParam is recorded on the declaration for that step. + // The resource is bound at the type level (the action's resource segment by + // convention). Instance binding from decl.idParam is not yet implemented; + // idParam is recorded on the declaration for that step. req := cedar.Request{ Principal: principal.UID, Action: decl.action, @@ -175,9 +184,10 @@ func (m *Middleware) evaluate(ctx huma.Context, principal Principal, decl *decla return decisionDeny } -// resourceFor builds the Cedar resource entity for decl. Phase A returns a -// type-level resource derived from the action's resource segment; Phase B will -// read decl.idParam from ctx to build an instance-level Type::"". +// resourceFor builds the Cedar resource entity for decl. It returns a type-level +// resource derived from the action's resource segment; instance binding that +// reads decl.idParam from ctx to build an instance-level Type::"" is not yet +// implemented. func resourceFor(decl *declaration, _ huma.Context) cedar.EntityUID { return resourceTypeFromAction(decl.action) } diff --git a/internal/config/config.go b/internal/config/config.go index 5d954ba..f0d35a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,10 +24,10 @@ const ( defaultLogLevel = "info" defaultLogFormat = "json" defaultDBMaxConns = 0 - // defaultAuthzEnabled is false in this phase: the engine ships with an empty - // contribution set and a deny-by-default posture, so enabling it before - // routes are tagged would reject every untagged operation. A later phase - // tags routes and flips this to true. + // defaultAuthzEnabled is false: the engine ships with an empty contribution + // set and a deny-by-default posture, so enabling it before routes carry an + // authorization declaration would reject every untagged operation. Operators + // enable it once their routes declare Require/Public. defaultAuthzEnabled = false ) @@ -69,8 +69,8 @@ type Config struct { DBMaxConns int32 // AuthzEnabled is the authorization master switch. When false the authz // middleware is inert (pass-through), the escape hatch for incremental - // adoption. It defaults to false this phase (the engine ships with no tagged - // routes, and deny-by-default would otherwise reject them all). + // adoption. It defaults to false: until routes carry an authorization + // declaration, deny-by-default would otherwise reject them all. AuthzEnabled bool // AuthzPolicyDir optionally loads .cedar policy files from a directory // instead of the embedded set. Empty (the default) uses the embedded From 138d8e91a00858bb1571f36bc0cb64297ab37690 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 18:16:04 -0700 Subject: [PATCH 03/10] refactor(postgres): set sqlc omit_unused_structs to keep the todo sqlc package todo-only --- internal/todo/postgres/sqlc/models.go | 6 ------ sqlc.yaml | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/todo/postgres/sqlc/models.go b/internal/todo/postgres/sqlc/models.go index b811576..2bd0690 100644 --- a/internal/todo/postgres/sqlc/models.go +++ b/internal/todo/postgres/sqlc/models.go @@ -10,12 +10,6 @@ import ( "github.com/google/uuid" ) -type ApiKey struct { - Key string - Subject string - Roles []string -} - type Todo struct { ID uuid.UUID Title string diff --git a/sqlc.yaml b/sqlc.yaml index 022210e..9eaaeb5 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -18,6 +18,12 @@ sql: sql_package: pgx/v5 emit_pointers_for_null_types: true emit_interface: true + # Only emit structs for tables/enums referenced by a todo query. The + # api_keys table lives in the shared migrations dir (read as the schema) + # but is queried by the apikey adapter's hand-written pgx, not sqlc, so + # this keeps the todo sqlc package free of an unused ApiKey model and + # leaves it untouched whether the api_keys migration is present or not. + omit_unused_structs: true overrides: # ID column maps to the same uuid.UUID the rest of the codebase uses; # the adapter converts to/from the domain's string at the boundary. From e63902d08770c70a79b4c7316caa1df60191074f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 18:42:22 -0700 Subject: [PATCH 04/10] refactor(authz): precedence fix, URL-id binding, install/finalize split 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::"" 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) --- internal/adapter/http/api.go | 11 ++- internal/adapter/http/router.go | 33 ++++++--- internal/authz/authz.go | 46 ++++++++++++ internal/authz/contribution.go | 8 +++ internal/authz/declare.go | 11 +++ internal/authz/getter.go | 7 +- internal/authz/getter_test.go | 3 + internal/authz/middleware.go | 67 +++++++++++++----- internal/authz/middleware_test.go | 48 +++++++++++++ internal/authz/ownership_test.go | 102 +++++++++++++++++++++++++++ internal/authz/principal_resolver.go | 24 +++++-- 11 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 internal/authz/ownership_test.go diff --git a/internal/adapter/http/api.go b/internal/adapter/http/api.go index f2d2f7e..70372f9 100644 --- a/internal/adapter/http/api.go +++ b/internal/adapter/http/api.go @@ -28,11 +28,20 @@ func NewAPI(mux chi.Router, version string) huma.API { // SpecYAML builds the API on a throwaway router, applies register, and returns the // OpenAPI 3.0.3 specification as YAML, without binding a network listener. -func SpecYAML(version string, register Registrar) ([]byte, error) { +// +// finalize, when non-nil, runs after the operations are registered and before +// the document is serialized. The composition root passes the authz hook here so +// the server-less export carries the same security scheme and per-operation +// requirements the running server installs — keeping the committed spec in step +// with the enforced protection. It is nil when authorization is disabled. +func SpecYAML(version string, register Registrar, finalize func(huma.API)) ([]byte, error) { api := NewAPI(chi.NewMux(), version) if register != nil { register(api) } + if finalize != nil { + finalize(api) + } spec, err := api.OpenAPI().DowngradeYAML() if err != nil { diff --git a/internal/adapter/http/router.go b/internal/adapter/http/router.go index 52a5552..10f58d8 100644 --- a/internal/adapter/http/router.go +++ b/internal/adapter/http/router.go @@ -40,10 +40,18 @@ type RouterDeps struct { // Register mounts resource operations onto the Huma API. Register Registrar // InstallAuthz installs the authentication/authorization Huma middleware on - // the API. It runs after the resource operations are registered. Nil (or a - // disabled middleware) leaves the API unauthenticated, which is the default - // until routes are tagged. + // the API. It MUST run before the resource operations are registered: Huma + // snapshots the API's middleware stack into each operation at registration + // time, so middleware added afterward never runs. Nil (or a disabled + // middleware) leaves the API unauthenticated — the escape hatch. InstallAuthz func(huma.API) + // FinalizeAuthz stamps the OpenAPI security scheme and per-operation security + // requirements onto the document. It MUST run after registration (it iterates + // the registered operations). Nil (or a disabled middleware) leaves the spec + // without security. It is the post-register counterpart to InstallAuthz, + // split because Huma fixes an operation's middleware at registration time + // while its OpenAPI metadata can be mutated afterward. + FinalizeAuthz func(huma.API) } // NewRouter assembles the chi router: the core middleware stack, RFC 9457 error @@ -83,16 +91,23 @@ func NewRouter(deps RouterDeps) http.Handler { problem.Write(w, http.StatusMethodNotAllowed, "the method is not allowed for this resource") }) - // Resource operations are mounted by their adapter packages via the Registrar. api := NewAPI(mux, deps.Version) + // The authn/authz Huma middleware is installed BEFORE the operations are + // registered: Huma bakes the API's middleware stack into each operation at + // registration time, so middleware added afterward would never run. It is a + // no-op when authorization is disabled. + if deps.InstallAuthz != nil { + deps.InstallAuthz(api) + } + // Resource operations are mounted by their adapter packages via the Registrar. if deps.Register != nil { deps.Register(api) } - // The authn/authz Huma middleware is installed after registration; it - // enforces per-operation declarations at request time, so registration order - // is irrelevant. It is a no-op when authorization is disabled. - if deps.InstallAuthz != nil { - deps.InstallAuthz(api) + // OpenAPI security is stamped AFTER registration, once the operations exist; + // it only mutates document metadata, so it is safe post-register. No-op when + // authorization is disabled. + if deps.FinalizeAuthz != nil { + deps.FinalizeAuthz(api) } // Infrastructure routes stay raw chi and are excluded from the spec. diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 5728b26..727f5b0 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -62,6 +62,10 @@ func New(contributions []Contribution, opts ...Option) (*Authorizer, error) { opt(&cfg) } + if err := validateTypeOwnership(contributions); err != nil { + return nil, err + } + base, err := loadBasePolicies(cfg.policyDir) if err != nil { return nil, err @@ -93,6 +97,48 @@ func New(contributions []Contribution, opts ...Option) (*Authorizer, error) { return &Authorizer{policies: merged, contributions: all}, nil } +// validateTypeOwnership enforces, at construction, that entity-type ownership is +// unambiguous across the merged engine. It is the fail-fast guard behind the +// composite getter's single-owner-per-type routing: +// - No slice may claim a reserved principal type (PrincipalType/AnonymousType), +// so a slice resolver can never shadow the always-present principal resolver. +// - No two slices may claim the same type, so a lookup routes to exactly one +// resolver and a type's facts have a single source of truth. +// +// A misconfigured contribution set therefore fails startup rather than silently +// shadowing facts or the principal at request time. +func validateTypeOwnership(contributions []Contribution) error { + reservedNames := reservedTypes() + reserved := make(map[string]struct{}, len(reservedNames)) + for _, t := range reservedNames { + reserved[t] = struct{}{} + } + + owner := make(map[string]int) + for i, c := range contributions { + for _, t := range c.Types { + if _, isReserved := reserved[t]; isReserved { + return fmt.Errorf( + "contribution %d claims reserved principal type %q: it is owned by the base principal resolver and cannot be overridden", + i, + t, + ) + } + if prev, dup := owner[t]; dup { + return fmt.Errorf( + "contributions %d and %d both claim entity type %q: each Cedar entity type must be owned by exactly one slice", + prev, + i, + t, + ) + } + owner[t] = i + } + } + + return nil +} + // loadBasePolicies returns the base policy source: the .cedar files concatenated // from dir when dir is non-empty, otherwise the embedded base.cedar. The files // are read in sorted name order so the merge (and policy IDs) are deterministic. diff --git a/internal/authz/contribution.go b/internal/authz/contribution.go index c4f8b62..8f3f094 100644 --- a/internal/authz/contribution.go +++ b/internal/authz/contribution.go @@ -20,6 +20,14 @@ type Contribution struct { // Action::"todo:read"). They are recorded for discovery and validation; the // engine does not require them to evaluate. Actions []types.EntityUID + // Types lists the Cedar entity type names this slice's resolver owns (for + // example "Todo"). It is the authoritative routing key: [New] validates that + // no two contributions claim the same type and that a slice never claims a + // reserved principal type, and the composite getter routes by it so a slice + // resolver can never shadow the always-present principal resolver. A slice + // with a Resolver must declare the types it owns; a slice contributing only + // coarse policies leaves it empty. + Types []string // Resolver builds this slice's entity resolver, bound to a request. It is nil // for slices that contribute only coarse policies needing no entity loads. Resolver ResolverFactory diff --git a/internal/authz/declare.go b/internal/authz/declare.go index ee836ff..e9676c5 100644 --- a/internal/authz/declare.go +++ b/internal/authz/declare.go @@ -115,6 +115,17 @@ func pathOperations(item *huma.PathItem) []*huma.Operation { return ops } +// DocumentSecurity stamps the security scheme and the per-operation security +// requirements onto api's OpenAPI document without installing the enforcing +// middleware. It is the server-less export path's counterpart to Install: the +// composition root passes it to the OpenAPI exporter so the committed spec +// advertises the same protection the running server enforces. Like Install, it +// must run after the operations are registered so ApplySecurity sees them. +func DocumentSecurity(api huma.API) { + RegisterSecurityScheme(api) + ApplySecurity(api) +} + // RegisterSecurityScheme declares the API-key security scheme on api's OpenAPI // document, so the Security requirement Require advertises resolves to a defined // scheme. The composition root calls it once when authorization is enabled. diff --git a/internal/authz/getter.go b/internal/authz/getter.go index 3dcc20b..80592b0 100644 --- a/internal/authz/getter.go +++ b/internal/authz/getter.go @@ -65,7 +65,12 @@ func newGetter(ctx context.Context, p Principal, contributions []Contribution) * continue } resolver := c.Resolver(g.ctx, p) - for _, t := range resolver.Types() { + // Route by the contribution's statically declared Types, not the + // resolver's runtime Types(), so a slice resolver cannot claim a type it + // did not declare (and [New] rejected) and thereby shadow the principal + // resolver. Contributions are applied in order, but [New] guarantees the + // keys are disjoint, so order does not affect routing. + for _, t := range c.Types { g.byType[t] = resolver } } diff --git a/internal/authz/getter_test.go b/internal/authz/getter_test.go index ac6679c..23da734 100644 --- a/internal/authz/getter_test.go +++ b/internal/authz/getter_test.go @@ -41,6 +41,7 @@ func TestGetterRoutesAndCaches(t *testing.T) { todoUID := types.NewEntityUID("Todo", "1") resolver := &recordingResolver{typ: "Todo", uid: todoUID} g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Types: []string{"Todo"}, Resolver: func(ctx context.Context, _ Principal) EntityResolver { resolver.sinkOf = ctx @@ -63,6 +64,7 @@ func TestGetterCachesMisses(t *testing.T) { resolver := &recordingResolver{typ: "Todo", uid: types.NewEntityUID("Todo", "1")} g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Types: []string{"Todo"}, Resolver: func(ctx context.Context, _ Principal) EntityResolver { resolver.sinkOf = ctx @@ -92,6 +94,7 @@ func TestGetterCapturesFirstLoadError(t *testing.T) { first := errors.New("first failure") resolver := &recordingResolver{typ: "Todo", err: first} g := newGetter(context.Background(), Anonymous(), []Contribution{{ + Types: []string{"Todo"}, Resolver: func(ctx context.Context, _ Principal) EntityResolver { resolver.sinkOf = ctx diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go index 7545320..125927e 100644 --- a/internal/authz/middleware.go +++ b/internal/authz/middleware.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/cedar-policy/cedar-go" + "github.com/cedar-policy/cedar-go/types" "github.com/danielgtaylor/huma/v2" ) @@ -60,22 +61,40 @@ func NewMiddleware( } } -// Install registers the authn and authz middleware on the API, declares the -// API-key security scheme, and stamps the OpenAPI Security requirement onto every -// operation that Require declared. It must run after the operations are -// registered so ApplySecurity sees them. It is a no-op when the middleware is -// disabled, so all operations run unauthenticated and unauthorized — the default -// that keeps untagged routes (and their tests) working until they are tagged. +// Install registers the authn and authz middleware on the API. It MUST run +// before the resource operations are registered: Huma snapshots the API's +// middleware stack into each operation at huma.Register time, so middleware added +// afterward never runs for those operations. It is a no-op when the middleware is +// disabled, so all operations run unauthenticated and unauthorized — the escape +// hatch that bypasses authorization entirely. +// +// OpenAPI security stamping is deliberately NOT done here, because it must run +// after registration (ApplySecurity needs the operations present); the +// composition root calls Finalize for that, or the server-less exporter calls +// DocumentSecurity directly. func (m *Middleware) Install() { if !m.enabled { return } - RegisterSecurityScheme(m.api) - ApplySecurity(m.api) m.api.UseMiddleware(m.authenticate, m.authorize) } +// Finalize stamps the API-key security scheme and the per-operation security +// requirements onto the OpenAPI document. It MUST run after the resource +// operations are registered (ApplySecurity iterates the registered paths). It is +// a no-op when the middleware is disabled, so a bypassed API advertises no +// security it does not enforce. Pairing Install (pre-register) with Finalize +// (post-register) is required because Huma fixes an operation's middleware at +// registration time while its OpenAPI metadata can be mutated afterward. +func (m *Middleware) Finalize() { + if !m.enabled { + return + } + + DocumentSecurity(m.api) +} + // authenticate runs the configured Authenticator and stores the resulting // Principal in the request context for the downstream authz middleware. A // missing credential yields an anonymous principal (authorization decides @@ -151,9 +170,6 @@ const ( func (m *Middleware) evaluate(ctx huma.Context, principal Principal, decl *declaration) outcome { getter := newGetter(ctx.Context(), principal, m.authorizer.Contributions()) - // The resource is bound at the type level (the action's resource segment by - // convention). Instance binding from decl.idParam is not yet implemented; - // idParam is recorded on the declaration for that step. req := cedar.Request{ Principal: principal.UID, Action: decl.action, @@ -184,12 +200,29 @@ func (m *Middleware) evaluate(ctx huma.Context, principal Principal, decl *decla return decisionDeny } -// resourceFor builds the Cedar resource entity for decl. It returns a type-level -// resource derived from the action's resource segment; instance binding that -// reads decl.idParam from ctx to build an instance-level Type::"" is not yet -// implemented. -func resourceFor(decl *declaration, _ huma.Context) cedar.EntityUID { - return resourceTypeFromAction(decl.action) +// resourceFor builds the Cedar resource entity for decl. When the declaration +// binds a path parameter (Require(action, idParam)), the resource is the +// instance Type::"", read straight from the matched route's path value — no +// database load — so policies can decide on the specific instance. Without a +// bound parameter (collection operations), the resource is the type-level entity +// derived from the action's resource segment. +// +// The route is matched before the Huma middleware runs, so ctx.Param(idParam) +// returns the matched path value here. An empty value (a missing or unmatched +// parameter) falls back to the type-level resource rather than minting a +// Type::"" instance, keeping a misconfiguration coarse rather than nonsensical. +func resourceFor(decl *declaration, ctx huma.Context) cedar.EntityUID { + resourceType := resourceTypeFromAction(decl.action) + if decl.idParam == "" { + return resourceType + } + + id := ctx.Param(decl.idParam) + if id == "" { + return resourceType + } + + return cedar.NewEntityUID(resourceType.Type, types.String(id)) } // principal returns the request principal, defaulting to anonymous when the diff --git a/internal/authz/middleware_test.go b/internal/authz/middleware_test.go index e5a3445..51cd715 100644 --- a/internal/authz/middleware_test.go +++ b/internal/authz/middleware_test.go @@ -191,6 +191,7 @@ func TestMiddlewareFailsClosedOnLoadError(t *testing.T) { contribution := authz.Contribution{ Policies: []byte(attrPolicy), + Types: []string{"Todo"}, Resolver: func(ctx context.Context, _ authz.Principal) authz.EntityResolver { return &failingResolver{ctx: ctx} }, @@ -245,3 +246,50 @@ func TestMiddlewareDisabledIsPassThrough(t *testing.T) { resp := api.Get("/undeclared") assert.Equal(t, http.StatusNoContent, resp.Code) } + +// idInput binds the {id} path parameter so the route is matched with a path +// value the authz middleware can read via ctx.Param. +type idInput struct { + ID string `path:"id"` +} + +func TestMiddlewareBindsURLIDToResource(t *testing.T) { + t.Parallel() + + // This policy permits the action only on the specific instance Todo::"42", + // so an Allow proves the middleware bound the {id} path value into + // Request.Resource (Todo::"") — with no entity load. + const instancePolicy = `permit ( + principal, + action == Action::"todo:read", + resource == Todo::"42" +);` + + authorizer, err := authz.New([]authz.Contribution{{Policies: []byte(instancePolicy)}}) + require.NoError(t, err) + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(user("alice"), nil) + + _, api := humatest.New(t) + logger := slog.New(slog.DiscardHandler) + authz.NewMiddleware(api, authn, authorizer, logger, true).Install() + + huma.Register(api, huma.Operation{ + OperationID: "get-item", + Method: http.MethodGet, + Path: "/todos/{id}", + Metadata: authz.Require(actionRead(), "id"), + }, func(_ context.Context, _ *idInput) (*struct{}, error) { + return &struct{}{}, nil + }) + + // The matching instance is allowed: the path id resolved to Todo::"42". + allowed := api.Get("/todos/42") + assert.Equal(t, http.StatusNoContent, allowed.Code, "the bound instance Todo::\"42\" must be allowed") + + // A different instance is denied: the path id resolved to Todo::"99", which + // the instance policy does not permit, proving the binding is per-request. + denied := api.Get("/todos/99") + assert.Equal(t, http.StatusForbidden, denied.Code, "a non-matching instance must be denied") +} diff --git a/internal/authz/ownership_test.go b/internal/authz/ownership_test.go new file mode 100644 index 0000000..232d4ae --- /dev/null +++ b/internal/authz/ownership_test.go @@ -0,0 +1,102 @@ +package authz + +import ( + "context" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// staticResolver owns one type and resolves a single fixed entity, so the +// precedence test can prove which resolver a lookup routes to. +type staticResolver struct { + typ string + entity types.Entity +} + +func (r *staticResolver) Types() []string { return []string{r.typ} } + +func (r *staticResolver) Resolve(uid types.EntityUID) (types.Entity, bool) { + if uid == r.entity.UID { + return r.entity, true + } + + return types.Entity{}, false +} + +func TestNewRejectsDuplicateTypeOwnership(t *testing.T) { + t.Parallel() + + contribs := []Contribution{ + {Types: []string{"Todo"}, Resolver: nopResolver}, + {Types: []string{"Todo"}, Resolver: nopResolver}, + } + + _, err := New(contribs) + require.Error(t, err, "two slices claiming the same entity type must fail construction") + assert.Contains(t, err.Error(), "Todo") +} + +func TestNewRejectsSliceClaimingReservedPrincipalType(t *testing.T) { + t.Parallel() + + // A slice may not claim the principal's reserved type; doing so would let it + // shadow the always-present principal resolver in the composite getter. + _, err := New([]Contribution{{Types: []string{string(PrincipalType)}, Resolver: nopResolver}}) + require.Error(t, err, "a slice claiming the reserved principal type must fail construction") + assert.Contains(t, err.Error(), string(PrincipalType)) +} + +func TestNewAllowsDistinctTypes(t *testing.T) { + t.Parallel() + + _, err := New([]Contribution{ + {Types: []string{"Todo"}, Resolver: nopResolver}, + {Types: []string{"Project"}, Resolver: nopResolver}, + }) + require.NoError(t, err, "slices owning distinct types must construct cleanly") +} + +// TestPrincipalResolverWinsOverSliceForPrincipalType proves the precedence fix +// holds even when a slice resolver claims, at runtime, to own the principal type +// (a Types() that disagrees with its declared Contribution.Types). Routing keys +// off the contribution's declared Types, which New validated, so a User lookup +// still resolves to the principal resolver — never the rogue slice resolver. +func TestPrincipalResolverWinsOverSliceForPrincipalType(t *testing.T) { + t.Parallel() + + principal := Principal{UID: types.NewEntityUID(PrincipalType, "alice")} + + // A misbehaving slice resolver: it declares "Todo" on its Contribution but + // its Types() lies and claims the principal type. The static routing must + // ignore Types() and route only by the declared "Todo". + rogue := &staticResolver{ + typ: string(PrincipalType), + entity: types.Entity{ + UID: types.NewEntityUID(PrincipalType, "alice"), + Attributes: types.NewRecord(types.RecordMap{"rogue": types.Boolean(true)}), + }, + } + contrib := Contribution{ + Types: []string{"Todo"}, + Resolver: func(_ context.Context, _ Principal) EntityResolver { return rogue }, + } + + authorizer, err := New([]Contribution{contrib}) + require.NoError(t, err) + + getter := newGetter(context.Background(), principal, authorizer.Contributions()) + entity, ok := getter.Get(principal.UID) + + require.True(t, ok, "the principal entity must resolve") + _, hasRogue := entity.Attributes.Get("rogue") + assert.False(t, hasRogue, "the principal lookup must route to the principal resolver, not the slice resolver") +} + +// nopResolver is a ResolverFactory that returns a resolver owning nothing useful; +// the ownership tests only exercise New's validation, never Resolve. +func nopResolver(_ context.Context, _ Principal) EntityResolver { + return &staticResolver{} +} diff --git a/internal/authz/principal_resolver.go b/internal/authz/principal_resolver.go index 561dbc2..9ea931d 100644 --- a/internal/authz/principal_resolver.go +++ b/internal/authz/principal_resolver.go @@ -11,18 +11,32 @@ import ( // Role::"", so policies can test `principal in Role::"…"` with no load. const RoleType types.EntityType = "Role" +// PrincipalType is the Cedar entity type of an authenticated caller. The API-key +// authenticator mints principals of this type; it is reserved so no slice +// resolver can shadow the always-present principal resolver. +const PrincipalType types.EntityType = "User" + // RolesClaim is the claim key the principal resolver reads the caller's roles // from, and the key an Authenticator writes them under, so role membership is // projected onto the principal entity's parents for `principal in Role::"…"`. const RolesClaim types.String = "roles" +// reservedTypes returns the Cedar entity types owned by the base principal +// resolver: the authenticated and anonymous principal types. They can never be +// claimed by a slice contribution, so a slice resolver cannot shadow the +// principal resolver in the composite getter (see [New] and [newGetter]). Role +// entities carry no attributes and need no resolver, so Role is not listed here. +func reservedTypes() []string { + return []string{string(PrincipalType), string(AnonymousType)} +} + // principalContribution is the always-present base contribution that resolves -// the authenticated principal entity from its claims. It owns the principal -// entity types so the composite getter routes principal lookups here, letting -// cross-cutting policies (base.cedar's admin override) and slice policies test -// principal group membership without any database load. +// the authenticated principal entity from its claims. It owns the reserved +// principal entity types so the composite getter routes principal lookups here, +// letting cross-cutting policies (base.cedar's admin override) and slice policies +// test principal group membership without any database load. func principalContribution() Contribution { - return Contribution{Resolver: newPrincipalResolver} + return Contribution{Types: reservedTypes(), Resolver: newPrincipalResolver} } // principalResolver materializes the request's principal entity (and its role From 73370c88a608d32a24583f438e0c265dc41d887e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 18:42:32 -0700 Subject: [PATCH 05/10] feat(authz): add todo authz slice, tag routes, enable authz (phase B) - 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::"". - 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) --- docs/docs/openapi.yaml | 14 +++ internal/app/app.go | 104 ++++++++++++++----- internal/app/app_test.go | 52 +++++++++- internal/config/config.go | 20 ++-- internal/config/config_test.go | 2 +- internal/todo/authz/actions.go | 49 +++++++++ internal/todo/authz/contribution.go | 33 ++++++ internal/todo/authz/facts.go | 93 +++++++++++++++++ internal/todo/authz/facts_test.go | 154 ++++++++++++++++++++++++++++ internal/todo/authz/policy.cedar | 44 ++++++++ internal/todo/authz/policy_test.go | 104 +++++++++++++++++++ internal/todo/httpapi/handler.go | 15 +++ 12 files changed, 649 insertions(+), 35 deletions(-) create mode 100644 internal/todo/authz/actions.go create mode 100644 internal/todo/authz/contribution.go create mode 100644 internal/todo/authz/facts.go create mode 100644 internal/todo/authz/facts_test.go create mode 100644 internal/todo/authz/policy.cedar create mode 100644 internal/todo/authz/policy_test.go diff --git a/docs/docs/openapi.yaml b/docs/docs/openapi.yaml index b5c21ac..a4fa2fe 100644 --- a/docs/docs/openapi.yaml +++ b/docs/docs/openapi.yaml @@ -122,6 +122,12 @@ components: - status - createdAt type: object + securitySchemes: + apiKey: + description: "API key supplied via the X-API-Key header or an Authorization: Bearer credential." + in: header + name: X-API-Key + type: apiKey info: title: template-go-api version: dev @@ -143,6 +149,8 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Error + security: + - apiKey: [] summary: List todos tags: - Todos @@ -167,6 +175,8 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Error + security: + - apiKey: [] summary: Create a todo tags: - Todos @@ -207,6 +217,8 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Internal Server Error + security: + - apiKey: [] summary: Get a todo by id tags: - Todos @@ -247,6 +259,8 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Internal Server Error + security: + - apiKey: [] summary: Mark a todo as completed tags: - Todos diff --git a/internal/app/app.go b/internal/app/app.go index 1b63c7d..9d6f17c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "github.com/meigma/template-go-api/internal/config" "github.com/meigma/template-go-api/internal/observability" "github.com/meigma/template-go-api/internal/todo" + todoauthz "github.com/meigma/template-go-api/internal/todo/authz" "github.com/meigma/template-go-api/internal/todo/httpapi" todopostgres "github.com/meigma/template-go-api/internal/todo/postgres" ) @@ -40,7 +41,8 @@ type App struct { type Option func(*options) type options struct { - repo todo.Repository + repo todo.Repository + authenticator authz.Authenticator } // WithRepository injects a ready-made todo.Repository instead of connecting the @@ -53,6 +55,17 @@ func WithRepository(repo todo.Repository) Option { } } +// WithAuthenticator injects an authz.Authenticator instead of wiring the shipped +// PostgreSQL-backed API-key authenticator. It mirrors WithRepository: tests use +// it to authenticate a request without a database (so authz can run with +// AuthzEnabled true and no api_keys table), and integrators use it to plug in a +// real verifier (JWT/OIDC/session) without editing the composition root. +func WithAuthenticator(authenticator authz.Authenticator) Option { + return func(o *options) { + o.authenticator = authenticator + } +} + // New wires the application from cfg and logger. version is reported in the // OpenAPI document served by the API. Unless a repository is injected with // WithRepository, it connects a PostgreSQL connection pool, which can fail. The @@ -77,7 +90,7 @@ func New( service := todo.NewService(repo, logger) metrics := observability.NewMetrics() - installAuthz, err := authzInstaller(cfg, pool, logger) + installAuthz, finalizeAuthz, err := authzInstaller(cfg, repo, pool, logger, o.authenticator) if err != nil { return nil, err } @@ -95,9 +108,10 @@ func New( TrustedProxyHeader: cfg.TrustedProxyHeader, // The postgres store contributes a real connectivity check here; an // injected repository (tests) contributes none, so /readyz is always ready. - Readiness: readiness, - Register: registerResources(service), - InstallAuthz: installAuthz, + Readiness: readiness, + Register: registerResources(service), + InstallAuthz: installAuthz, + FinalizeAuthz: finalizeAuthz, }) server := &http.Server{ @@ -155,38 +169,75 @@ func resolveStore( } // authzInstaller builds the authorization engine and returns a hook that -// installs the authn/authz Huma middleware on the API. The Authorizer is built -// from an empty contribution set (only the base cross-cutting policies); a later -// slice passes each domain's Contribution. cfg.AuthzPolicyDir, when set, loads -// the base policies from that directory instead of the embedded base.cedar. The -// API-key authenticator is PostgreSQL-backed, so it requires a pool when +// installs the authn/authz Huma middleware on the API. The Authorizer merges the +// base cross-cutting policies with each domain slice's Contribution — the todo +// slice contributes its policies, actions, and a repo-backed fact resolver, so +// an attribute policy can load a todo lazily through repo. Adding a resource adds +// one Contribution here. cfg.AuthzPolicyDir, when set, loads the base policies +// from that directory instead of the embedded base.cedar. +// +// Authentication is resolved through WithAuthenticator when one is injected +// (tests, or an integrator-supplied verifier); otherwise the shipped +// PostgreSQL-backed API-key authenticator is wired, which needs a pool when // authorization is enabled. The middleware is inert when cfg.AuthzEnabled is -// false, keeping every untagged route working. +// false, keeping every route working without authentication. func authzInstaller( cfg config.Config, + repo todo.Repository, pool *pgxpool.Pool, logger *slog.Logger, -) (func(huma.API), error) { - authorizer, err := authz.New(nil, authz.WithPolicyDir(cfg.AuthzPolicyDir)) + injected authz.Authenticator, +) (func(huma.API), func(huma.API), error) { + authorizer, err := authz.New( + []authz.Contribution{todoauthz.Contribution(repo)}, + authz.WithPolicyDir(cfg.AuthzPolicyDir), + ) if err != nil { - return nil, fmt.Errorf("build authorizer: %w", err) + return nil, nil, fmt.Errorf("build authorizer: %w", err) + } + + authenticator, err := resolveAuthenticator(cfg, pool, injected) + if err != nil { + return nil, nil, err + } + + // install registers the middleware before resources are mounted; finalize + // stamps the OpenAPI security after — the split Huma's registration-time + // middleware snapshot requires. + install := func(api huma.API) { + authz.NewMiddleware(api, authenticator, authorizer, logger, cfg.AuthzEnabled).Install() + } + finalize := func(api huma.API) { + authz.NewMiddleware(api, authenticator, authorizer, logger, cfg.AuthzEnabled).Finalize() + } + + return install, finalize, nil +} + +// resolveAuthenticator selects the authenticator the middleware runs. An injected +// authenticator (WithAuthenticator) is used as-is, the seam tests use to satisfy +// authz without a database and integrators use to plug in a real verifier. +// Otherwise the shipped PostgreSQL-backed API-key authenticator is wired, which +// requires a pool when authorization is enabled; when disabled the middleware +// never runs, so a nil authenticator is harmless. +func resolveAuthenticator( + cfg config.Config, + pool *pgxpool.Pool, + injected authz.Authenticator, +) (authz.Authenticator, error) { + if injected != nil { + return injected, nil } - // The authenticator resolves keys from PostgreSQL. When authorization is - // enabled it needs a pool (an injected-repository wiring has none); when - // disabled the middleware never runs, so a nil store is harmless. if cfg.AuthzEnabled && pool == nil { return nil, errors.New("authz-enabled requires a database connection for the api-key store") } - var authenticator authz.Authenticator - if pool != nil { - authenticator = apikey.NewAuthenticator(apikey.NewStore(pool)) + if pool == nil { + return nil, nil //nolint:nilnil // a disabled middleware never invokes the authenticator. } - return func(api huma.API) { - authz.NewMiddleware(api, authenticator, authorizer, logger, cfg.AuthzEnabled).Install() - }, nil + return apikey.NewAuthenticator(apikey.NewStore(pool)), nil } // Handler returns the assembled HTTP handler, primarily for functional tests. @@ -198,10 +249,15 @@ func (a *App) Handler() http.Handler { // OpenAPI 3.0.3 specification as YAML. The repository is never invoked while // generating the spec, so a no-op stub stands in for the real adapter and no // database connection is required. +// +// The routes are tagged with their authorization declarations, so the export +// also stamps the security scheme and per-operation requirements via +// authz.DocumentSecurity — independently of the runtime --authz-enabled flag, so +// the committed spec always advertises the protection the routes declare. func OpenAPIYAML(version string) ([]byte, error) { service := todo.NewService(noopRepository{}, nil) - spec, err := adapterhttp.SpecYAML(version, registerResources(service)) + spec, err := adapterhttp.SpecYAML(version, registerResources(service), authz.DocumentSecurity) if err != nil { return nil, fmt.Errorf("build openapi spec: %w", err) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7911ca9..4334632 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -9,16 +9,40 @@ import ( "strings" "testing" + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/meigma/template-go-api/internal/app" + "github.com/meigma/template-go-api/internal/authz" "github.com/meigma/template-go-api/internal/config" "github.com/meigma/template-go-api/internal/observability" "github.com/meigma/template-go-api/internal/todo/todotest" ) +// stubAuthenticator authenticates every request as a fixed principal carrying +// the given roles, so the wiring test can drive the authz-enabled server without +// a database-backed api-key store. It satisfies authz.Authenticator. +type stubAuthenticator struct { + roles []string +} + +func (s stubAuthenticator) Authenticate(_ huma.Context) (authz.Principal, error) { + roleValues := make([]types.Value, 0, len(s.roles)) + for _, role := range s.roles { + roleValues = append(roleValues, types.String(role)) + } + + return authz.Principal{ + UID: types.NewEntityUID("User", "test-user"), + Claims: types.NewRecord(types.RecordMap{ + authz.RolesClaim: types.NewSet(roleValues...), + }), + }, nil +} + func TestAppWiring(t *testing.T) { t.Parallel() @@ -26,10 +50,14 @@ func TestAppWiring(t *testing.T) { logger := observability.NewLogger(io.Discard, slog.LevelError, "json") // Inject an in-memory repository so the composition root wires a working // server without a database — the postgres path is covered by the - // container-backed integration suite. + // container-backed integration suite. Authorization defaults to ON now that + // the routes are tagged, so inject a stub authenticator that grants the + // "user" role the todo policy requires; without it the api-key store would + // need a database. application, err := app.New( context.Background(), cfg, logger, "test", app.WithRepository(todotest.NewRepository()), + app.WithAuthenticator(stubAuthenticator{roles: []string{"user"}}), ) require.NoError(t, err) @@ -47,3 +75,25 @@ func TestAppWiring(t *testing.T) { handler.ServeHTTP(createRec, createReq) assert.Equal(t, http.StatusCreated, createRec.Code) } + +// TestAppWiringDeniesUnauthorized proves authorization is wired and enforced by +// default: a principal carrying no role the todo policy grants is denied (403), +// so the create operation never reaches the handler. +func TestAppWiringDeniesUnauthorized(t *testing.T) { + t.Parallel() + + cfg := config.Load(viper.New()) + logger := observability.NewLogger(io.Discard, slog.LevelError, "json") + application, err := app.New( + context.Background(), cfg, logger, "test", + app.WithRepository(todotest.NewRepository()), + app.WithAuthenticator(stubAuthenticator{roles: nil}), + ) + require.NoError(t, err) + + createReq := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(`{"title":"x"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + application.Handler().ServeHTTP(createRec, createReq) + assert.Equal(t, http.StatusForbidden, createRec.Code) +} diff --git a/internal/config/config.go b/internal/config/config.go index f0d35a0..17c3b7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,11 +24,12 @@ const ( defaultLogLevel = "info" defaultLogFormat = "json" defaultDBMaxConns = 0 - // defaultAuthzEnabled is false: the engine ships with an empty contribution - // set and a deny-by-default posture, so enabling it before routes carry an - // authorization declaration would reject every untagged operation. Operators - // enable it once their routes declare Require/Public. - defaultAuthzEnabled = false + // defaultAuthzEnabled is true: the todo routes now carry their authorization + // declarations and the engine merges the base policies with each slice's + // Contribution, so the deny-by-default middleware protects the API out of the + // box. Operators set it false as an escape hatch to bypass authorization + // entirely (incremental adoption or local debugging). + defaultAuthzEnabled = true ) // Config holds runtime settings for the API server. @@ -67,10 +68,11 @@ type Config struct { // DBMaxConns caps the PostgreSQL connection pool size. Zero leaves the // driver default in place. DBMaxConns int32 - // AuthzEnabled is the authorization master switch. When false the authz - // middleware is inert (pass-through), the escape hatch for incremental - // adoption. It defaults to false: until routes carry an authorization - // declaration, deny-by-default would otherwise reject them all. + // AuthzEnabled is the authorization master switch. It defaults to true now + // that the routes carry their authorization declarations: the deny-by-default + // middleware protects the API out of the box. When false the authz middleware + // is inert (pass-through), the escape hatch for incremental adoption or local + // debugging. AuthzEnabled bool // AuthzPolicyDir optionally loads .cedar policy files from a directory // instead of the embedded set. Empty (the default) uses the embedded diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7332131..a5a16ca 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,7 +24,7 @@ func TestLoadDefaults(t *testing.T) { assert.Empty(t, cfg.TrustedProxyHeader) assert.Empty(t, cfg.DatabaseURL) assert.Zero(t, cfg.DBMaxConns) - assert.False(t, cfg.AuthzEnabled, "authz is disabled by default until routes are tagged") + assert.True(t, cfg.AuthzEnabled, "authz is enabled by default now that the routes are tagged") assert.Empty(t, cfg.AuthzPolicyDir) } diff --git a/internal/todo/authz/actions.go b/internal/todo/authz/actions.go new file mode 100644 index 0000000..ed6161a --- /dev/null +++ b/internal/todo/authz/actions.go @@ -0,0 +1,49 @@ +// Package authz is the todo resource's authorization slice. It contributes the +// todo-specific Cedar policies, the typed action identifiers the HTTP registrar +// tags its operations with, and a repository-backed entity ("fact") resolver +// that maps a todo into a Cedar entity on demand. The composition root collects +// this slice's Contribution alongside every other slice's and merges them into +// the one runtime engine (see internal/authz). +// +// The dependency runs slice -> domain core only: this package imports the todo +// domain and Cedar, while the todo domain never imports this package, so the +// Cedar-free-domain rule holds. The package is named authz to mirror the like +// per-domain package naming (todo/httpapi, todo/postgres); files needing both +// this slice and the base engine alias one on import (todoauthz). +package authz + +import "github.com/cedar-policy/cedar-go/types" + +// actionType is the Cedar entity type of every action, by convention. +const actionType types.EntityType = "Action" + +// The todo actions, one per operation the HTTP slice exposes. Each is a Cedar +// action UID of the form Action::":" (the naming convention in +// the design): the "" segment (todo) lets the base engine derive the +// type-level resource (Todo) for a collection operation, and the verb names the +// operation. The HTTP registrar tags each route with the matching identifier via +// authz.Require, and policy.cedar grants them to authenticated principals. +// +// These are package-level vars rather than consts because a Cedar EntityUID is a +// struct, which Go cannot express as a const; they are effectively immutable +// typed identifiers (never reassigned). +// +//nolint:gochecknoglobals // immutable typed Cedar action identifiers; EntityUID is a struct and cannot be const. +var ( + // ActionCreate authorizes creating a todo. + ActionCreate = types.NewEntityUID(actionType, "todo:create") + // ActionRead authorizes reading a single todo. + ActionRead = types.NewEntityUID(actionType, "todo:read") + // ActionUpdate authorizes updating a todo (for example, completing it). + ActionUpdate = types.NewEntityUID(actionType, "todo:update") + // ActionDelete authorizes deleting a todo. + ActionDelete = types.NewEntityUID(actionType, "todo:delete") + // ActionList authorizes listing todos. + ActionList = types.NewEntityUID(actionType, "todo:list") +) + +// actions lists every action this slice declares, recorded on the Contribution +// for discovery and validation. +func actions() []types.EntityUID { + return []types.EntityUID{ActionCreate, ActionRead, ActionUpdate, ActionDelete, ActionList} +} diff --git a/internal/todo/authz/contribution.go b/internal/todo/authz/contribution.go new file mode 100644 index 0000000..9976ca1 --- /dev/null +++ b/internal/todo/authz/contribution.go @@ -0,0 +1,33 @@ +package authz + +import ( + _ "embed" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/todo" +) + +// policy holds the slice's embedded Cedar policies, merged into the runtime +// PolicySet by the composition root. Embedding keeps the slice self-contained: +// the policies ship in the binary and need no external file. +// +//go:embed policy.cedar +var policy []byte + +// Contribution returns the todo slice's input to the authorization engine: its +// embedded policies, the actions it declares, the Todo entity type it owns, and +// a repository-backed resolver factory. The composition root collects it (with +// every other slice's) and merges them in authz.New. +// +// repo is the same todo.Repository the HTTP slice uses, so an attribute policy +// resolves a todo's facts from the one source of truth — lazily, only when a +// policy dereferences a Todo entity. The shipped coarse policy needs no load, so +// the resolver is never invoked by default. +func Contribution(repo todo.Repository) authz.Contribution { + return authz.Contribution{ + Policies: policy, + Actions: actions(), + Types: []string{string(TodoType)}, + Resolver: newResolver(repo), + } +} diff --git a/internal/todo/authz/facts.go b/internal/todo/authz/facts.go new file mode 100644 index 0000000..9729d9a --- /dev/null +++ b/internal/todo/authz/facts.go @@ -0,0 +1,93 @@ +package authz + +import ( + "context" + "errors" + + "github.com/cedar-policy/cedar-go/types" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/todo" +) + +// TodoType is the Cedar entity type for a todo. It matches the type-level +// resource the base engine derives from the action's "todo:" prefix, so a +// Todo::"" instance bound from the path parameter and a Todo lookup made by +// an attribute policy refer to the same entity space. +const TodoType types.EntityType = "Todo" + +// resolver is the todo slice's request-scoped fact resolver. It loads a todo +// from the repository on demand — only when an applicable policy dereferences a +// Todo entity's attributes — and maps it to a Cedar entity. Coarse policies (the +// shipped default) decide without touching it, so no load happens. +// +// It is bound to the request context at construction (Cedar's Get carries no +// context), and reports a load failure through authz.RecordLoadError rather than +// a return value (Get cannot return an error), so the middleware fails closed on +// a backend error instead of trusting a decision made on missing data. +type resolver struct { + ctx context.Context + repo todo.Repository +} + +// newResolver builds the per-request todo resolver bound to ctx and the slice's +// repository. It satisfies authz.ResolverFactory; the principal is unused +// because a todo's facts come from the repository, not the caller. +func newResolver(repo todo.Repository) authz.ResolverFactory { + return func(ctx context.Context, _ authz.Principal) authz.EntityResolver { + return &resolver{ctx: ctx, repo: repo} + } +} + +// Types reports the entity type this resolver owns, so the composite getter +// routes Todo lookups here. +func (r *resolver) Types() []string { + return []string{string(TodoType)} +} + +// Resolve loads the todo named by uid and maps it to a Cedar entity. A uid of a +// type this resolver does not own, or a todo that does not exist, is a miss +// (false) — not an error. A backend failure is recorded via +// authz.RecordLoadError so the middleware fails closed, and reported as a miss. +func (r *resolver) Resolve(uid types.EntityUID) (types.Entity, bool) { + if uid.Type != TodoType { + return types.Entity{}, false + } + + found, err := r.repo.FindByID(r.ctx, string(uid.ID)) + if err != nil { + if errors.Is(err, todo.ErrNotFound) { + return types.Entity{}, false + } + // A real backend failure: record it so the middleware returns 500 rather + // than letting Cedar decide on a missing entity. + authz.RecordLoadError(r.ctx, err) + + return types.Entity{}, false + } + + return toEntity(found), true +} + +// toEntity maps a todo to its Cedar entity, exposing only the domain's EXISTING +// fields as attributes (no owner field is invented). An attribute policy can +// then decide on a todo's title, status, or timestamps; the shipped coarse +// policy reads none of them, so this mapping runs only when such a policy is +// added. CompletedAt is omitted while the todo is open (a nil pointer), so a +// policy must guard it with `resource has completedAt`. +func toEntity(t todo.Todo) types.Entity { + attrs := types.RecordMap{ + "id": types.String(t.ID), + "title": types.String(t.Title), + "status": types.String(t.Status), + "createdAt": types.NewDatetime(t.CreatedAt), + } + if t.CompletedAt != nil { + attrs["completedAt"] = types.NewDatetime(*t.CompletedAt) + } + + return types.Entity{ + UID: types.NewEntityUID(TodoType, types.String(t.ID)), + Attributes: types.NewRecord(attrs), + } +} diff --git a/internal/todo/authz/facts_test.go b/internal/todo/authz/facts_test.go new file mode 100644 index 0000000..4cd5ae4 --- /dev/null +++ b/internal/todo/authz/facts_test.go @@ -0,0 +1,154 @@ +package authz_test + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/authz" + mockauthz "github.com/meigma/template-go-api/internal/authz/mocks" + "github.com/meigma/template-go-api/internal/todo" + todoauthz "github.com/meigma/template-go-api/internal/todo/authz" + "github.com/meigma/template-go-api/internal/todo/mocks" +) + +// resolverFor builds the slice's resolver bound to ctx, backed by repo. +func resolverFor(ctx context.Context, repo todo.Repository) authz.EntityResolver { + return todoauthz.Contribution(repo).Resolver(ctx, authz.Anonymous()) +} + +func TestResolverOwnsTodoType(t *testing.T) { + t.Parallel() + + resolver := resolverFor(context.Background(), mocks.NewRepository(t)) + assert.Equal(t, []string{string(todoauthz.TodoType)}, resolver.Types()) +} + +func TestResolverMapsTodoToEntity(t *testing.T) { + t.Parallel() + + created := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + completed := created.Add(time.Hour) + stored := todo.Todo{ + ID: "42", + Title: "buy milk", + Status: todo.StatusCompleted, + CreatedAt: created, + CompletedAt: &completed, + } + + repo := mocks.NewRepository(t) + repo.EXPECT().FindByID(mock.Anything, "42").Return(stored, nil) + + resolver := resolverFor(context.Background(), repo) + entity, ok := resolver.Resolve(types.NewEntityUID(todoauthz.TodoType, "42")) + + require.True(t, ok) + assert.Equal(t, types.NewEntityUID(todoauthz.TodoType, "42"), entity.UID) + + title, _ := entity.Attributes.Get("title") + assert.Equal(t, types.String("buy milk"), title) + status, _ := entity.Attributes.Get("status") + assert.Equal(t, types.String("completed"), status) + createdAt, hasCreated := entity.Attributes.Get("createdAt") + require.True(t, hasCreated) + assert.Equal(t, types.NewDatetime(created), createdAt) + completedAt, hasCompleted := entity.Attributes.Get("completedAt") + require.True(t, hasCompleted, "a completed todo exposes its completedAt") + assert.Equal(t, types.NewDatetime(completed), completedAt) +} + +func TestResolverOmitsCompletedAtWhileOpen(t *testing.T) { + t.Parallel() + + stored := todo.Todo{ID: "7", Title: "open", Status: todo.StatusOpen, CreatedAt: time.Now()} + repo := mocks.NewRepository(t) + repo.EXPECT().FindByID(mock.Anything, "7").Return(stored, nil) + + resolver := resolverFor(context.Background(), repo) + entity, ok := resolver.Resolve(types.NewEntityUID(todoauthz.TodoType, "7")) + + require.True(t, ok) + _, hasCompleted := entity.Attributes.Get("completedAt") + assert.False(t, hasCompleted, "an open todo omits completedAt") +} + +func TestResolverUnownedTypeIsMiss(t *testing.T) { + t.Parallel() + + // A uid of a type the resolver does not own must not hit the repository. + resolver := resolverFor(context.Background(), mocks.NewRepository(t)) + _, ok := resolver.Resolve(types.NewEntityUID("User", "alice")) + assert.False(t, ok) +} + +func TestResolverNotFoundIsMiss(t *testing.T) { + t.Parallel() + + // A missing todo is a miss (false), not a recorded error: without an + // attribute policy nothing dereferences it, and even an instance policy + // treats an absent resource as not-matching rather than a backend failure. + repo := mocks.NewRepository(t) + repo.EXPECT().FindByID(mock.Anything, "404").Return(todo.Todo{}, todo.ErrNotFound) + + resolver := resolverFor(context.Background(), repo) + _, ok := resolver.Resolve(types.NewEntityUID(todoauthz.TodoType, "404")) + assert.False(t, ok) +} + +// attrPolicy forces Cedar to load the resource entity (it reads resource.title), +// so a repository failure during the load is exercised. The condition value does +// not matter: the load itself triggers the fail-closed path. +const attrPolicy = `permit ( + principal, + action == Action::"todo:read", + resource +) when { resource.title == "anything" };` + +// TestResolverBackendErrorFailsClosed proves the resolver reports a backend +// failure (not a miss) so the middleware returns 500 rather than deciding on +// missing data. It drives the real composite getter via the middleware, since +// the error sink is request-scoped and installed there; a bare Resolve call +// cannot observe RecordLoadError. +func TestResolverBackendErrorFailsClosed(t *testing.T) { + t.Parallel() + + repo := mocks.NewRepository(t) + repo.EXPECT().FindByID(mock.Anything, "42").Return(todo.Todo{}, errors.New("database unavailable")) + + authorizer, err := authz.New([]authz.Contribution{{ + Policies: []byte(attrPolicy), + Types: []string{string(todoauthz.TodoType)}, + Resolver: todoauthz.Contribution(repo).Resolver, + }}) + require.NoError(t, err) + + authn := mockauthz.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(principal("alice"), nil) + + _, api := humatest.New(t) + authz.NewMiddleware(api, authn, authorizer, nil, true).Install() + + huma.Register(api, huma.Operation{ + OperationID: "get-todo", + Method: http.MethodGet, + Path: "/todos/{id}", + Metadata: authz.Require(todoauthz.ActionRead, "id"), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + return &struct{}{}, nil + }) + + resp := api.Get("/todos/42") + assert.Equal(t, http.StatusInternalServerError, resp.Code, "a backend load failure must fail closed with 500") +} diff --git a/internal/todo/authz/policy.cedar b/internal/todo/authz/policy.cedar new file mode 100644 index 0000000..c4e8861 --- /dev/null +++ b/internal/todo/authz/policy.cedar @@ -0,0 +1,44 @@ +// policy.cedar holds the todo slice's authorization policies. They merge with +// base.cedar (the admin override) and every other slice's policies into the one +// runtime PolicySet (see internal/authz.New). +// +// Day-one policy: COARSE by design. A principal carrying the "user" role may +// perform any todo action. Membership in Role::"user" is projected onto the +// principal entity's parents from the caller's claims at authentication time, so +// this evaluates with NO entity load — no todo is read to make the decision. The +// demo then exercises the full matrix: a user-role key is allowed, an admin-role +// key is allowed via base.cedar, and a key with neither role is denied (403), +// while no key is denied as anonymous (401). +permit ( + principal in Role::"user", + action in [ + Action::"todo:create", + Action::"todo:read", + Action::"todo:update", + Action::"todo:delete", + Action::"todo:list" + ], + resource +); + +// EXTENSION EXAMPLE (commented — not active): an instance/attribute policy. +// +// The coarse rule above needs no resource attributes, so no todo is ever loaded. +// To authorize on a todo's own attributes instead — for example, "a user may +// read only their own todo" — a policy dereferences resource., which is +// the moment Cedar pulls the entity through the fact resolver in facts.go +// (lazily, on demand). The middleware already binds the instance resource +// Todo::"" from the {id} path parameter (Require(action, "id")), so the +// resolver loads exactly that todo and maps its fields to entity attributes. +// +// This template's Todo carries no owner field, so the example is illustrative; +// adding ownership would mean adding the field to the domain and exposing it in +// facts.go. With such a field the policy would read: +// +// permit ( +// principal, +// action == Action::"todo:read", +// resource +// ) when { +// resource.owner == principal +// }; diff --git a/internal/todo/authz/policy_test.go b/internal/todo/authz/policy_test.go new file mode 100644 index 0000000..01f6809 --- /dev/null +++ b/internal/todo/authz/policy_test.go @@ -0,0 +1,104 @@ +package authz_test + +import ( + "context" + "net/http" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/authz/mocks" + todoauthz "github.com/meigma/template-go-api/internal/todo/authz" + "github.com/meigma/template-go-api/internal/todo/todotest" +) + +// principal returns a non-anonymous principal carrying the given roles, mirroring +// what the API-key authenticator builds from a resolved key. +func principal(subject string, roles ...string) authz.Principal { + values := make([]types.Value, 0, len(roles)) + for _, role := range roles { + values = append(values, types.String(role)) + } + + return authz.Principal{ + UID: types.NewEntityUID("User", types.String(subject)), + Claims: types.NewRecord(types.RecordMap{ + authz.RolesClaim: types.NewSet(values...), + }), + } +} + +// newAPI builds a humatest API with the authz middleware installed over the +// merged base + todo-slice policy set, and registers a single create operation +// tagged with the todo create action. This drives policy decisions through the +// same merged engine the server uses (base.cedar admin override + the slice's +// coarse user-role grant + the principal resolver projecting roles). +func newAPI(t *testing.T, authenticator authz.Authenticator) humatest.TestAPI { + t.Helper() + + authorizer, err := authz.New([]authz.Contribution{todoauthz.Contribution(todotest.NewRepository())}) + require.NoError(t, err) + + _, api := humatest.New(t) + authz.NewMiddleware(api, authenticator, authorizer, nil, true).Install() + + huma.Register(api, huma.Operation{ + OperationID: "create-todo", + Method: http.MethodPost, + Path: "/todos", + Metadata: authz.Require(todoauthz.ActionCreate), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + return &struct{}{}, nil + }) + + return api +} + +func TestPolicyAllowsUserRole(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(principal("alice", "user"), nil) + + resp := newAPI(t, authn).Post("/todos") + assert.Equal(t, http.StatusNoContent, resp.Code, "the coarse policy grants the user role") +} + +func TestPolicyAllowsAdminViaBasePolicy(t *testing.T) { + t.Parallel() + + // The admin grant lives in base.cedar, not the slice policy, so an admin with + // no user role still passes — exercising the merged base + slice set. + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(principal("root", "admin"), nil) + + resp := newAPI(t, authn).Post("/todos") + assert.Equal(t, http.StatusNoContent, resp.Code, "the base admin override grants any todo action") +} + +func TestPolicyForbidsInsufficientRole(t *testing.T) { + t.Parallel() + + // An authenticated caller with a role neither policy grants is forbidden. + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(principal("guest", "viewer"), nil) + + resp := newAPI(t, authn).Post("/todos") + assert.Equal(t, http.StatusForbidden, resp.Code, "a role the policies do not grant is denied 403") +} + +func TestPolicyRejectsAnonymousWith401(t *testing.T) { + t.Parallel() + + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(authz.Anonymous(), nil) + + resp := newAPI(t, authn).Post("/todos") + assert.Equal(t, http.StatusUnauthorized, resp.Code, "an anonymous caller is denied 401, not 403") +} diff --git a/internal/todo/httpapi/handler.go b/internal/todo/httpapi/handler.go index 15fec78..b97b6cb 100644 --- a/internal/todo/httpapi/handler.go +++ b/internal/todo/httpapi/handler.go @@ -10,9 +10,16 @@ import ( "github.com/danielgtaylor/huma/v2" + "github.com/meigma/template-go-api/internal/authz" "github.com/meigma/template-go-api/internal/todo" + todoauthz "github.com/meigma/template-go-api/internal/todo/authz" ) +// todoIDParam is the path parameter naming the todo id. The by-id operations +// bind it so the authz middleware sets Resource = Todo::"" straight from the +// matched route, enabling instance-level policies with no load. +const todoIDParam = "id" + // tagTodos groups the todo operations in the OpenAPI document. const tagTodos = "Todos" @@ -33,6 +40,8 @@ func Register(api huma.API, service *todo.Service) { Summary: "Create a todo", Tags: []string{tagTodos}, DefaultStatus: http.StatusCreated, + // Collection operation: the resource is type-level (Todo), so no id is bound. + Metadata: authz.Require(todoauthz.ActionCreate), }, h.create) huma.Register(api, huma.Operation{ @@ -41,6 +50,7 @@ func Register(api huma.API, service *todo.Service) { Path: "/todos", Summary: "List todos", Tags: []string{tagTodos}, + Metadata: authz.Require(todoauthz.ActionList), }, h.list) huma.Register(api, huma.Operation{ @@ -50,6 +60,8 @@ func Register(api huma.API, service *todo.Service) { Summary: "Get a todo by id", Tags: []string{tagTodos}, Errors: []int{http.StatusNotFound}, + // Item operation: bind the {id} path param so Resource = Todo::"". + Metadata: authz.Require(todoauthz.ActionRead, todoIDParam), }, h.get) huma.Register(api, huma.Operation{ @@ -59,6 +71,9 @@ func Register(api huma.API, service *todo.Service) { Summary: "Mark a todo as completed", Tags: []string{tagTodos}, Errors: []int{http.StatusNotFound}, + // Completing a todo mutates it, so it requires the update action; the + // {id} path param binds the instance resource. + Metadata: authz.Require(todoauthz.ActionUpdate, todoIDParam), }, h.complete) } From 299525d670f09d1306af5c4ad6df72c119e1cfab Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 18:54:22 -0700 Subject: [PATCH 06/10] fix(authz): address phase B review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/authz/apikey/apikey.go | 9 ++-- internal/authz/authz.go | 9 ++++ internal/authz/getter.go | 74 +++++++++++++++++++++++++++++++ internal/authz/getter_test.go | 64 ++++++++++++++++++++++++++ internal/authz/middleware.go | 7 ++- internal/authz/middleware_test.go | 13 ++++++ internal/authz/ownership_test.go | 20 +++++++++ 7 files changed, 189 insertions(+), 7 deletions(-) diff --git a/internal/authz/apikey/apikey.go b/internal/authz/apikey/apikey.go index e7507c6..bdf96e4 100644 --- a/internal/authz/apikey/apikey.go +++ b/internal/authz/apikey/apikey.go @@ -27,10 +27,6 @@ import ( // bearerPrefix is the scheme prefix of an Authorization: Bearer credential. const bearerPrefix = "Bearer " -// principalType is the Cedar entity type assigned to an API-key principal. Its -// id is the key's subject (for example User::"alice"). -const principalType types.EntityType = "User" - // ErrInvalidKey is returned when a credential is present but does not resolve to // a known principal. The authn middleware maps it to 401. A request with no // credential is not an error — it yields the anonymous principal. @@ -118,7 +114,10 @@ func toPrincipal(identity Identity) authz.Principal { } return authz.Principal{ - UID: types.NewEntityUID(principalType, types.String(identity.Subject)), + // Mint the principal under authz.PrincipalType: the composite getter routes + // principal lookups by the principal's UID type, so the type the + // authenticator stamps must be the one the base principal resolver owns. + UID: types.NewEntityUID(authz.PrincipalType, types.String(identity.Subject)), Claims: types.NewRecord(types.RecordMap{ authz.RolesClaim: types.NewSet(roleValues...), }), diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 727f5b0..66f2c27 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -104,6 +104,9 @@ func New(contributions []Contribution, opts ...Option) (*Authorizer, error) { // so a slice resolver can never shadow the always-present principal resolver. // - No two slices may claim the same type, so a lookup routes to exactly one // resolver and a type's facts have a single source of truth. +// - A slice supplying a Resolver must declare the types it owns: the composite +// getter routes purely by Types, so a resolver with no declared types is never +// invoked and any policy dereferencing its entities silently fails closed. // // A misconfigured contribution set therefore fails startup rather than silently // shadowing facts or the principal at request time. @@ -116,6 +119,12 @@ func validateTypeOwnership(contributions []Contribution) error { owner := make(map[string]int) for i, c := range contributions { + if c.Resolver != nil && len(c.Types) == 0 { + return fmt.Errorf( + "contribution %d supplies a Resolver but declares no Types: a resolver is routed only by the types it owns, so it would never be invoked", + i, + ) + } for _, t := range c.Types { if _, isReserved := reserved[t]; isReserved { return fmt.Errorf( diff --git a/internal/authz/getter.go b/internal/authz/getter.go index 80592b0..64829ce 100644 --- a/internal/authz/getter.go +++ b/internal/authz/getter.go @@ -50,6 +50,19 @@ type errorSinkKey struct{} // then builds each contribution's resolver bound to that context and the // principal, indexing them by the entity types they own. Slices with no Resolver // contribute nothing. +// +// Routing has two layers, keeping the precedence-fix guarantee while not breaking +// custom principal types: +// - Slice resolvers route by their statically declared Types — never the +// resolver's runtime Types() — so a slice resolver cannot claim a type it did +// not declare (and [New] rejected) and thereby shadow the principal resolver. +// - The always-present principal resolver additionally routes under the +// principal's actual UID type (p.UID.Type), so a custom Authenticator minting +// a non-User principal (for example Service::"x") still resolves its entity +// and projects its role parents. When a slice already owns that type (a custom +// principal type that collides with a slice's data type), the principal +// resolver is chained ahead of the slice resolver for the exact principal UID +// only; the slice still serves its own instances of that type. func newGetter(ctx context.Context, p Principal, contributions []Contribution) *getter { g := &getter{ byType: make(map[string]EntityResolver), @@ -60,11 +73,19 @@ func newGetter(ctx context.Context, p Principal, contributions []Contribution) * // can report a load failure even though Get cannot return an error. g.ctx = context.WithValue(ctx, errorSinkKey{}, g.setErr) + var principalResolver EntityResolver for _, c := range contributions { if c.Resolver == nil { continue } resolver := c.Resolver(g.ctx, p) + if isPrincipalResolver(resolver) { + // Routed last, under the principal's actual UID type, so it survives a + // custom principal type and never depends on slice ordering. + principalResolver = resolver + + continue + } // Route by the contribution's statically declared Types, not the // resolver's runtime Types(), so a slice resolver cannot claim a type it // did not declare (and [New] rejected) and thereby shadow the principal @@ -75,9 +96,62 @@ func newGetter(ctx context.Context, p Principal, contributions []Contribution) * } } + if principalResolver != nil { + g.routePrincipal(principalResolver, p.UID.Type) + } + return g } +// routePrincipal indexes the always-present principal resolver under the +// principal's actual UID type. If a slice already owns that type (a custom +// principal type colliding with a slice's data type), the two are chained so the +// principal resolver answers the exact principal UID and the slice answers its +// own instances; otherwise the principal resolver owns the type outright. +func (g *getter) routePrincipal(principalResolver EntityResolver, principalType types.EntityType) { + key := string(principalType) + if existing, ok := g.byType[key]; ok { + g.byType[key] = principalFirst{principal: principalResolver, fallback: existing} + + return + } + + g.byType[key] = principalResolver +} + +// isPrincipalResolver reports whether r is the base principal resolver, so +// newGetter can route it under the principal's actual UID type rather than its +// statically declared reserved types alone. +func isPrincipalResolver(r EntityResolver) bool { + _, ok := r.(*principalResolver) + + return ok +} + +// principalFirst chains the principal resolver ahead of a slice resolver that +// owns the same Cedar type as a custom principal. The principal resolver only +// matches its own bound UID, so a miss falls through to the slice resolver, which +// owns every other instance of that type. +type principalFirst struct { + principal EntityResolver + fallback EntityResolver +} + +// Types reports the union of both chained resolvers' owned types. +func (p principalFirst) Types() []string { + return append(p.principal.Types(), p.fallback.Types()...) +} + +// Resolve tries the principal resolver first (it matches only the principal's own +// UID) and falls back to the slice resolver for every other instance of the type. +func (p principalFirst) Resolve(uid types.EntityUID) (types.Entity, bool) { + if entity, ok := p.principal.Resolve(uid); ok { + return entity, true + } + + return p.fallback.Resolve(uid) +} + // RecordLoadError reports a fact-load failure to the request's getter so the // middleware can fail closed. A slice resolver calls it from its Resolve method // (Cedar's Get(uid) (Entity, bool) has no error return) using the context it was diff --git a/internal/authz/getter_test.go b/internal/authz/getter_test.go index 23da734..40013f0 100644 --- a/internal/authz/getter_test.go +++ b/internal/authz/getter_test.go @@ -7,6 +7,7 @@ import ( "github.com/cedar-policy/cedar-go/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // recordingResolver owns one entity type and counts how many times Resolve is @@ -88,6 +89,69 @@ func TestGetterUnownedTypeIsMiss(t *testing.T) { assert.NoError(t, g.Err(), "an unowned type is a miss, not an error") } +// TestGetterResolvesCustomPrincipalType proves a custom Authenticator that mints +// a non-User principal (the documented WithAuthenticator extension point) still +// resolves through the composite getter, with its role parents projected — so +// `principal in Role::"…"` policies hold. Routing the principal resolver only by +// its static reserved types ["User","Anonymous"] regressed this: a Service::"x" +// principal had no resolver and silently failed every role check. +func TestGetterResolvesCustomPrincipalType(t *testing.T) { + t.Parallel() + + principal := Principal{ + UID: types.NewEntityUID("Service", "svc-1"), + Claims: types.NewRecord(types.RecordMap{ + RolesClaim: types.NewSet(types.String("admin")), + }), + } + + authorizer, err := New(nil) + require.NoError(t, err) + + g := newGetter(context.Background(), principal, authorizer.Contributions()) + entity, ok := g.Get(principal.UID) + + require.True(t, ok, "a custom principal type must still resolve via the principal resolver") + assert.True(t, entity.Parents.Contains(types.NewEntityUID(RoleType, "admin")), + "the principal's role claims must project onto its Role parents") +} + +// TestGetterCustomPrincipalTypeCoexistsWithSlice proves that when a slice owns +// the same Cedar type as a custom principal, both resolve: the principal resolver +// answers the principal's own UID and the slice answers its other instances. The +// principal resolver is chained ahead of the slice for the exact principal UID +// only, so neither shadows the other. +func TestGetterCustomPrincipalTypeCoexistsWithSlice(t *testing.T) { + t.Parallel() + + principal := Principal{UID: types.NewEntityUID("Service", "svc-1")} + + dataUID := types.NewEntityUID("Service", "svc-2") + sliceResolver := &staticResolver{ + typ: "Service", + entity: types.Entity{UID: dataUID, Attributes: types.NewRecord(types.RecordMap{"slice": types.Boolean(true)})}, + } + authorizer, err := New([]Contribution{{ + Types: []string{"Service"}, + Resolver: func(_ context.Context, _ Principal) EntityResolver { return sliceResolver }, + }}) + require.NoError(t, err) + + g := newGetter(context.Background(), principal, authorizer.Contributions()) + + // The principal's own UID routes to the principal resolver. + principalEntity, ok := g.Get(principal.UID) + require.True(t, ok, "the principal entity must resolve") + _, hasSliceAttr := principalEntity.Attributes.Get("slice") + assert.False(t, hasSliceAttr, "the principal lookup must not route to the slice resolver") + + // A different instance of the same type falls through to the slice resolver. + dataEntity, ok := g.Get(dataUID) + require.True(t, ok, "the slice's own instances of the shared type must still resolve") + _, hasSliceAttr = dataEntity.Attributes.Get("slice") + assert.True(t, hasSliceAttr, "a non-principal instance must route to the slice resolver") +} + func TestGetterCapturesFirstLoadError(t *testing.T) { t.Parallel() diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go index 125927e..a9adf53 100644 --- a/internal/authz/middleware.go +++ b/internal/authz/middleware.go @@ -123,10 +123,13 @@ func (m *Middleware) authorize(ctx huma.Context, next func(huma.Context)) { decl, ok := declarationFrom(ctx.Operation()) if !ok { // Fail-closed: a route that declared neither Require nor Public is a - // programming omission, not a public endpoint. + // server-side programming omission, not a missing-credential case, so it + // is always 403 (never 401) — credentials can never satisfy a route that + // declares no requirement, and a 401 would invite a pointless retry. This + // matches the design's undeclared = deny-403 framing. m.logger.WarnContext(ctx.Context(), "denying undeclared operation", slog.String("operation", ctx.Operation().OperationID)) - m.deny(ctx, principal) + m.writeErr(ctx, http.StatusForbidden, genericForbidden) return } diff --git a/internal/authz/middleware_test.go b/internal/authz/middleware_test.go index 51cd715..058f876 100644 --- a/internal/authz/middleware_test.go +++ b/internal/authz/middleware_test.go @@ -163,6 +163,19 @@ func TestMiddlewareDeniesUndeclaredOperation(t *testing.T) { assert.Equal(t, http.StatusForbidden, resp.Code) } +func TestMiddlewareDeniesUndeclaredOperationForAnonymousWith403(t *testing.T) { + t.Parallel() + + // An undeclared route is a server-side omission, not a missing-credential + // case: an anonymous caller is denied 403, not 401, because no credential can + // ever satisfy a route that declares no requirement. + authn := mocks.NewAuthenticator(t) + authn.EXPECT().Authenticate(mock.Anything).Return(authz.Anonymous(), nil) + + resp := newTestAPI(t, authn).Get("/undeclared") + assert.Equal(t, http.StatusForbidden, resp.Code) +} + // failingResolver owns the Todo type and reports a load error whenever Cedar // dereferences an entity, so the fail-closed path can be exercised. type failingResolver struct { diff --git a/internal/authz/ownership_test.go b/internal/authz/ownership_test.go index 232d4ae..0f6f044 100644 --- a/internal/authz/ownership_test.go +++ b/internal/authz/ownership_test.go @@ -49,6 +49,26 @@ func TestNewRejectsSliceClaimingReservedPrincipalType(t *testing.T) { assert.Contains(t, err.Error(), string(PrincipalType)) } +func TestNewRejectsResolverWithoutTypes(t *testing.T) { + t.Parallel() + + // A slice with a Resolver but no Types would be registered under zero type + // keys and never invoked, so its policies' entity dereferences would silently + // fail closed. The misconfiguration must fail startup, not at request time. + _, err := New([]Contribution{{Resolver: nopResolver}}) + require.Error(t, err, "a Resolver with no declared Types must fail construction") + assert.Contains(t, err.Error(), "Types") +} + +func TestNewAllowsCoarseSliceWithoutResolver(t *testing.T) { + t.Parallel() + + // A slice contributing only coarse policies (no Resolver) may leave Types + // empty — there is nothing to route. + _, err := New([]Contribution{{Policies: []byte(`permit (principal, action, resource);`)}}) + require.NoError(t, err, "a coarse slice with no Resolver may omit Types") +} + func TestNewAllowsDistinctTypes(t *testing.T) { t.Parallel() From e12f76d322fc2c3f6decb065c00d05f9abd16d75 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 19:08:49 -0700 Subject: [PATCH 07/10] test(authz): container-backed APIKeyStore + e2e functional authz coverage (phase C) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/integration/apikey_store_test.go | 158 +++++++++++++ internal/integration/authz_e2e_test.go | 216 ++++++++++++++++++ internal/integration/postgres_fixture_test.go | 15 +- 3 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 internal/integration/apikey_store_test.go create mode 100644 internal/integration/authz_e2e_test.go diff --git a/internal/integration/apikey_store_test.go b/internal/integration/apikey_store_test.go new file mode 100644 index 0000000..d2b2394 --- /dev/null +++ b/internal/integration/apikey_store_test.go @@ -0,0 +1,158 @@ +//go:build integration + +package integration + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cedar-policy/cedar-go/types" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/authz/apikey" +) + +// seedAPIKey inserts an api_keys row directly through the pool, so the store is +// resolved against rows it did not write — proving the real query and the real +// schema (text[] roles column) round-trip together. +func seedAPIKey(ctx context.Context, t *testing.T, pool *pgxpool.Pool, key, subject string, roles []string) { + t.Helper() + + _, err := pool.Exec(ctx, + `INSERT INTO api_keys (key, subject, roles) VALUES ($1, $2, $3)`, + key, subject, roles, + ) + require.NoError(t, err) +} + +// apiKeyContext builds a huma.Context carrying the X-API-Key header so the real +// Authenticator's credential extraction runs end to end against the store. +func apiKeyContext(key string) huma.Context { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set(authz.APIKeyHeader, key) + + return humatest.NewContext(&huma.Operation{}, r, httptest.NewRecorder()) +} + +// TestAPIKeyStoreAdapter exercises the real PostgreSQL-backed apikey.Store and +// the apikey.Authenticator against the container database. The design defers the +// adapter's coverage to this suite ("the postgres APIKeyStore adapter is covered +// in internal/integration"). Rows are inserted directly, then resolved through +// the shipped store and authenticator, so the hand-written lookup query, the +// text[] roles column, and the principal mapping are all exercised together. It +// shares one migrated container (the fixture applies migration 00002, so the +// api_keys table exists) and restores the clean snapshot between subtests for +// isolation, so the subtests run sequentially rather than in parallel. +func TestAPIKeyStoreAdapter(t *testing.T) { + ctx := context.Background() + fix := setupPostgres(ctx, t) + + t.Run("LookupResolvesSubjectAndRoles", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + seedAPIKey(ctx, t, pool, "user-key", "alice", []string{"user"}) + seedAPIKey(ctx, t, pool, "admin-key", "root", []string{"admin", "user"}) + + store := apikey.NewStore(pool) + + // A user-role key resolves to its subject and single role. + user, ok, err := store.Lookup(ctx, "user-key") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "alice", user.Subject) + assert.Equal(t, []string{"user"}, user.Roles) + + // An admin key with multiple roles parses the whole text[] array, proving + // the roles[] column round-trips through the scan in order. + admin, ok, err := store.Lookup(ctx, "admin-key") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "root", admin.Subject) + assert.Equal(t, []string{"admin", "user"}, admin.Roles) + }) + + t.Run("UnknownKeyIsMissNotError", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + seedAPIKey(ctx, t, pool, "user-key", "alice", []string{"user"}) + + store := apikey.NewStore(pool) + + // An unknown key is a miss (false), never an error — the authn middleware + // maps it to 401, not 500. + identity, ok, err := store.Lookup(ctx, "does-not-exist") + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, apikey.Identity{}, identity) + }) + + t.Run("KeyIsMatchedExactly", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + seedAPIKey(ctx, t, pool, "secret-key", "alice", []string{"user"}) + + store := apikey.NewStore(pool) + + // A prefix of a stored key must not match: the lookup is an exact equality + // on the primary key, not a LIKE/prefix scan. + _, ok, err := store.Lookup(ctx, "secret") + require.NoError(t, err) + assert.False(t, ok, "a partial key must not resolve to a stored row") + + // A trailing-space variant is likewise a distinct key and must miss. + _, ok, err = store.Lookup(ctx, "secret-key ") + require.NoError(t, err) + assert.False(t, ok, "a key with trailing whitespace must not match a stored row") + }) + + t.Run("EmptyRolesResolveToEmptySlice", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + // roles defaults to '{}' (the migration's column default), so a row with no + // roles resolves to an empty, non-nil slice — a principal with no role. + _, err := pool.Exec(ctx, + `INSERT INTO api_keys (key, subject) VALUES ($1, $2)`, "no-roles", "nobody") + require.NoError(t, err) + + store := apikey.NewStore(pool) + + identity, ok, err := store.Lookup(ctx, "no-roles") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "nobody", identity.Subject) + assert.Empty(t, identity.Roles) + }) + + t.Run("AuthenticatorResolvesPrincipalThroughStore", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + seedAPIKey(ctx, t, pool, "alice-key", "alice", []string{"user", "auditor"}) + + // The full apikey path: the real store wired into the real Authenticator, + // resolving an X-API-Key credential to a Principal (subject -> User entity, + // roles -> the roles claim the base principal resolver projects onto Role + // parents). + auth := apikey.NewAuthenticator(apikey.NewStore(pool)) + + principal, err := auth.Authenticate(apiKeyContext("alice-key")) + require.NoError(t, err) + assert.False(t, principal.IsAnonymous()) + assert.Equal(t, types.NewEntityUID(authz.PrincipalType, "alice"), principal.UID) + + roles, ok := principal.Claims.Get(authz.RolesClaim) + require.True(t, ok, "resolved roles must be recorded on the claims") + assert.Equal(t, types.NewSet(types.String("user"), types.String("auditor")), roles) + }) + + t.Run("AuthenticatorUnknownKeyIsInvalid", func(t *testing.T) { + pool := fix.ResetPool(ctx, t) + + auth := apikey.NewAuthenticator(apikey.NewStore(pool)) + + principal, err := auth.Authenticate(apiKeyContext("nope")) + require.ErrorIs(t, err, apikey.ErrInvalidKey) + assert.True(t, principal.IsAnonymous()) + }) +} diff --git a/internal/integration/authz_e2e_test.go b/internal/integration/authz_e2e_test.go new file mode 100644 index 0000000..c16b759 --- /dev/null +++ b/internal/integration/authz_e2e_test.go @@ -0,0 +1,216 @@ +//go:build integration + +package integration + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-go-api/internal/app" + "github.com/meigma/template-go-api/internal/authz" + "github.com/meigma/template-go-api/internal/config" + "github.com/meigma/template-go-api/internal/observability" +) + +// e2eHeader names a request header to attach to an authz end-to-end request, +// such as the X-API-Key credential. +type e2eHeader struct{ key, value string } + +// e2eResult is the slice of an HTTP response the authz assertions care about. +type e2eResult struct { + status int + body string +} + +// e2eServer wires the FULL application against the container database with +// authorization ENABLED. It deliberately does not inject a repository or an +// authenticator: app.New takes the real composition path, so the request runs +// through the real PostgreSQL todo repository, the real PostgreSQL-backed api-key +// Authenticator, the real Cedar Authorizer (base.cedar admin override + the todo +// slice policy), and the real Huma authn/authz middleware chain. Anything less +// would not be an end-to-end authz test. +func e2eServer(ctx context.Context, t *testing.T, databaseURL string) *httptest.Server { + t.Helper() + + vp := viper.New() + vp.Set("database-url", databaseURL) + cfg := config.Load(vp) + require.NoError(t, cfg.Validate()) + // Guard the premise of the whole suite: authz must be enabled by default now + // that the routes are tagged, otherwise the middleware would be inert. + require.True(t, cfg.AuthzEnabled, "authz must be enabled for the e2e suite to mean anything") + + logger := observability.NewLogger(io.Discard, slog.LevelError, "json") + application, err := app.New(ctx, cfg, logger, "test") + require.NoError(t, err) + + srv := httptest.NewServer(application.Handler()) + t.Cleanup(srv.Close) + + return srv +} + +// e2eRequest drives one request against the wired server, attaching the given +// headers (credential, content-type), and returns its status and body. +func e2eRequest( + t *testing.T, + srv *httptest.Server, + method, path, body string, + headers ...e2eHeader, +) e2eResult { + t.Helper() + + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + + req, err := http.NewRequestWithContext(context.Background(), method, srv.URL+path, reader) + require.NoError(t, err) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + for _, h := range headers { + req.Header.Set(h.key, h.value) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return e2eResult{status: resp.StatusCode, body: string(data)} +} + +// apiKey returns the X-API-Key header for key. +func apiKey(key string) e2eHeader { return e2eHeader{key: authz.APIKeyHeader, value: key} } + +// bearer returns the Authorization: Bearer header for key. +func bearer(key string) e2eHeader { return e2eHeader{key: "Authorization", value: "Bearer " + key} } + +// createdID parses the id out of a TodoOutput body, asserting the response was a +// successful create/get so a later by-id request can target a real instance. +func createdID(t *testing.T, body string) string { + t.Helper() + + var todo struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal([]byte(body), &todo)) + require.NotEmpty(t, todo.ID, "expected a todo id in: %s", body) + + return todo.ID +} + +// TestAuthzEndToEnd drives real HTTP traffic through the full stack with +// authorization enabled and a real PostgreSQL-backed api-key authenticator. It +// seeds a user-role key, an admin-role key, and a roleless key directly into the +// api_keys table, then asserts the real decision matrix: no credential -> 401, +// an unauthorized role -> 403, a user key -> 2xx on every granted todo route, an +// admin key -> allowed via base.cedar, and the URL-identity by-id routes +// (get/complete) resolving Resource = Todo::"" for a todo created over HTTP. +// +// It shares one migrated container and seeds the api_keys rows once after a clean +// restore; the app connects its own pool to the same database, so the request +// path resolves keys against the seeded rows exactly as production would. +func TestAuthzEndToEnd(t *testing.T) { + ctx := context.Background() + fix := setupPostgres(ctx, t) + + // Restore the clean schema, then seed the keys through a dedicated pool. The + // app opens its own pool to the same database below. + pool := fix.ResetPool(ctx, t) + seedAPIKey(ctx, t, pool, "user-key", "alice", []string{"user"}) + seedAPIKey(ctx, t, pool, "admin-key", "root", []string{"admin"}) + seedAPIKey(ctx, t, pool, "guest-key", "guest", []string{"guest"}) + + srv := e2eServer(ctx, t, fix.url) + + t.Run("NoCredentialIsUnauthorized", func(t *testing.T) { + // Anonymous on a protected route -> 401 (the design's no-principal + deny + // path), not 403. + got := e2eRequest(t, srv, http.MethodGet, "/todos", "") + assert.Equal(t, http.StatusUnauthorized, got.status, got.body) + + got = e2eRequest(t, srv, http.MethodPost, "/todos", `{"title":"x"}`) + assert.Equal(t, http.StatusUnauthorized, got.status, got.body) + }) + + t.Run("UnauthorizedRoleIsForbidden", func(t *testing.T) { + // guest-key authenticates (so it is not anonymous) but carries neither the + // user nor admin role the policies grant -> 403, not 401. + got := e2eRequest(t, srv, http.MethodGet, "/todos", "", apiKey("guest-key")) + assert.Equal(t, http.StatusForbidden, got.status, got.body) + + got = e2eRequest(t, srv, http.MethodPost, "/todos", `{"title":"x"}`, apiKey("guest-key")) + assert.Equal(t, http.StatusForbidden, got.status, got.body) + }) + + t.Run("UserKeyIsAllowedAcrossGrantedRoutes", func(t *testing.T) { + // create (collection, type-level resource) -> 201. + created := e2eRequest(t, srv, http.MethodPost, "/todos", `{"title":"buy milk"}`, apiKey("user-key")) + require.Equal(t, http.StatusCreated, created.status, created.body) + id := createdID(t, created.body) + + // list (collection) -> 200. + listed := e2eRequest(t, srv, http.MethodGet, "/todos", "", apiKey("user-key")) + assert.Equal(t, http.StatusOK, listed.status, listed.body) + + // get by id (URL-identity: Resource = Todo::"") -> 200 for the todo + // created above, proving the path param feeds the Cedar resource and the + // coarse user policy still allows it. + fetched := e2eRequest(t, srv, http.MethodGet, "/todos/"+id, "", apiKey("user-key")) + assert.Equal(t, http.StatusOK, fetched.status, fetched.body) + assert.Equal(t, id, createdID(t, fetched.body)) + + // complete by id (URL-identity, update action) -> 200. + completed := e2eRequest(t, srv, http.MethodPost, "/todos/"+id+"/complete", "", apiKey("user-key")) + assert.Equal(t, http.StatusOK, completed.status, completed.body) + var out struct { + Status string `json:"status"` + } + require.NoError(t, json.Unmarshal([]byte(completed.body), &out)) + assert.Equal(t, "completed", out.Status) + }) + + t.Run("AdminKeyIsAllowedViaBasePolicy", func(t *testing.T) { + // The admin role is granted everything by base.cedar's admin override, not + // by the todo slice policy, so this proves the merged PolicySet evaluates + // the cross-cutting rule too. + created := e2eRequest(t, srv, http.MethodPost, "/todos", `{"title":"admin task"}`, apiKey("admin-key")) + require.Equal(t, http.StatusCreated, created.status, created.body) + id := createdID(t, created.body) + + fetched := e2eRequest(t, srv, http.MethodGet, "/todos/"+id, "", apiKey("admin-key")) + assert.Equal(t, http.StatusOK, fetched.status, fetched.body) + + listed := e2eRequest(t, srv, http.MethodGet, "/todos", "", apiKey("admin-key")) + assert.Equal(t, http.StatusOK, listed.status, listed.body) + }) + + t.Run("BearerCredentialAlsoAuthenticates", func(t *testing.T) { + // The same user key presented as an Authorization: Bearer credential must + // resolve identically, proving the second accepted credential carrier. + listed := e2eRequest(t, srv, http.MethodGet, "/todos", "", bearer("user-key")) + assert.Equal(t, http.StatusOK, listed.status, listed.body) + }) + + t.Run("UnknownKeyIsUnauthorized", func(t *testing.T) { + // A present-but-unknown credential is a 401 (invalid credential), distinct + // from the missing-credential and wrong-role cases above. + got := e2eRequest(t, srv, http.MethodGet, "/todos", "", apiKey("not-a-real-key")) + assert.Equal(t, http.StatusUnauthorized, got.status, got.body) + }) +} diff --git a/internal/integration/postgres_fixture_test.go b/internal/integration/postgres_fixture_test.go index eb0728f..ddb606e 100644 --- a/internal/integration/postgres_fixture_test.go +++ b/internal/integration/postgres_fixture_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "github.com/jackc/pgx/v5/pgxpool" // stdlib registers the "pgx" database/sql driver. Importing it lets the // testcontainers postgres module take snapshots through pgx instead of the // slower `docker exec psql` fallback. @@ -79,11 +80,23 @@ func setupPostgres(ctx context.Context, t *testing.T) *fixture { func (f *fixture) Reset(ctx context.Context, t *testing.T) *todopostgres.TodoRepository { t.Helper() + return todopostgres.NewTodoRepository(f.ResetPool(ctx, t)) +} + +// ResetPool restores the database to its clean post-migration state and returns +// a fresh pgx pool bound to it. Restore drops and recreates the target database +// (WITH FORCE), terminating any existing connections, so the pool must be opened +// after the restore — hence a new pool per call. The pool is closed via +// t.Cleanup. The authz suites use it directly to seed api_keys rows and to share +// the container URL with app.New. +func (f *fixture) ResetPool(ctx context.Context, t *testing.T) *pgxpool.Pool { + t.Helper() + require.NoError(t, f.container.Restore(ctx)) pool, err := postgres.Connect(ctx, postgres.Config{URL: f.url}) require.NoError(t, err) t.Cleanup(pool.Close) - return todopostgres.NewTodoRepository(pool) + return pool } From 37286639914b51e87e059415810f3b315555f270 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 19:19:54 -0700 Subject: [PATCH 08/10] test(authz): tighten phase C test comments (drop design refs, correct URL-id claim) --- internal/integration/apikey_store_test.go | 16 ++++++++-------- internal/integration/authz_e2e_test.go | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/internal/integration/apikey_store_test.go b/internal/integration/apikey_store_test.go index d2b2394..697dcc1 100644 --- a/internal/integration/apikey_store_test.go +++ b/internal/integration/apikey_store_test.go @@ -42,14 +42,14 @@ func apiKeyContext(key string) huma.Context { } // TestAPIKeyStoreAdapter exercises the real PostgreSQL-backed apikey.Store and -// the apikey.Authenticator against the container database. The design defers the -// adapter's coverage to this suite ("the postgres APIKeyStore adapter is covered -// in internal/integration"). Rows are inserted directly, then resolved through -// the shipped store and authenticator, so the hand-written lookup query, the -// text[] roles column, and the principal mapping are all exercised together. It -// shares one migrated container (the fixture applies migration 00002, so the -// api_keys table exists) and restores the clean snapshot between subtests for -// isolation, so the subtests run sequentially rather than in parallel. +// the apikey.Authenticator against the container database — the only place the +// hand-written lookup query and the text[] roles column run against real +// PostgreSQL. Rows are inserted directly, then resolved through the shipped store +// and authenticator, so the lookup query, the text[] roles column, and the +// principal mapping are all exercised together. It shares one migrated container +// (the fixture applies migration 00002, so the api_keys table exists) and restores +// the clean snapshot between subtests for isolation, so the subtests run +// sequentially rather than in parallel. func TestAPIKeyStoreAdapter(t *testing.T) { ctx := context.Background() fix := setupPostgres(ctx, t) diff --git a/internal/integration/authz_e2e_test.go b/internal/integration/authz_e2e_test.go index c16b759..c99cea2 100644 --- a/internal/integration/authz_e2e_test.go +++ b/internal/integration/authz_e2e_test.go @@ -118,9 +118,12 @@ func createdID(t *testing.T, body string) string { // authorization enabled and a real PostgreSQL-backed api-key authenticator. It // seeds a user-role key, an admin-role key, and a roleless key directly into the // api_keys table, then asserts the real decision matrix: no credential -> 401, -// an unauthorized role -> 403, a user key -> 2xx on every granted todo route, an -// admin key -> allowed via base.cedar, and the URL-identity by-id routes -// (get/complete) resolving Resource = Todo::"" for a todo created over HTTP. +// an unauthorized role -> 403, a user key -> 2xx on every granted todo route, and +// an admin key -> allowed via base.cedar. The by-id routes (get/complete) confirm +// those routes are reachable, authorized for the granted role, and return the +// correct instance; the Resource = Todo::"" binding itself is guarded by the +// unit test TestMiddlewareBindsURLIDToResource, since the coarse policy here is +// resource-agnostic and cannot distinguish the bound id. // // It shares one migrated container and seeds the api_keys rows once after a clean // restore; the app connects its own pool to the same database, so the request @@ -139,8 +142,7 @@ func TestAuthzEndToEnd(t *testing.T) { srv := e2eServer(ctx, t, fix.url) t.Run("NoCredentialIsUnauthorized", func(t *testing.T) { - // Anonymous on a protected route -> 401 (the design's no-principal + deny - // path), not 403. + // Anonymous on a protected route -> 401 (no principal + deny path), not 403. got := e2eRequest(t, srv, http.MethodGet, "/todos", "") assert.Equal(t, http.StatusUnauthorized, got.status, got.body) @@ -168,9 +170,10 @@ func TestAuthzEndToEnd(t *testing.T) { listed := e2eRequest(t, srv, http.MethodGet, "/todos", "", apiKey("user-key")) assert.Equal(t, http.StatusOK, listed.status, listed.body) - // get by id (URL-identity: Resource = Todo::"") -> 200 for the todo - // created above, proving the path param feeds the Cedar resource and the - // coarse user policy still allows it. + // get by id -> 200 for the todo created above: the by-id route is reachable, + // authorized for the user role, and returns the same instance. (The + // Resource = Todo::"" binding is proven in TestMiddlewareBindsURLIDToResource; + // the coarse policy here allows any resource.) fetched := e2eRequest(t, srv, http.MethodGet, "/todos/"+id, "", apiKey("user-key")) assert.Equal(t, http.StatusOK, fetched.status, fetched.body) assert.Equal(t, id, createdID(t, fetched.body)) From 37634b188d941ea884c95d9df5fd1dbd345babf0 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 19:31:29 -0700 Subject: [PATCH 09/10] docs(authz): mock-keys seed + README/DELETE_ME/docs for the authz tier (phase D) Co-Authored-By: Claude Opus 4.8 (1M context) --- DELETE_ME.md | 34 ++++++- README.md | 156 ++++++++++++++++++++++++++++++-- docs/docs/index.md | 33 ++++++- hack/sql/0002_seed_api_keys.sql | 29 ++++++ 4 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 hack/sql/0002_seed_api_keys.sql diff --git a/DELETE_ME.md b/DELETE_ME.md index ff7b9b5..b230ed8 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -10,6 +10,7 @@ It is only here to orient the initial project owner. - A runnable hexagonal HTTP API server (chi + Huma) at `github.com/meigma/template-go-api`, with a `todo` example resource, RFC 9457 errors, `/healthz`, `/readyz`, and `/metrics`, runtime API docs at `/docs`, and an `openapi` spec-export command. - A PostgreSQL persistence adapter (pgx + sqlc typed queries + goose migrations) behind the domain's `todo.Repository` port: a `migrate` subcommand, a committed-and-drift-guarded sqlc layer, a real `/readyz` check, and container-backed integration tests. The port is the seam — implement it to back the template with a different datastore. +- An authorization tier (Cedar via `cedar-go`) with a deny-by-default Huma middleware, a modular per-resource authz slice pattern, and authentication deferred to the integrator: a placeholder API-key authenticator (`X-API-Key`/Bearer, backed by an `api_keys` table) and dev-only mock keys seeded for the Compose demo. Replace the authenticator with real authn — see step 6. - A Cobra/Viper entrypoint under `cmd/template-go-api` and `internal/cli` exposing `serve` (default), `version`, `openapi`, and `migrate`. - Moon tasks for `format`, `lint`, `build`, `test`, and `check`, plus `sqlc` / `sqlc-check` (regenerate and drift-guard the typed query layer), `mockery` / `mockery-check` (regenerate and drift-guard the testify mocks), `migrate` (run database migrations), and `test-integration` (container-backed adapter tests). - `golangci-lint`, `sqlc`, `goose`, and `mockery` wired through Proto and Moon. @@ -85,6 +86,7 @@ The nominal generated-project path is an HTTP service with both a downloadable b - Add a domain package `internal/` (entity, `Repository` port, and `Service`), mirroring `internal/todo`. Each resource owns its adapters nested beneath it (`internal//{httpapi,postgres}`). - Implement the port in a nested adapter: mirror `internal/todo/postgres` (pgx + sqlc, on the shared pool/migrations in `internal/adapter/postgres`). The README's [Persistence](README.md#persistence) section covers the migration and sqlc-regeneration workflow. - Add a transport adapter `internal//httpapi` (DTOs, domain mapping, error translation, and a `Register` function), mirroring `internal/todo/httpapi`. + - Add an authz slice `internal//authz` (policies, actions, fact resolver), tag the `httpapi` operations with `authz.Require`/`authz.Public`, and merge its `Contribution` into `authz.New` in `internal/app/app.go`, mirroring `internal/todo/authz`. Authorization is deny-by-default, so an untagged operation is rejected (see the README's [Authorization](README.md#authorization) section). If you are dropping authorization, see step 6. - Add one `Register` call in `registerResources` in `internal/app/app.go`. - When you wire a real datastore, add a readiness check to the `Readiness` slice in `internal/app/app.go` so `/readyz` reflects it (the PostgreSQL adapter shows the pattern with its `Ping` check). - Put cross-package integration tests in `internal/integration` (package `integration`, `//go:build integration`), run via `moon run test-integration`; keep fast unit tests beside the code they cover. Repository doubles come from the mockery-generated mocks in `internal//mocks` (register the new port in `.mockery.yaml`); a stateful in-memory fake for end-to-end tests lives in `internal/todo/todotest`. The README's [Testing](README.md#testing) section covers the split. @@ -94,13 +96,35 @@ The nominal generated-project path is an HTTP service with both a downloadable b Keep the generic transport in `internal/adapter/http` (router, middleware, `/healthz`/`/readyz`/`/metrics`, RFC 9457 fallbacks, the `Registrar` seam), `internal/config`, and `internal/observability` as-is unless you have a reason to change them. -6. Refresh module metadata: +6. Wire real authentication (and prune or keep the authorization tier). + + The template ships an authorization tier (Cedar via `cedar-go`, deny-by-default Huma middleware) with authentication **deferred to you**. The shipped API-key authenticator is a placeholder, not a security mechanism. Address it before the first real deployment: + + - **Replace the shipped API-key authenticator (first priority).** It is a placeholder: it stores keys verbatim and is meant only to demonstrate the flow. Implement `authz.Authenticator` with a real verifier (JWT via `lestrrat-go/jwx`, OIDC via `coreos/go-oidc`, sessions, etc.) and inject it with `app.WithAuthenticator` in `internal/app/app.go`. Map the verified claims (subject, roles/groups) into the `authz.Principal`. If you keep the API-key store instead of replacing it, at minimum store a **hash** of each key and compare in constant time (`crypto/subtle.ConstantTimeCompare`) — see the SECURITY note in `internal/authz/apikey/store.go` — and never seed the mock keys. + - **Delete the dev mock-keys seed regardless:** remove `hack/sql/0002_seed_api_keys.sql` (insecure public credentials, dev-only). Real deployments insert their own `api_keys` rows out of band and never apply `hack/sql/`. + + To remove the authorization tier **entirely** (surgical, the slice pattern keeps it self-contained): + + - Delete the base engine `internal/authz` (this also removes `internal/authz/apikey`, the shipped authenticator + PostgreSQL `APIKeyStore`, and the base mockery doubles under `internal/authz/mocks` and `internal/authz/apikey/mocks`). + - Delete the todo authz slice `internal/todo/authz` (its `policy.cedar`, actions, and fact resolver). + - Delete the `api_keys` goose migration `internal/adapter/postgres/migrations/00002_create_api_keys.sql` and the dev seed `hack/sql/0002_seed_api_keys.sql`. + - Untag the `httpapi` routes: remove the `Metadata: authz.Require(...)` lines and the `authz`/`todoauthz` imports from `internal/todo/httpapi/handler.go`. + - Remove the authz wiring from the composition root `internal/app/app.go` (`authzInstaller`/`resolveAuthenticator`, the `WithAuthenticator` option, the `InstallAuthz`/`FinalizeAuthz` hooks, and the `DocumentSecurity` call in the spec exporter). + - Remove the `--authz-enabled` and `--authz-policy-dir` flags (and the `AuthzEnabled`/`AuthzPolicyDir` fields) from `internal/config/config.go`. + - Remove the new authz ports from `.mockery.yaml`. + - Run `go mod tidy` to drop `github.com/cedar-policy/cedar-go`. + - **No `sqlc` regen is needed:** `omit_unused_structs: true` in `sqlc.yaml` already keeps the todo sqlc package todo-only, so the `api_keys` table never produced any todo-sqlc output and dropping the migration changes nothing there. + - Drop the authz integration/e2e coverage in `internal/integration` (the container-backed `APIKeyStore` test and the functional authz tests). + + As a quick alternative for incremental adoption, set `--authz-enabled=false` (env `TEMPLATE_GO_API_AUTHZ_ENABLED=false`) to bypass the middleware entirely without deleting anything. + +7. Refresh module metadata: ```sh go mod tidy ``` -7. Configure releases for the chosen shape. +8. Configure releases for the chosen shape. For the nominal binary plus container case: @@ -135,19 +159,19 @@ The nominal generated-project path is an HTTP service with both a downloadable b In every release-bearing project, configure the release app credentials, protected-tag bypass, and repository package permissions before the first release. Run the release dry-run workflow after these edits and before merging the first release PR. -8. Run the full local check: +9. Run the full local check: ```sh moon run root:check ``` -9. Update project-facing docs: +10. Update project-facing docs: - Rewrite `README.md` for the actual project. - Review `CONTRIBUTING.md` and `SECURITY.md`. - Add a real license before publishing the repository. -10. Delete this file: +11. Delete this file: ```sh rm DELETE_ME.md diff --git a/README.md b/README.md index efddf39..ebda4ed 100644 --- a/README.md +++ b/README.md @@ -50,24 +50,36 @@ To build the binary and run it against your own PostgreSQL instead, see moon run root:build # or: go build -o bin/template-go-api ./cmd/template-go-api ``` -With the stack up, exercise the example `todo` API: +With the stack up, exercise the example `todo` API. The todo routes are +protected by the [Authorization](#authorization) tier (on by default), so each +request carries one of the dev keys the Compose stack seeds — `dev-user-key` +sent via the `X-API-Key` header: ```sh -# Create a todo +# Without a key, protected routes reject the request: +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/todos +# => 401 + +# Create a todo (the user key has the role the todo policy requires): curl -sS -X POST localhost:8080/todos \ + -H 'X-API-Key: dev-user-key' \ -H 'content-type: application/json' \ -d '{"title":"buy milk"}' # => 201 {"id":"...","title":"buy milk","status":"open","createdAt":"...","completedAt":null} -curl -sS localhost:8080/todos # list -curl -sS localhost:8080/todos/ # fetch one (404 if unknown) -curl -sS -X POST localhost:8080/todos//complete # mark complete +curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # list +curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos/ # fetch one (404 if unknown) +curl -sS -X POST -H 'X-API-Key: dev-user-key' localhost:8080/todos//complete # mark complete # Validation and not-found errors use RFC 9457 problem+json: -curl -sS -i -X POST localhost:8080/todos -H 'content-type: application/json' -d '{"title":""}' +curl -sS -i -X POST localhost:8080/todos \ + -H 'X-API-Key: dev-user-key' -H 'content-type: application/json' -d '{"title":""}' # => 422 application/problem+json ``` +These mock keys are dev-only seeds; see [Authorization](#authorization) for how +authn/authz work and how to plug in a real authenticator. + Operational endpoints: ```sh @@ -86,14 +98,25 @@ Elements) and the live spec at `/openapi.yaml` and `/openapi.json`. ## Local stack (Docker Compose) `docker compose up --build` brings up the **full** template against PostgreSQL — -no local Go toolchain or database setup required: +no local Go toolchain or database setup required. The stack also seeds the +dev-only mock API keys (`hack/sql/0002_seed_api_keys.sql`), so the +[Authorization](#authorization) tier is exercised end to end out of the box: ```sh docker compose up --build -curl -sS localhost:8080/todos # => the seeded todos + +# Authorization is on by default. With no key, a protected route returns 401: +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/todos # => 401 + +# With the seeded dev user key, the same route returns 200 and the seeded todos: +curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # => 200, the seeded todos + curl -sS localhost:8080/readyz # => {"status":"ready","checks":{"postgres":"ok"}} ``` +`/readyz` (and the other operational endpoints) are raw routes outside the Huma +authorization middleware, so they need no key. + Startup is an ordered DAG, because migrations are explicit (the server never runs them) and the seed data needs the schema to exist first: @@ -112,7 +135,10 @@ Prepopulate local data by dropping SQL files in [`hack/sql/`](hack/sql/) — the run after the schema exists, so you can `INSERT` straight into tables like `todos` without touching migrations or adding setup code to the server. The bundled `hack/sql/0001_seed_todos.sql` seeds a few todos so the API returns data on the -first request. +first request, and `hack/sql/0002_seed_api_keys.sql` seeds the **dev-only mock +API keys** (`dev-user-key`, `dev-admin-key`) that the [Authorization](#authorization) +demo uses. These seeds are local-development only and never reach a real +deployment (migrations run everywhere; `hack/sql/` does not). ## Commands @@ -151,6 +177,8 @@ default. | `--trusted-proxy-header` | `TEMPLATE_GO_API_TRUSTED_PROXY_HEADER` | _(none)_ | proxy header to read the client IP from (e.g. `X-Real-IP`); empty trusts the TCP peer | | `--database-url` | `TEMPLATE_GO_API_DATABASE_URL` | _(none)_ | PostgreSQL connection URL (**required**) | | `--db-max-conns` | `TEMPLATE_GO_API_DB_MAX_CONNS` | `0` | maximum PostgreSQL pool connections; `0` uses the driver default | +| `--authz-enabled` | `TEMPLATE_GO_API_AUTHZ_ENABLED` | `true` | enable the [authorization](#authorization) middleware (deny-by-default); `false` bypasses it entirely | +| `--authz-policy-dir` | `TEMPLATE_GO_API_AUTHZ_POLICY_DIR` | _(none)_ | directory of `.cedar` files to load instead of the embedded policies; empty uses the embedded set | CORS is off until you set origins. Client IP is read from the direct TCP peer unless you opt into a trusted proxy header — never from `X-Forwarded-For` @@ -288,6 +316,109 @@ The rule: query-builder types must never appear in a port signature. The domain speaks in domain criteria; only the adapter knows SQL. Swapping Squirrel for Bob, or back to plain sqlc, then stays a change inside one package. +## Authorization + +The template ships an authorization tier built on +[AWS Cedar](https://www.cedarpolicy.com/) via +[`cedar-policy/cedar-go`](https://github.com/cedar-policy/cedar-go) as the +embedded policy engine. Policies are real `.cedar` source, embedded in the binary +(or loaded from a directory with `--authz-policy-dir`), and evaluated by one +global Huma middleware. + +The posture is **deny-by-default**: every Huma API operation must declare its +authorization requirement, and an operation that declares none is denied +(fail-closed) and logged. Denials are returned as RFC 9457 problem responses — +`401` when no/invalid credential was presented, `403` when an authenticated +caller lacks the required role. The operational routes (`/healthz`, `/readyz`, +`/metrics`, `/openapi.*`, `/docs`) are raw chi routes outside the Huma +middleware, so they are never gated. + +`--authz-enabled` (default `true`) is the master switch; setting it `false` +bypasses the middleware entirely (an escape hatch for incremental adoption or +local debugging). The declaration also populates each operation's OpenAPI +`security`, so protection is visible in the generated spec and at `/docs`. + +### Authentication is deferred to the integrator + +Authentication is **not** something the template decides for you. It ships the +*seam* — an `Authenticator` interface (`internal/authz`) — plus one **replaceable +starting point**: an API-key authenticator (`internal/authz/apikey`). + +The shipped authenticator reads a key from the `X-API-Key` header or an +`Authorization: Bearer ` credential and resolves it through an `APIKeyStore` +port to a principal (subject + roles). The shipped store is PostgreSQL-backed: +keys live in the `api_keys` table (created by migration +`00002_create_api_keys.sql`). This is a real but minimal mechanism — enough to +demonstrate the full flow — not production authn. It stores keys verbatim; the +hardening path (hash + constant-time compare) and replacement with real authn are +called out in [DELETE_ME.md](DELETE_ME.md). + +For local development the Compose stack seeds two mock keys via +`hack/sql/0002_seed_api_keys.sql`: + +| Key | Roles | Authorized for | +| -------------- | ------- | ---------------------------------------------------------- | +| `dev-user-key` | `user` | all todo actions (the todo slice's policy) | +| `dev-admin-key`| `admin` | everything (the cross-cutting admin override, `base.cedar`)| + +These are **insecure, public, dev-only** credentials; real deployments insert +their own `api_keys` rows and never apply `hack/sql/`. + +To swap in real authn (JWT/OIDC/session), implement `authz.Authenticator` and +inject it with `app.WithAuthenticator`; nothing else in the tier changes. + +### Modular, per-resource authorization + +Authorization is **modular** in the same way the HTTP transport is: each domain +contributes an *authz slice* and the composition root merges all contributions +into one runtime engine. The todo slice lives at `internal/todo/authz` and +contributes three things via an `authz.Contribution`: + +- **Policies** — embedded `policy.cedar` source (merged with `base.cedar` and + every other slice's policies into one Cedar `PolicySet`). +- **Actions** — typed Cedar action identifiers (`todoauthz.ActionCreate`, + `ActionRead`, `ActionUpdate`, `ActionDelete`, `ActionList`), each of the form + `Action::"todo:"`. +- **A fact resolver** — maps a `Todo` entity to its Cedar attributes/parents, + loaded **lazily** (only when a policy dereferences a todo, which the shipped + coarse policy never does). + +The HTTP slice tags each operation with its action at registration, using +`authz.Require` (or `authz.Public` to opt out): + +```go +huma.Register(api, huma.Operation{ + OperationID: "get-todo", + Method: http.MethodGet, + Path: "/todos/{id}", + // Item operation: bind the {id} path param so the middleware sets + // Resource = Todo::"" straight from the matched route (no load). + Metadata: authz.Require(todoauthz.ActionRead, "id"), +}, h.get) + +huma.Register(api, huma.Operation{ + OperationID: "list-todos", + Method: http.MethodGet, + Path: "/todos", + // Collection operation: type-level resource, no id bound. + Metadata: authz.Require(todoauthz.ActionList), +}, h.list) +``` + +To add authorization to a **new** resource, mirror the todo slice: + +1. Add `internal//authz` with `policy.cedar` (embedded), typed action + constants, a fact resolver, and a `Contribution(repo)` function. +2. Tag the resource's `httpapi` operations with `authz.Require([, idParam])` + (or `authz.Public()` for an unauthenticated operation). +3. Add the slice's `Contribution(...)` to the slice list passed to `authz.New` + in `internal/app/app.go` (alongside `todoauthz.Contribution(repo)`). + +At runtime there is one merged `PolicySet` over one shared entity space, so a +policy in one slice can reference shared principal roles (`principal in +Role::"admin"`) or another slice's entities. Cross-cutting rules and shared +principal/role types live in the base package's `base.cedar`. + ## Testing Unit tests sit beside the code and use [Testify](https://github.com/stretchr/testify) @@ -325,8 +456,12 @@ internal/ postgres/ outbound adapter: PostgreSQL Repository (pgx + sqlc) queries/ hand-written sqlc queries sqlc/ generated, committed, drift-guarded query layer + authz/ the todo authz slice: policy.cedar, actions, fact resolver mocks/ generated testify mock of the Repository port (mockery) todotest/ in-memory Repository fake for tests + authz/ base authz engine: Cedar Authorizer, deny-by-default middleware, + Require/Public declarations, Principal/Authenticator seam, base.cedar + apikey/ the shipped API-key Authenticator + PostgreSQL APIKeyStore adapter/ shared, cross-domain infrastructure (not domain-specific) http/ generic transport: chi router, middleware, RFC 9457 errors, /healthz /readyz /metrics, OpenAPI export, Registrar seam @@ -353,7 +488,8 @@ package root, with its adapters nested beneath it. 1. Add a domain package `internal/` (entity + `Repository` port + `Service`), mirroring `internal/todo`. 2. Implement the port in a nested adapter — mirror `internal/todo/postgres` for a PostgreSQL-backed datastore (see [Persistence](#persistence) for the sqlc/goose workflow). 3. Add a transport adapter `internal//httpapi` (DTOs, domain mapping, error translation, and a `Register` function), mirroring `internal/todo/httpapi`. -4. Add one `Register` call in `registerResources` in `internal/app/app.go`. +4. Add an authz slice `internal//authz` (policies, actions, fact resolver) and tag the `httpapi` operations with `authz.Require`/`authz.Public`, then merge its `Contribution` in `internal/app/app.go`. See [Authorization](#authorization) — deny-by-default means an untagged operation is rejected. +5. Add one `Register` call in `registerResources` in `internal/app/app.go`. Shared, cross-domain infrastructure needs no changes: the generic transport in `internal/adapter/http` and the connection pool / migrations in diff --git a/docs/docs/index.md b/docs/docs/index.md index 88e1776..4a780df 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -17,13 +17,29 @@ port to back the template with a different datastore. The server persists to PostgreSQL, so running it needs a database. The fastest path is Docker Compose, which brings up the database, migrations, seed data, and -the API together: +the API together. The Compose stack also seeds dev-only mock API keys, because +the todo routes are protected by the authorization tier (on by default): ```sh docker compose up --build -curl -sS -X POST localhost:8080/todos -H 'content-type: application/json' -d '{"title":"buy milk"}' + +# Authorization is on: without a key, a protected route returns 401. +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/todos # => 401 + +# Use the seeded dev user key (sent via the X-API-Key header): +curl -sS -X POST localhost:8080/todos \ + -H 'X-API-Key: dev-user-key' \ + -H 'content-type: application/json' \ + -d '{"title":"buy milk"}' # => 201 +curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # => 200, the seeded todos ``` +The stack seeds two mock keys: `dev-user-key` (role `user`, authorized for the +todo actions) and `dev-admin-key` (role `admin`, authorized for everything). +These are insecure, dev-only credentials — real deployments insert their own +keys and never apply `hack/sql/`. The operational endpoints (`/healthz`, +`/readyz`, `/metrics`) sit outside the authorization middleware and need no key. + To build the binary and run it against your own PostgreSQL instead: ```sh @@ -32,15 +48,21 @@ docker run --rm -d -p 5432:5432 \ -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app postgres:17-alpine export TEMPLATE_GO_API_DATABASE_URL='postgres://app:app@localhost:5432/app?sslmode=disable' moon run root:build -./bin/template-go-api migrate up # create the schema +./bin/template-go-api migrate up # create the schema (incl. the api_keys table) ./bin/template-go-api serve # listens on :8080 ``` +Running the binary directly applies the schema but not the `hack/sql/` seeds, so +the `api_keys` table starts empty. Insert a key yourself, or set +`TEMPLATE_GO_API_AUTHZ_ENABLED=false` to bypass authorization while developing. + See the [README](https://github.com/meigma/template-go-api#readme) for the full quickstart, configuration reference, the [Persistence](https://github.com/meigma/template-go-api#persistence) workflow -(migrations, sqlc regeneration, integration tests, dynamic queries), and guidance -on replacing the example resource. +(migrations, sqlc regeneration, integration tests, dynamic queries), the +[Authorization](https://github.com/meigma/template-go-api#authorization) tier +(Cedar policies, the deferred-authn seam, the modular slice pattern), and +guidance on replacing the example resource. ## API reference @@ -54,6 +76,7 @@ running server also serves interactive docs at `/docs` and the live spec at - Readiness: `GET /readyz` (reports named per-check results; the PostgreSQL adapter adds a `postgres` connectivity check) - Metrics: `GET /metrics` on a dedicated listener (`--metrics-addr`, default `:9090`) - Migrations are explicit: `serve` never runs them; use the `migrate up|down|status` subcommand. +- Authorization is deny-by-default and on by default (`--authz-enabled`, env `TEMPLATE_GO_API_AUTHZ_ENABLED`); the operational endpoints above are outside the authorization middleware. Set it `false` to bypass authorization entirely. ## Support and security diff --git a/hack/sql/0002_seed_api_keys.sql b/hack/sql/0002_seed_api_keys.sql new file mode 100644 index 0000000..562bf17 --- /dev/null +++ b/hack/sql/0002_seed_api_keys.sql @@ -0,0 +1,29 @@ +-- ############################################################################ +-- # INSECURE MOCK API KEYS — LOCAL DEVELOPMENT ONLY. DO NOT USE IN PRODUCTION. +-- ############################################################################ +-- +-- These are plaintext placeholder credentials seeded into the EPHEMERAL Docker +-- Compose database so `docker compose up` demonstrates the authorization tier +-- end to end with zero config. They are NOT a security mechanism: +-- +-- * The values are public, hard-coded, and committed to the repository. +-- * They are stored verbatim (the shipped store matches keys as plaintext). +-- * They are data, not schema: real deployments run the goose migrations but +-- NEVER apply hack/sql/, so these mock keys can never reach a real database. +-- +-- Remove this file (and replace the shipped API-key authenticator with real +-- authn) before going to production — see DELETE_ME.md. Real deployments insert +-- their own api_keys rows out of band. +-- +-- The roles below must match the shipped policies: Role::"user" satisfies the +-- todo slice's coarse policy (internal/todo/authz/policy.cedar) and Role::"admin" +-- satisfies the cross-cutting admin override (internal/authz/base.cedar). +-- +-- Columns are (key, subject, roles text[]) per migration +-- internal/adapter/postgres/migrations/00002_create_api_keys.sql. The +-- authenticator reads the key from the X-API-Key header or an +-- Authorization: Bearer credential. +INSERT INTO api_keys (key, subject, roles) VALUES + ('dev-user-key', 'dev-user', ARRAY['user']), + ('dev-admin-key', 'dev-admin', ARRAY['admin']) +ON CONFLICT (key) DO NOTHING; From bf0b9aaf2d4e46aa3594c1d3125b8aedcca6e37a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 23 Jun 2026 19:43:42 -0700 Subject: [PATCH 10/10] docs(authz): correct apikey.go sqlc note, create-response example, compose curl comment --- README.md | 2 +- compose.yaml | 2 +- internal/authz/apikey/apikey.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ebda4ed..33872ed 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ curl -sS -X POST localhost:8080/todos \ -H 'X-API-Key: dev-user-key' \ -H 'content-type: application/json' \ -d '{"title":"buy milk"}' -# => 201 {"id":"...","title":"buy milk","status":"open","createdAt":"...","completedAt":null} +# => 201 {"$schema":"...","id":"...","title":"buy milk","status":"open","createdAt":"..."} curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # list curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos/ # fetch one (404 if unknown) diff --git a/compose.yaml b/compose.yaml index 72554ee..604b3c5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ # Day-one local stack: PostgreSQL + the API server, fully wired. # # docker compose up --build -# curl -sS localhost:8080/todos # returns the seeded todos +# curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/todos # the seeded todos (authz is on) # # Startup is an ordered DAG, because migrations are explicit (the server never # runs them) and the seed data depends on the schema existing first: diff --git a/internal/authz/apikey/apikey.go b/internal/authz/apikey/apikey.go index bdf96e4..5f8e03a 100644 --- a/internal/authz/apikey/apikey.go +++ b/internal/authz/apikey/apikey.go @@ -8,9 +8,9 @@ // The shipped store is PostgreSQL-backed (store.go); keys live in an api_keys // table since the template is postgres-only. The package hand-writes its single // query rather than adding a second sqlc package, so removal stays surgical for -// the Go code. The api_keys table lives in the shared migrations directory, so -// the todo sqlc package generates an unused ApiKey model from it; removing the -// feature also drops that table and regenerates the todo sqlc package. +// the Go code. The api_keys table lives in the shared migrations directory, but +// sqlc.yaml sets omit_unused_structs, so the todo sqlc package emits no ApiKey +// model and removing the feature needs no sqlc regeneration. package apikey import (