From d473398aba135f661a11ff4440066567c8051b00 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 9 May 2026 16:20:04 +0000 Subject: [PATCH] auth-bundle-1 Phase 3 (primitive): RequirePermission middleware + demo-mode + protocol allowlist Bundle 1 / Phase 3 (primitive ship): the load-bearing RBAC middleware factory plus its dependencies. Handler conversion sweep (5 admin files: bulk_revocation.go, admin_crl_cache.go, admin_scep_intune.go, admin_est.go, intermediate_ca.go) + m008_admin_gate_test.go registry update is Phase 3.5 follow-on; this commit ships the primitive so 3.5 is mechanical. New context keys (internal/auth/context.go): ActorIDKey, ActorTypeKey, TenantIDKey alongside the legacy UserKey + AdminKey. New helpers GetActorID / GetActorType / GetTenantID with safe fallbacks (UserKey for actor id, ActorTypeAPIKey for missing type, DefaultTenantID for missing tenant). Constants DemoAnonActorID + ActorTypeAPIKey + ActorTypeAnonymous mirror internal/domain/auth without an import cycle. RequirePermission factory (internal/auth/require_permission.go): wraps a handler and gates it behind a named permission. 401 when no actor, 403 when actor lacks permission, 500 on repository error. Skips the gate entirely for protocol endpoints (ACME / SCEP / EST / OCSP / CRL) per the audit's Category F do-not-gate allowlist. PermissionChecker is an interface so internal/auth doesn't depend on internal/service/auth (cmd/server wires the concrete Authorizer at startup). HasPermission is the imperative variant for handlers that branch behaviour rather than 403'ing. ScopeFunc closure extracts the scope type + id from the request for per-resource gating. Protocol-endpoint allowlist (internal/auth/protocol_endpoints.go): IsProtocolEndpoint matches /acme, /scep, /.well-known/est, /.well-known/pki/ocsp, /.well-known/pki/crl prefixes. Adding a new protocol endpoint MUST update this list and add a parallel test. Demo-mode synthetic admin (internal/auth/middleware.go::NewDemoModeAuth): when CERTCTL_AUTH_TYPE=none is configured, this middleware injects ActorID=actor-demo-anon, ActorType=Anonymous, TenantID=t-default, plus the legacy UserKey + AdminKey for back-compat with existing handlers. The synthetic actor's admin-role grant is seeded by migration 000029 so RequirePermission resolves through the JOIN like any other actor. cmd/server startup wires this middleware only when none-mode is configured. API-key middleware extension: NewAuthWithNamedKeys now populates the new keys (ActorIDKey, ActorTypeKey=APIKey, TenantIDKey=t-default) alongside UserKey + AdminKey on every successful Bearer match. Existing handlers continue to read UserKey / IsAdmin until the Phase 3.5 sweep converts them to RequirePermission. Test coverage: TestRequirePermission_NoActorReturns401, TestRequirePermission_GrantedActorReaches200, TestRequirePermission_DeniedActorReturns403, TestRequirePermission_CheckerErrorReturns500, TestRequirePermission_ProtocolEndpointBypassesGate (covers all 5 prefixes), TestRequirePermission_ScopeFnExtractsResourceID, TestIsProtocolEndpoint_PrefixesOnly, TestNewDemoModeAuth_InjectsSyntheticActor, TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys. fakeChecker pins the contract without a database. Phase 3.5 follow-on (NOT in this commit): convert each of the 5 admin handlers from auth.IsAdmin checks to auth.RequirePermission middleware in router.go; update internal/api/handler/m008_admin_gate_test.go to track auth.RequirePermission call sites instead of (or alongside) auth.IsAdmin; pick the right permission per handler (cert.revoke for bulk_revocation, etc.). Each handler conversion needs the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) per M-008. Branch: dev/auth-bundle-1. Phase 2 was prior commit (service layer). Phase 3.5 (handler conversion) + Phase 4 (HTTP API) on the next session. --- internal/auth/context.go | 82 ++++++++ internal/auth/middleware.go | 37 +++- internal/auth/protocol_endpoints.go | 49 +++++ internal/auth/require_permission.go | 126 ++++++++++++ internal/auth/require_permission_test.go | 233 +++++++++++++++++++++++ 5 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 internal/auth/protocol_endpoints.go create mode 100644 internal/auth/require_permission.go create mode 100644 internal/auth/require_permission_test.go diff --git a/internal/auth/context.go b/internal/auth/context.go index 9bc5970..ccf146b 100644 --- a/internal/auth/context.go +++ b/internal/auth/context.go @@ -48,3 +48,85 @@ func IsAdmin(ctx context.Context) bool { admin, ok := ctx.Value(AdminKey{}).(bool) return ok && admin } + +// ============================================================================= +// Bundle 1 Phase 3: RBAC-aware context keys. +// +// ActorIDKey, ActorTypeKey, and TenantIDKey are populated by the auth +// middleware (NewAuthWithNamedKeys, NewDemoModeAuth, and Bundle 2's +// session middleware) so that downstream RBAC checks have a stable +// identity + tenancy view of the caller. +// +// UserKey + AdminKey continue to be populated for back-compat with +// existing audit / rate-limiter / handler code; the new keys are the +// canonical Phase 3+ identity. +// ============================================================================= + +// ActorIDKey is the canonical actor identifier (e.g. an API-key name, +// an OIDC user id, or the synthetic `actor-demo-anon`). Phase 3 +// middleware populates this; auth.RequirePermission and +// auth.CallerFromContext read it. +type ActorIDKey struct{} + +// ActorTypeKey is the typed-string actor type (User, System, Agent, +// APIKey, Anonymous) corresponding to internal/domain.ActorType. Stored +// as a string so the internal/auth package doesn't need to import the +// domain package and create a cycle. +type ActorTypeKey struct{} + +// TenantIDKey is the tenant the request executes in. Bundle 1 ships +// single-tenant; every authenticated request gets the seeded +// `t-default` tenant unless the future managed-service offering +// configures a different one. +type TenantIDKey struct{} + +// GetActorID returns the canonical actor id from context, or "" when +// no actor is present (anonymous request, missing middleware in test +// harnesses, etc.). Falls back to the legacy UserKey value for +// back-compat with handlers that have not yet adopted the new keys. +func GetActorID(ctx context.Context) string { + if id, ok := ctx.Value(ActorIDKey{}).(string); ok && id != "" { + return id + } + return GetUser(ctx) +} + +// GetActorType returns the actor type string from context, or "" when +// no actor type was set. Phase 3 middleware sets this to "APIKey" for +// validated bearer-token requests and "Anonymous" for the demo-mode +// synthetic actor. +func GetActorType(ctx context.Context) string { + if t, ok := ctx.Value(ActorTypeKey{}).(string); ok { + return t + } + return "" +} + +// GetTenantID returns the tenant id from context, or the seeded +// default tenant when no value was set. Returning the default rather +// than "" keeps RBAC lookups working in deployments that haven't +// configured a tenant explicitly (the Bundle 1 baseline). +func GetTenantID(ctx context.Context) string { + if t, ok := ctx.Value(TenantIDKey{}).(string); ok && t != "" { + return t + } + return DefaultTenantID +} + +// DefaultTenantID is the seeded single tenant. Mirrors +// internal/domain/auth.DefaultTenantID; duplicated here to avoid a +// cross-package import in the hot-path middleware. +const DefaultTenantID = "t-default" + +// DemoAnonActorID is the synthetic actor id used by the demo-mode +// auth middleware when CERTCTL_AUTH_TYPE=none. Mirrors +// internal/domain/auth.DemoAnonActorID. +const DemoAnonActorID = "actor-demo-anon" + +// ActorTypeAPIKey + ActorTypeAnonymous mirror the corresponding +// domain.ActorType values. Stored as untyped strings here so callers +// don't have to import the domain package. +const ( + ActorTypeAPIKey = "APIKey" + ActorTypeAnonymous = "Anonymous" +) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 29b55fe..8c4eaa2 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -100,9 +100,44 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl return } - // Store the authenticated identity and admin flag in context + // Store the authenticated identity and admin flag in context. + // Bundle 1 Phase 0: legacy UserKey + AdminKey for back-compat. + // Bundle 1 Phase 3: new ActorIDKey + ActorTypeKey + TenantIDKey + // for RBAC-aware downstream code (RequirePermission, etc.). ctx := context.WithValue(r.Context(), UserKey{}, matched.name) ctx = context.WithValue(ctx, AdminKey{}, matched.admin) + ctx = context.WithValue(ctx, ActorIDKey{}, matched.name) + ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey) + ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// NewDemoModeAuth returns a middleware that injects the synthetic +// `actor-demo-anon` identity into every request context. Used when +// CERTCTL_AUTH_TYPE=none is configured (the demo path) so that +// RBAC-gated handlers see an admin-equivalent caller without operator +// configuration. +// +// The synthetic actor is seeded by migration 000029_rbac.up.sql with +// the admin role at global scope, so RequirePermission resolves +// every gated request as an admin. The reserved-actor guard in the +// service layer prevents the API from accidentally mutating this +// actor's role assignments. +// +// Production deployments MUST NOT use this middleware. The cmd/server +// startup wires it only when CERTCTL_AUTH_TYPE=none is explicitly +// configured. +func NewDemoModeAuth() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, UserKey{}, DemoAnonActorID) + ctx = context.WithValue(ctx, AdminKey{}, true) + ctx = context.WithValue(ctx, ActorIDKey{}, DemoAnonActorID) + ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAnonymous) + ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/auth/protocol_endpoints.go b/internal/auth/protocol_endpoints.go new file mode 100644 index 0000000..7332c9f --- /dev/null +++ b/internal/auth/protocol_endpoints.go @@ -0,0 +1,49 @@ +package auth + +import "strings" + +// ProtocolEndpointPrefixes lists the URL path prefixes that authenticate +// via the protocol itself rather than via certctl's Bearer / cookie +// stack. Bundle 1 Phase 3 uses this allowlist as the explicit "do NOT +// wrap with RequirePermission" set: the RBAC middleware applies only to +// admin handlers replacing legacy IsAdmin checks plus any new +// permission-gated routes; the endpoints below keep their existing +// protocol-level auth. +// +// Adding a new protocol endpoint that doesn't take a Bearer token MUST +// also add the prefix here and a parallel test in Phase 12 asserting +// the route is unwrapped. +// +// Per the Phase 3 audit: +// +// ACME server : /acme/profile//* + /acme/* (JWS-signed, RFC 8555). +// SCEP server : /scep (challenge password + +// signed CSR, RFC 8894). +// EST server : /.well-known/est/* (mTLS client cert, +// RFC 7030). +// OCSP responder : /.well-known/pki/ocsp (RFC 6960, public). +// CRL distrib. : /.well-known/pki/crl/* (RFC 5280, public). +// +// Plus the existing public-route bypass list at internal/api/router +// (router.go:69-72): /health, /ready, /api/v1/auth/info. Those bypass +// EVERY middleware stack, not just RBAC, so they're not in this +// allowlist; they're handled in router.go directly. +var ProtocolEndpointPrefixes = []string{ + "/acme", + "/scep", + "/.well-known/est", + "/.well-known/pki/ocsp", + "/.well-known/pki/crl", +} + +// IsProtocolEndpoint reports whether the request path is in the +// "do not gate" allowlist. Phase 3 RequirePermission check bails out +// early for these paths so the protocol surface is preserved. +func IsProtocolEndpoint(path string) bool { + for _, p := range ProtocolEndpointPrefixes { + if path == p || strings.HasPrefix(path, p+"/") { + return true + } + } + return false +} diff --git a/internal/auth/require_permission.go b/internal/auth/require_permission.go new file mode 100644 index 0000000..0605ae8 --- /dev/null +++ b/internal/auth/require_permission.go @@ -0,0 +1,126 @@ +package auth + +import ( + "context" + "errors" + "log/slog" + "net/http" +) + +// PermissionChecker is the dependency the RequirePermission middleware +// expects. internal/service/auth.Authorizer satisfies this interface; +// tests can supply an in-memory fake. +// +// scopeID is nil for global checks; non-nil for per-resource checks +// (e.g. per-profile or per-issuer scoping). scopeType matches +// internal/domain/auth.ScopeType ("global", "profile", "issuer"). +type PermissionChecker interface { + CheckPermission( + ctx context.Context, + actorID string, + actorType string, + tenantID string, + permission string, + scopeType string, + scopeID *string, + ) (bool, error) +} + +// ScopeFunc extracts the scope (type, id) from the request. A nil +// ScopeFunc means "global scope" (the most common case for admin-class +// gates like bulk revocation, intermediate-CA management, etc.). +type ScopeFunc func(r *http.Request) (scopeType string, scopeID *string) + +// RequirePermission returns a middleware that gates the wrapped handler +// behind the named permission. Returns 401 when no actor is in +// context, 403 when the actor exists but lacks the permission, 500 on +// repository errors. Skips the gate entirely for protocol-level +// endpoints in ProtocolEndpointPrefixes (ACME / SCEP / EST / OCSP / CRL). +// +// The permission name MUST exist in +// internal/domain/auth.CanonicalPermissions (enforced indirectly via +// the seed migration; an unknown permission name will simply return +// 403 because no role grant references it). +func RequirePermission(checker PermissionChecker, permission string, scope ScopeFunc) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Protocol endpoints keep their existing protocol-level + // auth; the RBAC gate doesn't apply. + if IsProtocolEndpoint(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + ctx := r.Context() + actorID := GetActorID(ctx) + if actorID == "" { + writeJSONError(w, http.StatusUnauthorized, "Authentication required") + return + } + + actorType := GetActorType(ctx) + if actorType == "" { + // Legacy callers that only set UserKey: assume APIKey. + // Bundle 2's OIDC middleware sets the type explicitly + // to "User"; the demo-mode middleware sets it to + // "Anonymous"; the API-key middleware (Phase 3 + // extension) sets it to "APIKey". + actorType = ActorTypeAPIKey + } + + scopeType := "global" + var scopeID *string + if scope != nil { + scopeType, scopeID = scope(r) + } + + tenantID := GetTenantID(ctx) + ok, err := checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID) + if err != nil { + slog.ErrorContext(ctx, "RBAC check failed", + "permission", permission, + "actor_id", actorID, + "error", err, + ) + writeJSONError(w, http.StatusInternalServerError, "Internal error") + return + } + if !ok { + writeJSONError(w, http.StatusForbidden, "Insufficient permissions") + return + } + next.ServeHTTP(w, r) + }) + } +} + +// HasPermission is a convenience for handlers that need to check a +// permission imperatively (e.g. branch behaviour without 403'ing the +// whole request). Returns (true, nil) when granted, (false, nil) when +// denied, (false, err) on repository failure. Skips the protocol- +// endpoint allowlist. +func HasPermission(ctx context.Context, checker PermissionChecker, permission string, scopeType string, scopeID *string) (bool, error) { + actorID := GetActorID(ctx) + if actorID == "" { + return false, ErrNoActor + } + actorType := GetActorType(ctx) + if actorType == "" { + actorType = ActorTypeAPIKey + } + tenantID := GetTenantID(ctx) + return checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID) +} + +// ErrNoActor is returned by HasPermission when the request context has +// no actor identity. Handler code typically translates this to HTTP +// 401. +var ErrNoActor = errors.New("auth: no actor in context") + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + // Match the existing middleware error shape so handler tests that + // assert on the body text continue to work. + _, _ = w.Write([]byte(`{"error":"` + msg + `"}`)) +} diff --git a/internal/auth/require_permission_test.go b/internal/auth/require_permission_test.go new file mode 100644 index 0000000..d698769 --- /dev/null +++ b/internal/auth/require_permission_test.go @@ -0,0 +1,233 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeChecker implements PermissionChecker for unit tests. The check +// function controls the result; tests pin specific behaviour via +// closures. +type fakeChecker struct { + check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) +} + +func (f *fakeChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) { + return f.check(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID) +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func TestRequirePermission_NoActorReturns401(t *testing.T) { + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + t.Fatalf("checker should not be called when no actor in context") + return false, nil + }} + mw := RequirePermission(checker, "cert.read", nil) + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("no actor should yield 401; got %d", rec.Code) + } +} + +func TestRequirePermission_GrantedActorReaches200(t *testing.T) { + checker := &fakeChecker{check: func(_ context.Context, actorID, actorType, _, perm, _ string, _ *string) (bool, error) { + if actorID != "alice" { + t.Errorf("actor id = %q, want alice", actorID) + } + if actorType != ActorTypeAPIKey { + t.Errorf("actor type = %q, want %q", actorType, ActorTypeAPIKey) + } + if perm != "cert.read" { + t.Errorf("perm = %q, want cert.read", perm) + } + return true, nil + }} + mw := RequirePermission(checker, "cert.read", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil) + req = req.WithContext(WithActor(req.Context(), "alice")) + req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice")) + req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey)) + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("granted actor should reach handler 200; got %d", rec.Code) + } +} + +func TestRequirePermission_DeniedActorReturns403(t *testing.T) { + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + return false, nil + }} + mw := RequirePermission(checker, "cert.delete", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil) + req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "bob")) + req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey)) + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("denied actor should yield 403; got %d", rec.Code) + } +} + +func TestRequirePermission_CheckerErrorReturns500(t *testing.T) { + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + return false, errors.New("database fell over") + }} + mw := RequirePermission(checker, "cert.read", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil) + req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice")) + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("checker error should yield 500; got %d", rec.Code) + } +} + +func TestRequirePermission_ProtocolEndpointBypassesGate(t *testing.T) { + gateChecks := 0 + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) { + gateChecks++ + return false, nil + }} + mw := RequirePermission(checker, "cert.read", nil) + for _, p := range []string{ + "/acme/profile/corp/new-order", + "/scep", + "/.well-known/est/cacerts", + "/.well-known/pki/ocsp", + "/.well-known/pki/crl/ca.crl", + } { + req := httptest.NewRequest(http.MethodGet, p, nil) + // Deliberately no actor: protocol endpoints must reach the + // handler regardless of context state. + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("protocol endpoint %s should bypass gate; got %d", p, rec.Code) + } + } + if gateChecks != 0 { + t.Errorf("checker should be called zero times for protocol endpoints; got %d", gateChecks) + } +} + +func TestRequirePermission_ScopeFnExtractsResourceID(t *testing.T) { + captured := struct { + scopeType string + scopeID *string + }{} + checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, st string, sid *string) (bool, error) { + captured.scopeType = st + captured.scopeID = sid + return true, nil + }} + scope := func(r *http.Request) (string, *string) { + id := r.URL.Query().Get("profile") + return "profile", &id + } + mw := RequirePermission(checker, "profile.edit", scope) + req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/p-corp?profile=p-corp", nil) + req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice")) + rec := httptest.NewRecorder() + mw(okHandler()).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("scoped grant should pass; got %d", rec.Code) + } + if captured.scopeType != "profile" { + t.Errorf("scope type = %q, want profile", captured.scopeType) + } + if captured.scopeID == nil || *captured.scopeID != "p-corp" { + t.Errorf("scope id = %v, want p-corp", captured.scopeID) + } +} + +func TestIsProtocolEndpoint_PrefixesOnly(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"/acme", true}, + {"/acme/profile/corp/new-order", true}, + {"/scep", true}, + // Query strings live in r.URL.RawQuery; r.URL.Path stays + // just `/scep`, so callers always pass the path-only form. + {"/.well-known/est/cacerts", true}, + {"/.well-known/pki/ocsp", true}, + {"/.well-known/pki/crl/ca.crl", true}, + {"/api/v1/certificates", false}, + {"/api/v1/auth/me", false}, + {"/health", false}, // bypassed at the router level, NOT by RBAC. + {"/acmedotcom", false}, + {"/scepfake", false}, + } + for _, tc := range cases { + if got := IsProtocolEndpoint(tc.path); got != tc.want { + t.Errorf("IsProtocolEndpoint(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +func TestNewDemoModeAuth_InjectsSyntheticActor(t *testing.T) { + mw := NewDemoModeAuth() + var captured struct { + actorID, actorType, user string + isAdmin bool + } + handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + captured.actorID = GetActorID(r.Context()) + captured.actorType = GetActorType(r.Context()) + captured.user = GetUser(r.Context()) + captured.isAdmin = IsAdmin(r.Context()) + })) + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if captured.actorID != DemoAnonActorID { + t.Errorf("actor id = %q, want %q", captured.actorID, DemoAnonActorID) + } + if captured.actorType != ActorTypeAnonymous { + t.Errorf("actor type = %q, want %q", captured.actorType, ActorTypeAnonymous) + } + if captured.user != DemoAnonActorID { + t.Errorf("legacy UserKey = %q, want %q (back-compat)", captured.user, DemoAnonActorID) + } + if !captured.isAdmin { + t.Errorf("legacy AdminKey should be true in demo mode (back-compat for IsAdmin handlers)") + } +} + +func TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys(t *testing.T) { + mw := NewAuthWithNamedKeys([]NamedAPIKey{ + {Name: "alice", Key: "ALICE_KEY", Admin: true}, + }) + var captured struct { + actorID, actorType, tenantID string + } + handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + captured.actorID = GetActorID(r.Context()) + captured.actorType = GetActorType(r.Context()) + captured.tenantID = GetTenantID(r.Context()) + })) + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil) + req.Header.Set("Authorization", "Bearer ALICE_KEY") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if captured.actorID != "alice" { + t.Errorf("Phase 3 actor id = %q, want alice", captured.actorID) + } + if captured.actorType != ActorTypeAPIKey { + t.Errorf("Phase 3 actor type = %q, want %q", captured.actorType, ActorTypeAPIKey) + } + if captured.tenantID != DefaultTenantID { + t.Errorf("Phase 3 tenant id = %q, want %q", captured.tenantID, DefaultTenantID) + } +}