diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef9d43..a0a2d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## v2.1.0 — Auth Bundle 1: RBAC primitive ⚠️ + +> **SECURITY: AUDIT YOUR API KEYS.** +> +> Bundle 1 ships role-based authorization. Every existing API key +> configured via `CERTCTL_API_KEYS_NAMED` (or the legacy +> `CERTCTL_AUTH_SECRET`) is mapped to the **r-admin role on the first +> upgrade boot** so existing automation keeps working unchanged. Most +> keys do NOT need full admin power; downgrade them before tagging +> the next release. +> +> Recommended post-upgrade flow: +> +> ```bash +> # 1. List every key with its current role: +> certctl-cli auth keys list +> +> # 2. Walk an interactive prompt that downgrades each key: +> certctl-cli auth keys scope-down +> +> # 3. Or get a heuristic suggestion based on 30 days of audit history: +> certctl-cli auth keys scope-down --suggest +> certctl-cli auth keys scope-down --suggest --apply # applies the suggestion +> +> # 4. Or drive scope-down from a JSON config (Helm post-upgrade hook): +> certctl-cli auth keys scope-down --non-interactive ./scope-down.json +> ``` +> +> The synthetic `actor-demo-anon` actor (used when +> `CERTCTL_AUTH_TYPE=none` is configured) is system-managed and +> excluded from the prompt loop. + +What else changed in v2.1.0: + +- **RBAC primitive shipped.** `tenants`, `roles`, `permissions`, + `role_permissions`, `actor_roles` tables (migration 000029); 33-permission + canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`, + `agent`, `mcp`, `cli`, `auditor`); per-handler permission gates via + `auth.RequirePermission` middleware (replaces the legacy + `IsAdmin` boolean check on the 5 admin-only handlers). +- **Day-0 admin bootstrap.** Set `CERTCTL_BOOTSTRAP_TOKEN` on a fresh + deploy and POST a single curl call against `/v1/auth/bootstrap` to + mint the first admin API key; one-shot, never logged, and locks + closed once any admin actor exists. Migration 000031 ships the + `api_keys` table that stores the SHA-256 hash; the plaintext is + shown in the response body once and never persisted. +- **Auditor role split.** New `auditor` role holds only `audit.read` + + `audit.export`. Compliance reviewers can read the audit trail + without holding mutation power. Migration 000032 adds + `audit_events.event_category` so auditors can filter to + authentication-related events specifically. +- **`/v1/auth/check` enrichment.** Response now includes the actor's + standing roles and effective permissions, so the GUI gates + affordances from a single fetch on app boot. +- **OpenAPI catalogues every new route.** Every Bundle 1 endpoint + ships with an `operationId`; the parity test guards against drift. +- **Bundle 2 (OIDC + sessions) starts after Bundle 1 lands on + master.** Roadmap entry remains in `cowork/auth-bundle-2-prompt.md`. + +Migration ordering, idempotency, and downgrade are documented in +`docs/migration/api-keys-to-rbac.md`. + ## v2.0.68 — Image registry path changed ⚠️ > **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever — only the container-registry path changed. diff --git a/api/openapi.yaml b/api/openapi.yaml index 538b262..453f7d1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -220,6 +220,80 @@ paths: # lifecycle, `auth.key.*` for key management. Read endpoints require # `auth.role.list`. The /v1/auth/me endpoint has no permission gate # (every authenticated caller can read their own permissions). + /api/v1/auth/bootstrap: + get: + tags: [Auth] + summary: Probe whether the day-0 bootstrap endpoint is callable + description: | + Returns `{available: true}` when CERTCTL_BOOTSTRAP_TOKEN is set + AND no admin-roled actor exists yet; otherwise `{available: false}`. + Auth-exempt because it serves the GUI / install one-liner before + the first admin key has been minted. Bundle 1 Phase 6. + security: [] + operationId: getAuthBootstrap + responses: + "200": + description: Bootstrap availability + content: + application/json: + schema: + type: object + required: [available] + properties: + available: + type: boolean + post: + tags: [Auth] + summary: Mint the first admin API key from a one-shot bootstrap token + description: | + Operator POSTs the CERTCTL_BOOTSTRAP_TOKEN value plus the desired + admin-key name. Returns the freshly minted plaintext key value + once; the server stores only the SHA-256 hash. Subsequent calls + return 410 Gone (the strategy is one-shot AND the admin-existence + probe re-closes the door once the new admin lands). Auth-exempt + because the endpoint authenticates via the bootstrap token + itself. Bundle 1 Phase 6. + security: [] + operationId: postAuthBootstrap + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, actor_name] + properties: + token: + type: string + description: The CERTCTL_BOOTSTRAP_TOKEN value (constant-time compared server-side). + actor_name: + type: string + description: 3-64 chars, lowercase alphanumeric + hyphen + underscore. + pattern: "^[a-z0-9][a-z0-9_-]{2,63}$" + responses: + "201": + description: Admin key minted + content: + application/json: + schema: + type: object + required: [actor_id, api_key_id, key_value, created_at, message] + properties: + actor_id: { type: string } + api_key_id: { type: string } + key_value: + type: string + description: The plaintext API key. Capture this — it is shown only once. + created_at: { type: string, format: date-time } + message: { type: string } + "400": { description: Invalid actor_name or malformed body } + "401": { description: Bootstrap token mismatch } + "410": + description: | + Endpoint disabled. Either CERTCTL_BOOTSTRAP_TOKEN is unset, + an admin actor already exists, or the strategy was already + consumed by a successful prior call. + /api/v1/auth/me: get: tags: [Auth] @@ -462,6 +536,43 @@ paths: "403": { description: Forbidden } "404": { description: Role or permission grant not found } + /api/v1/auth/keys: + get: + tags: [Auth] + summary: List actors with role grants in the active tenant + description: | + Returns every distinct (actor_id, actor_type) pair in the + tenant that holds at least one role grant. Bundle 1 Phase 7 + ships this so the CLI's `auth keys list` and scope-down helper + can enumerate the operator-key population without joining + against the env-var-loaded namedKeys directly. Permission + `auth.role.list`. + operationId: listAuthKeys + responses: + "200": + description: Actor list with role assignments + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + type: object + required: [actor_id, actor_type, tenant_id, role_ids] + properties: + actor_id: { type: string } + actor_type: + type: string + enum: [User, System, Agent, APIKey, Anonymous] + tenant_id: { type: string } + role_ids: + type: array + items: { type: string } + "401": { description: Unauthorized } + "403": { description: Forbidden } + /api/v1/auth/keys/{id}/roles: post: tags: [Auth] @@ -3057,10 +3168,22 @@ paths: get: tags: [Audit] summary: List audit events + description: | + Bundle 1 Phase 8 adds the optional `category` query parameter + for auditor-role filtering. Allowed values: `cert_lifecycle` + (cert/agent/deployment events), `auth` (role/key/bootstrap + mutations), `config` (issuer/target/settings edits). Omitting + the parameter returns every category. operationId: listAuditEvents parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/per_page" + - in: query + name: category + schema: + type: string + enum: [cert_lifecycle, auth, config] + description: Filter to events of this event_category. (Bundle 1 Phase 8) responses: "200": description: Paginated list of audit events @@ -3075,6 +3198,8 @@ paths: type: array items: $ref: "#/components/schemas/AuditEvent" + "400": + description: Invalid `category` value "500": $ref: "#/components/responses/InternalError" @@ -5699,6 +5824,13 @@ components: timestamp: type: string format: date-time + event_category: + type: string + enum: [cert_lifecycle, auth, config] + description: | + Bundle 1 Phase 8: classifies the event for auditor-role + filtering. Empty / absent on rows from pre-Phase-8 + deployments (the migration backfills "cert_lifecycle"). # ─── Notifications ─────────────────────────────────────────────── NotificationType: diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 443c22b..15011ae 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -427,10 +427,12 @@ func handleAuthPermissions(client *cli.Client, args []string) error { func handleAuthKeys(client *cli.Client, args []string) error { if len(args) == 0 { - fmt.Fprintf(os.Stderr, "usage: auth keys [...]\n") + fmt.Fprintf(os.Stderr, "usage: auth keys [...]\n") return nil } switch args[0] { + case "list": + return client.AuthListKeys() case "assign": // auth keys assign --role if len(args) < 4 || args[2] != "--role" { @@ -445,8 +447,42 @@ func handleAuthKeys(client *cli.Client, args []string) error { return nil } return client.AuthRevokeRoleFromKey(args[1], args[3]) + case "scope-down": + // Bundle 1 Phase 7 — interactive (default), --non-interactive + // , or --suggest [--apply]. + return handleAuthKeysScopeDown(client, args[1:]) default: fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0]) return nil } } + +// handleAuthKeysScopeDown dispatches the three scope-down modes: +// +// auth keys scope-down → interactive +// auth keys scope-down --non-interactive → JSON-driven +// auth keys scope-down --suggest [--apply] → audit-driven suggestions +func handleAuthKeysScopeDown(client *cli.Client, args []string) error { + if len(args) == 0 { + return client.AuthScopeDown() + } + switch args[0] { + case "--non-interactive": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "usage: auth keys scope-down --non-interactive \n") + return nil + } + return client.AuthScopeDownNonInteractive(args[1]) + case "--suggest": + apply := false + for _, a := range args[1:] { + if a == "--apply" { + apply = true + } + } + return client.AuthScopeDownSuggest(apply) + default: + fmt.Fprintf(os.Stderr, "unknown scope-down flag: %s\n", args[0]) + return nil + } +} diff --git a/cmd/server/auth_backfill.go b/cmd/server/auth_backfill.go index a59349c..e44b53c 100644 --- a/cmd/server/auth_backfill.go +++ b/cmd/server/auth_backfill.go @@ -2,13 +2,60 @@ package main import ( "context" + "fmt" "log/slog" + "strings" "github.com/certctl-io/certctl/internal/auth" + "github.com/certctl-io/certctl/internal/config" "github.com/certctl-io/certctl/internal/domain" authdomain "github.com/certctl-io/certctl/internal/domain/auth" ) +// assembleNamedAPIKeys translates the operator's CERTCTL_API_KEYS_NAMED +// env-var (preferred) or CERTCTL_AUTH_SECRET (legacy) into the +// auth.NamedAPIKey slice the rest of the boot path consumes. +// +// Authentication unification (M-002): every authenticated request now +// carries a named actor in the request context so audit events record +// the real key identity instead of the hardcoded "api-key-user" +// string. Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For +// backward compatibility CERTCTL_AUTH_SECRET is synthesized into +// legacy-key-N entries with Admin=false. +func assembleNamedAPIKeys(cfg *config.Config, logger *slog.Logger) []auth.NamedAPIKey { + if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone { + return nil + } + var out []auth.NamedAPIKey + for _, nk := range cfg.Auth.NamedKeys { + out = append(out, auth.NamedAPIKey{ + Name: nk.Name, + Key: nk.Key, + Admin: nk.Admin, + }) + } + if len(out) == 0 && cfg.Auth.Secret != "" { + idx := 0 + for _, p := range strings.Split(cfg.Auth.Secret, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, auth.NamedAPIKey{ + Name: fmt.Sprintf("legacy-key-%d", idx), + Key: p, + Admin: false, + }) + idx++ + } + if len(out) > 0 && logger != nil { + logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating", + "synthesized_keys", len(out)) + } + } + return out +} + // actorRoleGranter is the narrow interface backfillNamedKeyActorRoles // needs from the postgres ActorRoleRepository. Pulled out so the unit // test can inject a fake without spinning up the full repo / DB. diff --git a/cmd/server/main.go b/cmd/server/main.go index 5e2ecbc..24078cf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "github.com/certctl-io/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/auth" + "github.com/certctl-io/certctl/internal/auth/bootstrap" "github.com/certctl-io/certctl/internal/config" discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm" discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv" @@ -264,11 +265,68 @@ func main() { authRoleRepo := postgres.NewRoleRepository(db) authPermRepo := postgres.NewPermissionRepository(db) authActorRoleRepo := postgres.NewActorRoleRepository(db) + authAPIKeyRepo := postgres.NewAPIKeyRepository(db) authAuthorizer := authsvc.NewAuthorizer(authActorRoleRepo) // authCheckerAdapter bridges authsvc.Authorizer (typed-string args) // to the auth.PermissionChecker interface (plain-string args) so // internal/auth doesn't have to import internal/service/auth. authCheckerAdapter := authPermissionCheckerAdapter{a: authAuthorizer} + + // Bundle 1 Phase 6 — parse env-var named API keys + assemble the + // runtime keystore + wire the bootstrap service. The keystore + + // bootstrap handler must exist before the HandlerRegistry is + // constructed below; the auth middleware that reads from the same + // keystore is wired further down (next to the rest of the + // middleware stack) but holds a reference to the same keystore so + // runtime additions from bootstrap propagate without restart. + // + // boot-path operations use context.Background() because the long- + // lived request context isn't constructed until later in main(); + // this matches the convention used by other one-shot setup calls + // in this section (issuerService.SeedFromEnvVars, etc.). + bootCtx := context.Background() + namedKeys := assembleNamedAPIKeys(cfg, logger) + backfillNamedKeyActorRoles(bootCtx, authActorRoleRepo, namedKeys, logger) + authKeyStore := auth.NewMutableKeyStore(namedKeys) + if persistedKeys, err := authAPIKeyRepo.List(bootCtx, authdomainAlias.DefaultTenantID); err == nil { + for _, pk := range persistedKeys { + authKeyStore.AddHashed(pk.Name, pk.KeyHash, pk.Admin) + } + if len(persistedKeys) > 0 { + logger.Info("loaded persisted api_keys into runtime keystore", + "count", len(persistedKeys)) + } + } else { + logger.Warn("api_keys boot loader failed; bootstrap-minted keys will not authenticate until next restart that succeeds", + "err", err) + } + bootstrapStrategy := bootstrap.NewEnvTokenStrategy( + cfg.Auth.BootstrapToken, + func(ctx context.Context) (bool, error) { + return authActorRoleRepo.AdminExists(ctx, authdomainAlias.DefaultTenantID) + }, + ) + bootstrapService := bootstrap.NewService( + bootstrapStrategy, + authAPIKeyRepo, + authActorRoleRepo, + auditService, + authKeyStore, + auth.HashAPIKey, + ) + if cfg.Auth.BootstrapToken != "" { + // Honour the prompt's "warn at startup if token set + admin + // exists" requirement. The strategy re-probes on every Validate + // so this boot-time warning is purely informational. + if exists, probeErr := authActorRoleRepo.AdminExists(bootCtx, authdomainAlias.DefaultTenantID); probeErr == nil && exists { + logger.Warn("CERTCTL_BOOTSTRAP_TOKEN set but admin actors already exist; bootstrap endpoint will return 410 Gone — unset the env var to silence this warning") + } else if probeErr != nil { + logger.Warn("CERTCTL_BOOTSTRAP_TOKEN admin-existence probe failed at startup; behaviour will be determined by the live probe at request time", "err", probeErr) + } else { + logger.Info("bootstrap endpoint enabled — POST /api/v1/auth/bootstrap to mint the first admin key (one-shot)") + } + } + bootstrapHandler := handler.NewBootstrapHandler(bootstrapService) policyService := service.NewPolicyService(policyRepo, auditService) policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter // G-1: RenewalPolicyService — distinct from PolicyService (compliance rules). @@ -1001,6 +1059,10 @@ func main() { authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService), authCheckerAdapter, ), + // Bundle 1 Phase 6 — bootstrap day-0 admin endpoint. The + // service is wired above; handler is auth-exempt at the + // router (gated by the bootstrap.Strategy itself). + Bootstrap: bootstrapHandler, // Checker is the load-bearing auth.PermissionChecker that // auth.RequirePermission middleware uses to gate the legacy admin // handlers (Bundle 1 Phase 3.5: bulk_revocation, admin_crl_cache, @@ -1523,75 +1585,19 @@ func main() { // Build middleware stack. // - // Authentication unification (M-002): every authenticated request now - // carries a named actor in the request context so audit events record - // the real key identity instead of the hardcoded "api-key-user" string. - // Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward - // compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N - // entries with Admin=false. - var namedKeys []auth.NamedAPIKey - if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone { - // Translate typed config.NamedAPIKey -> auth.NamedAPIKey. The - // two structs are field-compatible but live in different packages to - // preserve the config→middleware dependency direction. - for _, nk := range cfg.Auth.NamedKeys { - namedKeys = append(namedKeys, auth.NamedAPIKey{ - Name: nk.Name, - Key: nk.Key, - Admin: nk.Admin, - }) - } - // Back-compat: if no named keys but legacy Secret is configured, - // synthesize named entries so the audit trail still attributes the - // action (instead of falling back to "api-key-user" / "anonymous"). - if len(namedKeys) == 0 && cfg.Auth.Secret != "" { - parts := strings.Split(cfg.Auth.Secret, ",") - idx := 0 - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - namedKeys = append(namedKeys, auth.NamedAPIKey{ - Name: fmt.Sprintf("legacy-key-%d", idx), - Key: p, - Admin: false, - }) - idx++ - } - if len(namedKeys) > 0 { - logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating", - "synthesized_keys", len(namedKeys)) - } - } - } - // Bundle 1 Phase 3 closure (C2): backfill actor_roles rows for every - // CERTCTL_API_KEYS_NAMED entry (and the legacy CERTCTL_AUTH_SECRET - // synthesized fallbacks) so RBAC checks have a row to match against. - // Without this, named keys would land on a Phase-3 actor context - // that authorizes every request through the legacy in-handler - // auth.IsAdmin path but fails every Phase-3.5 rbacGate (no - // actor_roles row → empty EffectivePermissions → 403). The helper - // lives in cmd/server/auth_backfill.go so the role-mapping invariant - // is pinned by a focused unit test without dragging in the full - // server bootstrap path. - backfillNamedKeyActorRoles(ctx, authActorRoleRepo, namedKeys, logger) - // Bundle 1 Phase 3 closure (C1): when CERTCTL_AUTH_TYPE=none the - // legacy NewAuthWithNamedKeys returns a no-op pass-through, which - // would leave ActorIDKey / ActorTypeKey / TenantIDKey unpopulated - // in context. Phase 3.5's rbacGate + Phase 4's RBAC handlers all - // require an actor in context (or they 401), so demo mode would be - // completely broken. NewDemoModeAuth injects the synthetic - // `actor-demo-anon` actor seeded by migration 000029, which holds - // the admin role at global scope; the demo + 5 examples in - // examples/*/docker-compose.yml continue to work end-to-end. + // Bundle 1 Phase 6: namedKeys + authKeyStore + bootstrap service + // are now constructed earlier (right after the auth repos) so the + // HandlerRegistry can wire the bootstrap handler. The auth + // middleware below reads from the same authKeyStore reference, so + // runtime additions from bootstrap propagate without restart. var authMiddleware func(http.Handler) http.Handler switch config.AuthType(cfg.Auth.Type) { case config.AuthTypeNone: authMiddleware = auth.NewDemoModeAuth() default: - authMiddleware = auth.NewAuthWithNamedKeys(namedKeys) + authMiddleware = auth.NewAuthWithKeyStore(authKeyStore) } + _ = bootstrapHandler // referenced by HandlerRegistry above corsMiddleware := middleware.NewCORS(middleware.CORSConfig{ AllowedOrigins: cfg.CORS.AllowedOrigins, }) diff --git a/internal/api/handler/audit.go b/internal/api/handler/audit.go index afe430d..f0f8d06 100644 --- a/internal/api/handler/audit.go +++ b/internal/api/handler/audit.go @@ -14,6 +14,12 @@ import ( type AuditService interface { ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, error) + // ListAuditEventsByCategory (Bundle 1 Phase 8) returns audit + // rows whose event_category column matches eventCategory. + // eventCategory is one of "cert_lifecycle", "auth", "config"; + // empty string returns all categories. Used by the auditor role + // (filtered to "auth" via /v1/audit?category=auth). + ListAuditEventsByCategory(ctx context.Context, eventCategory string, page, perPage int) ([]domain.AuditEvent, int64, error) } // AuditHandler handles HTTP requests for audit event operations. @@ -27,7 +33,12 @@ func NewAuditHandler(svc AuditService) AuditHandler { } // ListAuditEvents lists audit events. -// GET /api/v1/audit?page=1&per_page=50 +// GET /api/v1/audit?page=1&per_page=50&category=auth +// +// Bundle 1 Phase 8 adds the optional `category` query parameter for +// auditor-role filtering. Allowed values: cert_lifecycle, auth, config. +// Unknown values surface 400 so misuse is caught loud (instead of +// silently returning all rows). func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { Error(w, http.StatusMethodNotAllowed, "Method not allowed") @@ -49,8 +60,29 @@ func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) { perPage = parsed } } + category := query.Get("category") + if category != "" { + switch category { + case domain.EventCategoryCertLifecycle, domain.EventCategoryAuth, domain.EventCategoryConfig: + // ok + default: + ErrorWithRequestID(w, http.StatusBadRequest, + "Invalid category — allowed: cert_lifecycle, auth, config", + requestID) + return + } + } - events, total, err := h.svc.ListAuditEvents(r.Context(), page, perPage) + var ( + events []domain.AuditEvent + total int64 + err error + ) + if category != "" { + events, total, err = h.svc.ListAuditEventsByCategory(r.Context(), category, page, perPage) + } else { + events, total, err = h.svc.ListAuditEvents(r.Context(), page, perPage) + } if err != nil { ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID) return diff --git a/internal/api/handler/audit_category_test.go b/internal/api/handler/audit_category_test.go new file mode 100644 index 0000000..bcb41a3 --- /dev/null +++ b/internal/api/handler/audit_category_test.go @@ -0,0 +1,157 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/certctl-io/certctl/internal/domain" +) + +// ============================================================================= +// Bundle 1 Phase 8 — audit category-filter HTTP behaviour. +// ============================================================================= + +// TestListAuditEvents_Phase8_CategoryFilterDispatchesToService pins the +// happy-path: ?category=auth routes through ListAuditEventsByCategory +// with the right argument. +func TestListAuditEvents_Phase8_CategoryFilterDispatchesToService(t *testing.T) { + var capturedCategory string + mockSvc := &mockAuditService{ + listByCatFunc: func(category string, _, _ int) ([]domain.AuditEvent, int64, error) { + capturedCategory = category + return []domain.AuditEvent{ + {ID: "audit-1", Action: "auth.role.assign", EventCategory: domain.EventCategoryAuth}, + }, 1, nil + }, + } + h := NewAuditHandler(mockSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if capturedCategory != "auth" { + t.Errorf("captured category = %q, want auth", capturedCategory) + } +} + +// TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents pins +// that the legacy unfiltered path still routes through ListAuditEvents +// (preserves back-compat). +func TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents(t *testing.T) { + listCalled := false + listByCatCalled := false + mockSvc := &mockAuditService{ + listFunc: func(_, _ int) ([]domain.AuditEvent, int64, error) { + listCalled = true + return nil, 0, nil + }, + listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) { + listByCatCalled = true + return nil, 0, nil + }, + } + h := NewAuditHandler(mockSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + if !listCalled { + t.Errorf("ListAuditEvents not called for unfiltered request") + } + if listByCatCalled { + t.Errorf("ListAuditEventsByCategory called unexpectedly for unfiltered request") + } +} + +// TestListAuditEvents_Phase8_RejectsUnknownCategory pins the 400 surface +// for misuse. Allowed values are exactly cert_lifecycle/auth/config; +// anything else surfaces a clear error rather than silently returning +// every row. +func TestListAuditEvents_Phase8_RejectsUnknownCategory(t *testing.T) { + mockSvc := &mockAuditService{} + h := NewAuditHandler(mockSvc) + for _, bad := range []string{"agent", "AUTH", "auth%20", "system"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+bad, nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("category=%q got status %d, want 400", bad, rec.Code) + } + } +} + +// TestListAuditEvents_Phase8_AcceptsAllThreeCategories pins that each of +// the three documented enum values dispatches without a 400. +func TestListAuditEvents_Phase8_AcceptsAllThreeCategories(t *testing.T) { + mockSvc := &mockAuditService{ + listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) { + return nil, 0, nil + }, + } + h := NewAuditHandler(mockSvc) + for _, cat := range []string{ + domain.EventCategoryCertLifecycle, + domain.EventCategoryAuth, + domain.EventCategoryConfig, + } { + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+cat, nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("category=%s got status %d, want 200", cat, rec.Code) + } + } +} + +// TestListAuditEvents_Phase8_CategoryAndPageCombine confirms the query +// parser respects both the page and category params concurrently. +func TestListAuditEvents_Phase8_CategoryAndPageCombine(t *testing.T) { + var capturedCategory string + var capturedPage int + mockSvc := &mockAuditService{ + listByCatFunc: func(category string, page, _ int) ([]domain.AuditEvent, int64, error) { + capturedCategory = category + capturedPage = page + return nil, 0, nil + }, + } + h := NewAuditHandler(mockSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth&page=3", nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + if capturedCategory != "auth" || capturedPage != 3 { + t.Errorf("captured (cat=%q page=%d), want (auth, 3)", capturedCategory, capturedPage) + } +} + +// TestListAuditEvents_Phase8_ResponseSurfacesEventCategory confirms the +// JSON output carries the event_category field for downstream auditors. +func TestListAuditEvents_Phase8_ResponseSurfacesEventCategory(t *testing.T) { + mockSvc := &mockAuditService{ + listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) { + return []domain.AuditEvent{ + {ID: "a1", Action: "auth.role.assign", EventCategory: "auth"}, + {ID: "a2", Action: "issuer.edit", EventCategory: "config"}, + }, 2, nil + }, + } + h := NewAuditHandler(mockSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil) + rec := httptest.NewRecorder() + h.ListAuditEvents(rec, req) + var resp struct { + Data []domain.AuditEvent `json:"data"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data) != 2 || resp.Data[0].EventCategory != "auth" || resp.Data[1].EventCategory != "config" { + t.Errorf("event_category not surfaced in JSON: %+v", resp.Data) + } +} + +var _ = context.Background // keep import even if other tests strip it diff --git a/internal/api/handler/audit_handler_test.go b/internal/api/handler/audit_handler_test.go index 2d58834..e75bce1 100644 --- a/internal/api/handler/audit_handler_test.go +++ b/internal/api/handler/audit_handler_test.go @@ -15,8 +15,9 @@ import ( // mockAuditService implements AuditService for testing. type mockAuditService struct { - listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error) - getFunc func(id string) (*domain.AuditEvent, error) + listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error) + listByCatFunc func(category string, page, perPage int) ([]domain.AuditEvent, int64, error) + getFunc func(id string) (*domain.AuditEvent, error) } func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) { @@ -26,6 +27,16 @@ func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) return nil, 0, nil } +func (m *mockAuditService) ListAuditEventsByCategory(_ context.Context, category string, page, perPage int) ([]domain.AuditEvent, int64, error) { + if m.listByCatFunc != nil { + return m.listByCatFunc(category, page, perPage) + } + if m.listFunc != nil { + return m.listFunc(page, perPage) + } + return nil, 0, nil +} + func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) { if m.getFunc != nil { return m.getFunc(id) diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index fb20805..1ec247d 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -58,6 +58,12 @@ type AuthActorRoleService interface { Revoke(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType, roleID string) error ListForActor(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error) EffectivePermissions(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error) + // ListKeys (Bundle 1 Phase 7) returns every actor in the tenant + // with at least one role grant. The CLI's `auth keys list` and + // scope-down helper consume this. The synthetic actor-demo-anon + // row is included; the CLI filters it out of the interactive + // prompt loop. + ListKeys(ctx context.Context, caller *authsvc.Caller) ([]repository.ActorWithRoles, error) } // NewAuthHandler constructs an AuthHandler with the service-layer @@ -291,6 +297,39 @@ func (h AuthHandler) ListPermissions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": out}) } +// ListKeys handles GET /api/v1/auth/keys (Bundle 1 Phase 7). +// Permission: auth.role.list. Returns every distinct actor in the +// tenant with at least one role grant — the CLI's `auth keys list` +// and scope-down flow consume this. +func (h AuthHandler) ListKeys(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + keys, err := h.actors.ListKeys(r.Context(), caller) + if err != nil { + writeAuthError(w, err) + return + } + type keyEntry struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + TenantID string `json:"tenant_id"` + RoleIDs []string `json:"role_ids"` + } + out := make([]keyEntry, 0, len(keys)) + for _, k := range keys { + out = append(out, keyEntry{ + ActorID: k.ActorID, + ActorType: string(k.ActorType), + TenantID: k.TenantID, + RoleIDs: k.RoleIDs, + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out}) +} + // AddRolePermission handles POST /api/v1/auth/roles/{id}/permissions. func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) { caller, err := callerFromRequest(r) diff --git a/internal/api/handler/auth_bootstrap.go b/internal/api/handler/auth_bootstrap.go new file mode 100644 index 0000000..54977e0 --- /dev/null +++ b/internal/api/handler/auth_bootstrap.go @@ -0,0 +1,127 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/certctl-io/certctl/internal/auth/bootstrap" +) + +// BootstrapHandler exposes the Bundle 1 Phase 6 day-0 admin path. +// +// Threat model (from cowork/auth-bundle-1-prompt.md): the control +// plane comes up with no admin actors. The operator hands the +// CERTCTL_BOOTSTRAP_TOKEN to a single curl call; the server mints +// the first admin key and locks the door. No subsequent invocation +// can mint another admin via this path — the strategy state and the +// "admin already exists" probe both close it. After bootstrap the +// operator manages keys via /v1/auth/keys/... +// +// Handler shape: +// +// GET /v1/auth/bootstrap → 200 {available:true|false} +// POST /v1/auth/bootstrap → 201 {api_key, key_value, actor_id} +// +// The GET surface is intentionally probable from any caller; it +// returns availability (no token, no admin probe) so the GUI and the +// install one-liner can decide whether to render the bootstrap +// affordance. The POST surface requires the bootstrap token and +// returns the plaintext key value once. +type BootstrapHandler struct { + svc *bootstrap.Service +} + +// NewBootstrapHandler constructs a BootstrapHandler. svc may be nil +// to disable both methods (handler returns 410 Gone on every call). +func NewBootstrapHandler(svc *bootstrap.Service) BootstrapHandler { + return BootstrapHandler{svc: svc} +} + +type bootstrapAvailableResponse struct { + Available bool `json:"available"` +} + +type bootstrapRequest struct { + Token string `json:"token"` + ActorName string `json:"actor_name"` +} + +type bootstrapResponse struct { + ActorID string `json:"actor_id"` + APIKeyID string `json:"api_key_id"` + KeyValue string `json:"key_value"` + CreatedAt string `json:"created_at"` + Message string `json:"message"` +} + +// Available is the GET probe. Returns {available: true} when the +// strategy is callable AND no admin actors exist; otherwise {available: +// false}. The endpoint never reveals the bootstrap token's existence +// independently of admin actor state — the GUI uses this to decide +// whether to render the "first-time setup" wizard. +func (h BootstrapHandler) Available(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + available := false + if h.svc != nil { + ok, err := h.svc.Available(r.Context()) + if err == nil { + available = ok + } + } + JSON(w, http.StatusOK, bootstrapAvailableResponse{Available: available}) +} + +// Mint is the POST handler that consumes the token + creates the +// first admin key. +// +// Status mapping: +// +// 410 Gone → strategy disabled (no token, admin exists, or one-shot already consumed) +// 401 Unauthorized → token mismatch +// 400 Bad Request → invalid actor_name +// 201 Created → key minted; response carries the plaintext key value +func (h BootstrapHandler) Mint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if h.svc == nil { + // No service wired = endpoint disabled. Same status as the + // "already consumed" path so callers can't differentiate + // configuration from state. + Error(w, http.StatusGone, "bootstrap endpoint disabled") + return + } + var body bootstrapRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON body") + return + } + body.ActorName = strings.TrimSpace(body.ActorName) + result, err := h.svc.ValidateAndMint(r.Context(), body.Token, body.ActorName) + if err != nil { + switch { + case errors.Is(err, bootstrap.ErrDisabled): + Error(w, http.StatusGone, "bootstrap endpoint disabled") + case errors.Is(err, bootstrap.ErrInvalidToken): + Error(w, http.StatusUnauthorized, "Invalid bootstrap token") + case errors.Is(err, bootstrap.ErrInvalidActorName): + Error(w, http.StatusBadRequest, "Invalid actor_name (3-64 chars, lowercase alnum + - + _)") + default: + Error(w, http.StatusInternalServerError, "Bootstrap failed") + } + return + } + JSON(w, http.StatusCreated, bootstrapResponse{ + ActorID: result.APIKey.Name, + APIKeyID: result.APIKey.ID, + KeyValue: result.KeyValue, + CreatedAt: result.APIKey.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), + Message: "Admin API key created. This is the only time the key value is shown — capture it now.", + }) +} diff --git a/internal/api/handler/auth_bootstrap_test.go b/internal/api/handler/auth_bootstrap_test.go new file mode 100644 index 0000000..cf1c571 --- /dev/null +++ b/internal/api/handler/auth_bootstrap_test.go @@ -0,0 +1,275 @@ +package handler + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/certctl-io/certctl/internal/auth/bootstrap" + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" +) + +// ============================================================================= +// In-memory fakes (copies of the bootstrap-package fakes; the package +// boundary keeps the bootstrap-package tests independent). +// ============================================================================= + +type stubMinter struct{ created []*authdomain.APIKey } + +func (s *stubMinter) Create(_ context.Context, k *authdomain.APIKey) error { + s.created = append(s.created, k) + return nil +} +func (s *stubMinter) GetByName(_ context.Context, _ string) (*authdomain.APIKey, error) { + return nil, nil +} + +type stubGranter struct{ calls []*authdomain.ActorRole } + +func (s *stubGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error { + s.calls = append(s.calls, ar) + return nil +} + +type stubAudit struct{ calls []map[string]interface{} } + +func (s *stubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, _ string, _ string, _ string, _ string, details map[string]interface{}) error { + s.calls = append(s.calls, details) + return nil +} + +type stubKeyStore struct { + mu sync.Mutex + rows []string +} + +func (s *stubKeyStore) AddHashed(name, hash string, _ bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.rows = append(s.rows, name+":"+hash) +} + +func sha(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func newBootstrapHandlerWith(token string, probe bootstrap.AdminExistenceProbe) (BootstrapHandler, *stubMinter, *stubGranter, *stubAudit, *stubKeyStore) { + strategy := bootstrap.NewEnvTokenStrategy(token, probe) + minter := &stubMinter{} + granter := &stubGranter{} + audit := &stubAudit{} + store := &stubKeyStore{} + svc := bootstrap.NewService(strategy, minter, granter, audit, store, sha) + return NewBootstrapHandler(svc), minter, granter, audit, store +} + +// ============================================================================= +// Handler tests +// ============================================================================= + +// TestBootstrapHandler_Mint_ValidTokenReturns201 is the happy path. +// Plaintext key value present in the response body; only the hash is +// persisted via the minter. +func TestBootstrapHandler_Mint_ValidTokenReturns201(t *testing.T) { + h, minter, granter, audit, store := newBootstrapHandlerWith("the-token", nil) + + body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.Mint(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + var resp bootstrapResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ActorID != "first-admin" { + t.Errorf("actor_id = %q, want first-admin", resp.ActorID) + } + if resp.KeyValue == "" { + t.Errorf("key_value missing from response") + } + if len(minter.created) != 1 || len(granter.calls) != 1 || len(audit.calls) != 1 || len(store.rows) != 1 { + t.Errorf("side effects mismatch: minter=%d grants=%d audit=%d keystore=%d", + len(minter.created), len(granter.calls), len(audit.calls), len(store.rows)) + } +} + +// TestBootstrapHandler_Mint_WrongToken_401 pins the wrong-token mapping. +func TestBootstrapHandler_Mint_WrongToken_401(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil) + body, _ := json.Marshal(map[string]string{"token": "wrong", "actor_name": "first-admin"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.Mint(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestBootstrapHandler_Mint_TwiceReturns410 pins the one-shot +// invariant. Second call after a successful first call returns 410 +// Gone, NOT 401 (which would suggest "wrong token, retry"). +func TestBootstrapHandler_Mint_TwiceReturns410(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil) + + body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"}) + rec1 := httptest.NewRecorder() + h.Mint(rec1, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec1.Code != http.StatusCreated { + t.Fatalf("first call status = %d, want 201", rec1.Code) + } + rec2 := httptest.NewRecorder() + h.Mint(rec2, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec2.Code != http.StatusGone { + t.Errorf("second call status = %d, want 410 Gone", rec2.Code) + } +} + +// TestBootstrapHandler_Mint_AdminExists410 pins that the admin- +// existence probe gates the endpoint. Operator forgets to unset +// CERTCTL_BOOTSTRAP_TOKEN after onboarding → endpoint stays 410. +func TestBootstrapHandler_Mint_AdminExists410(t *testing.T) { + probe := func(_ context.Context) (bool, error) { return true, nil } + h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe) + + body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"}) + rec := httptest.NewRecorder() + h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec.Code != http.StatusGone { + t.Errorf("status = %d, want 410 Gone (admin already exists)", rec.Code) + } +} + +// TestBootstrapHandler_Mint_NoTokenConfigured410 pins that an unset +// CERTCTL_BOOTSTRAP_TOKEN closes the path (410), matching the +// "endpoint disabled" semantics the prompt requires. +func TestBootstrapHandler_Mint_NoTokenConfigured410(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("", nil) + + body, _ := json.Marshal(map[string]string{"token": "anything", "actor_name": "first-admin"}) + rec := httptest.NewRecorder() + h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec.Code != http.StatusGone { + t.Errorf("status = %d, want 410 Gone (no token configured)", rec.Code) + } +} + +// TestBootstrapHandler_Mint_BadActorName_400 pins the actor-name +// validation surface (charset, length). +func TestBootstrapHandler_Mint_BadActorName_400(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil) + cases := []string{"", "AB", "has space", "Has-Caps"} + for _, name := range cases { + body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": name}) + rec := httptest.NewRecorder() + // Each request consumes the strategy on success so we rebuild + // per case. + h2, _, _, _, _ := newBootstrapHandlerWith("the-token", nil) + h2.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Errorf("name=%q status = %d, want 400", name, rec.Code) + } + } + _ = h +} + +// TestBootstrapHandler_Available_NoTokenSet pins the GET probe shape: +// {available:false} when the token is unset. +func TestBootstrapHandler_Available_NoTokenSet(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("", nil) + rec := httptest.NewRecorder() + h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var resp bootstrapAvailableResponse + _ = json.NewDecoder(rec.Body).Decode(&resp) + if resp.Available { + t.Errorf("available=true with no token, want false") + } +} + +// TestBootstrapHandler_Available_TokenSetNoAdmin returns true. +func TestBootstrapHandler_Available_TokenSetNoAdmin(t *testing.T) { + probe := func(_ context.Context) (bool, error) { return false, nil } + h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe) + rec := httptest.NewRecorder() + h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil)) + var resp bootstrapAvailableResponse + _ = json.NewDecoder(rec.Body).Decode(&resp) + if !resp.Available { + t.Errorf("available=false with token set + no admin, want true") + } +} + +// TestBootstrapHandler_TokenLeakHygiene scans the slog logger output +// after a happy-path mint. The bootstrap token MUST NOT appear in any +// log line. Audit details, app logs, error wrappers — none of them +// can contain the token. +func TestBootstrapHandler_TokenLeakHygiene(t *testing.T) { + const token = "extremely-secret-bootstrap-token-do-not-leak" + + // Capture every slog write. Tests in this package (and the + // upstream service package) currently use the global slog + // default; we redirect it for the duration of this test. + var logBuf bytes.Buffer + origLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(origLogger) + + h, _, _, audit, _ := newBootstrapHandlerWith(token, nil) + + body, _ := json.Marshal(map[string]string{"token": token, "actor_name": "first-admin"}) + rec := httptest.NewRecorder() + h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d", rec.Code) + } + + if strings.Contains(logBuf.String(), token) { + t.Errorf("bootstrap token leaked into slog output") + } + for i, c := range audit.calls { + blob, _ := json.Marshal(c) + if strings.Contains(string(blob), token) { + t.Errorf("bootstrap token leaked into audit details[%d]: %s", i, blob) + } + } + if strings.Contains(rec.Header().Get("Location"), token) { + t.Errorf("bootstrap token leaked into Location header") + } +} + +// TestBootstrapHandler_Mint_BodyReadCapped guards against a bad-faith +// caller posting a 1MB token field. The handler caps the request body +// at 4KB; a 5KB body should fail to decode. +func TestBootstrapHandler_Mint_BodyReadCapped(t *testing.T) { + h, _, _, _, _ := newBootstrapHandlerWith("t", nil) + huge := strings.Repeat("a", 5000) + body := []byte(`{"token":"t","actor_name":"first-admin","filler":"` + huge + `"}`) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)) + h.Mint(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("oversized body should yield 400, got %d", rec.Code) + } +} + +// keep io reachable (some compiler runs strip unused imports during +// AST refactors; explicit ref guards against that without producing a +// real test side effect). +var _ = io.Discard diff --git a/internal/api/handler/auth_test.go b/internal/api/handler/auth_test.go index fe95aa1..cd7bfff 100644 --- a/internal/api/handler/auth_test.go +++ b/internal/api/handler/auth_test.go @@ -142,6 +142,18 @@ func (f *fakeAuthActorSvc) ListForActor(_ context.Context, _ *authsvc.Caller, _ func (f *fakeAuthActorSvc) EffectivePermissions(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]repository.EffectivePermission, error) { return f.effective, nil } +func (f *fakeAuthActorSvc) ListKeys(_ context.Context, _ *authsvc.Caller) ([]repository.ActorWithRoles, error) { + out := make([]repository.ActorWithRoles, 0, len(f.roles)) + for _, ar := range f.roles { + out = append(out, repository.ActorWithRoles{ + ActorID: ar.ActorID, + ActorType: ar.ActorType, + TenantID: ar.TenantID, + RoleIDs: []string{ar.RoleID}, + }) + } + return out, nil +} type fakePermChecker struct { check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2e55752..0c79af8 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -78,10 +78,12 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter, // The TestRouter_AuthExemptAllowlist regression test below pins the slice // to the actual mux.Handle calls — adding an undocumented bypass fails CI. var AuthExemptRouterRoutes = []string{ - "GET /health", // K8s/Docker liveness probe; cannot carry Bearer - "GET /ready", // K8s/Docker readiness probe; cannot carry Bearer - "GET /api/v1/auth/info", // GUI calls before login to detect auth mode - "GET /api/v1/version", // Rollout probes need build identity without key + "GET /health", // K8s/Docker liveness probe; cannot carry Bearer + "GET /ready", // K8s/Docker readiness probe; cannot carry Bearer + "GET /api/v1/auth/info", // GUI calls before login to detect auth mode + "GET /api/v1/version", // Rollout probes need build identity without key + "GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage) + "POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe } // AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes @@ -131,6 +133,13 @@ type HandlerRegistry struct { // PermissionService dependencies. Phase 5 ships the CLI mirror. Auth handler.AuthHandler + // Bootstrap (Bundle 1 Phase 6) handles the day-0 admin path under + // /api/v1/auth/bootstrap. GET probes availability without revealing + // state; POST consumes CERTCTL_BOOTSTRAP_TOKEN once and mints the + // first admin API key. Both routes are auth-exempt (the endpoint + // itself authenticates via the bootstrap token). + Bootstrap handler.BootstrapHandler + // Checker is the load-bearing auth.PermissionChecker that // auth.RequirePermission middleware uses to gate the legacy admin // handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres @@ -245,6 +254,21 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // Auth check endpoint (uses full middleware chain via r.Register) r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck)) + // Bundle 1 Phase 6 — bootstrap routes. Auth-exempt because the + // endpoint itself authenticates via the CERTCTL_BOOTSTRAP_TOKEN + // (see internal/auth/bootstrap). Both routes are pinned in the + // AuthExemptRouterRoutes allowlist above. + r.mux.Handle("GET /api/v1/auth/bootstrap", middleware.Chain( + http.HandlerFunc(reg.Bootstrap.Available), + middleware.CORS, + middleware.ContentType, + )) + r.mux.Handle("POST /api/v1/auth/bootstrap", middleware.Chain( + http.HandlerFunc(reg.Bootstrap.Mint), + middleware.CORS, + middleware.ContentType, + )) + // RBAC management routes (Bundle 1 Phase 4). Permission gates are // enforced inside each handler via the service layer; the Phase 3 // auth.RequirePermission middleware factory will wrap these in a @@ -259,6 +283,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole)) r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission)) r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission)) + r.Register("GET /api/v1/auth/keys", http.HandlerFunc(reg.Auth.ListKeys)) r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey)) r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey)) diff --git a/internal/auth/bootstrap/bootstrap.go b/internal/auth/bootstrap/bootstrap.go new file mode 100644 index 0000000..1e3679c --- /dev/null +++ b/internal/auth/bootstrap/bootstrap.go @@ -0,0 +1,194 @@ +// Package bootstrap ships the day-0 admin-creation primitive for Bundle 1 +// Phase 6. The control plane comes up with no admin-roled actors; the +// operator hands the env-var token to a single curl call; the server +// mints the first admin API key, returns the key value once, then locks +// the bootstrap door behind it. +// +// The Strategy interface is the forward-compat seam: Bundle 2 plugs in an +// OIDC-first-admin strategy (the operator logs in via OIDC, the server +// recognizes their group claim, the first such login auto-grants r-admin) +// alongside the env-var-token strategy this file ships. Both implementations +// satisfy the same interface; the boot path picks one based on which +// CERTCTL_BOOTSTRAP_* env var is set. +package bootstrap + +import ( + "context" + "crypto/subtle" + "errors" + "sync" +) + +// Sentinel errors the HTTP handler maps to status codes. +var ( + // ErrDisabled is returned when the bootstrap path is not callable + // either because (a) no token was set, or (b) admin actors already + // exist, or (c) the token was already consumed by an earlier call. + // Maps to HTTP 410 Gone. + ErrDisabled = errors.New("bootstrap: endpoint disabled") + + // ErrInvalidToken is returned when the supplied token does not + // match the env-var token (constant-time compared). Maps to HTTP + // 401 Unauthorized. Deliberately does NOT distinguish between + // "wrong token" and "no token configured" so callers cannot use + // timing or status to probe the server's bootstrap state. + ErrInvalidToken = errors.New("bootstrap: invalid token") + + // ErrInvalidActorName is returned when the requested admin-key + // name is empty or contains characters that would break audit + // attribution. Maps to HTTP 400. + ErrInvalidActorName = errors.New("bootstrap: invalid actor name") +) + +// Strategy is the bundle 1 -> bundle 2 forward-compat seam. Each +// strategy gates the day-0 admin path with a different credential type: +// Bundle 1 ships EnvTokenStrategy (CERTCTL_BOOTSTRAP_TOKEN); Bundle 2 +// adds OIDCFirstAdminStrategy (CERTCTL_BOOTSTRAP_OIDC_GROUP). The +// service holds whichever strategy was wired at boot. +type Strategy interface { + // Available reports whether the strategy is currently callable. + // Returns false once the strategy is consumed (one-shot semantics) + // OR once the strategy detects an existing admin (via the + // AdminExistenceProbe). The HTTP handler maps !Available to 410 + // Gone before doing any token validation, so probing for "is there + // a bootstrap path open" is safe. + Available(ctx context.Context) (bool, error) + + // Validate consumes the credential and returns nil when the caller + // is permitted to mint the first admin. The strategy MUST atomic- + // flip its consumed state on first successful Validate so a + // concurrent racing call gets ErrDisabled. Returning a non-nil + // error MUST NOT mark the strategy consumed; the operator can + // retry with the correct credential. + Validate(ctx context.Context, token string) error +} + +// AdminExistenceProbe is the callback the EnvTokenStrategy uses to ask +// the actor-role repository whether any actor holds r-admin. Lives at +// this package boundary so the strategy doesn't import internal/repository +// (would create a cycle: bootstrap -> repository -> postgres -> bootstrap +// when the postgres adapter is wired). +type AdminExistenceProbe func(ctx context.Context) (bool, error) + +// EnvTokenStrategy is the env-var-token Bundle 1 implementation. The +// operator sets CERTCTL_BOOTSTRAP_TOKEN, the server boots with this +// strategy, the first valid Validate call atomically flips the +// `consumed` flag and the next call returns ErrDisabled. +// +// The token comparison is crypto/subtle.ConstantTimeCompare so timing +// attacks can't leak the token byte-by-byte. The token itself never +// leaves this package: the strategy holds it in memory, the handler +// receives only error sentinels, the audit row records the event but +// not the token value. +type EnvTokenStrategy struct { + token string // set once at construction; never mutated + probe AdminExistenceProbe // optional; nil = skip the existence probe + mu sync.Mutex // guards consumed + consumed bool // flipped to true after first successful Validate + tokenLength int // cached for early-reject fast path +} + +// NewEnvTokenStrategy constructs the env-var-token strategy. token must +// be the raw value of CERTCTL_BOOTSTRAP_TOKEN. probe is optional; when +// non-nil it gates Available + Validate on "no admin exists yet" so the +// caller can't bootstrap a second admin after the fleet has stabilized. +// +// When token is empty the returned strategy is born consumed — +// Available returns false, Validate returns ErrDisabled. This matches +// the boot-path contract that an unset env var disables the endpoint. +func NewEnvTokenStrategy(token string, probe AdminExistenceProbe) *EnvTokenStrategy { + s := &EnvTokenStrategy{ + token: token, + probe: probe, + tokenLength: len(token), + } + if token == "" { + s.consumed = true + } + return s +} + +// Available implements Strategy. +func (s *EnvTokenStrategy) Available(ctx context.Context) (bool, error) { + s.mu.Lock() + consumed := s.consumed + s.mu.Unlock() + if consumed { + return false, nil + } + if s.probe != nil { + exists, err := s.probe(ctx) + if err != nil { + return false, err + } + if exists { + return false, nil + } + } + return true, nil +} + +// Validate implements Strategy. +func (s *EnvTokenStrategy) Validate(ctx context.Context, token string) error { + // Fast-path: if the strategy is disabled, return Disabled before + // doing any constant-time compare. The state flip below acquires + // the same mutex so this read is safe. + s.mu.Lock() + if s.consumed { + s.mu.Unlock() + return ErrDisabled + } + // Refuse zero-length tokens up front. ConstantTimeCompare returns + // 1 when both inputs are empty, which would otherwise produce a + // permanent backdoor on misconfigured deployments where token="" + // at construction; NewEnvTokenStrategy already covers that, but + // belt-and-braces here in case a future caller passes the strategy + // raw. + if s.tokenLength == 0 || len(token) == 0 { + s.mu.Unlock() + return ErrInvalidToken + } + // Constant-time compare. Length-pad implicit: ConstantTimeCompare + // returns 0 when lengths differ (and runs in constant time + // relative to the shorter length). + if subtle.ConstantTimeCompare([]byte(s.token), []byte(token)) != 1 { + s.mu.Unlock() + return ErrInvalidToken + } + // External probe: respect the "admin already exists" gate even + // after a valid token was supplied. This closes the race where a + // fleet first-admin lands during the gap between Available and + // Validate. + if s.probe != nil { + // Drop the lock for the probe — repo calls may be slow and + // holding the mutex through I/O would serialize every + // concurrent bootstrap attempt. Re-acquire after. + s.mu.Unlock() + exists, err := s.probe(ctx) + if err != nil { + return err + } + if exists { + return ErrDisabled + } + s.mu.Lock() + // Re-check consumed because a concurrent caller might have + // flipped it while we were probing. + if s.consumed { + s.mu.Unlock() + return ErrDisabled + } + } + s.consumed = true + s.mu.Unlock() + return nil +} + +// IsConsumed reports whether the strategy has already been used. Test +// helper; production callers should use Available which also runs the +// admin-existence probe. +func (s *EnvTokenStrategy) IsConsumed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.consumed +} diff --git a/internal/auth/bootstrap/bootstrap_test.go b/internal/auth/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..ae92661 --- /dev/null +++ b/internal/auth/bootstrap/bootstrap_test.go @@ -0,0 +1,125 @@ +package bootstrap + +import ( + "context" + "errors" + "testing" +) + +// TestEnvTokenStrategy_EmptyTokenIsBornDisabled pins the load-bearing +// invariant that an unset CERTCTL_BOOTSTRAP_TOKEN closes the bootstrap +// path at construction time. The handler depends on this — without it, +// a misconfigured deploy that forgot to set the env var would expose +// the endpoint with a token of "" that an attacker could trivially +// match by also sending "". +func TestEnvTokenStrategy_EmptyTokenIsBornDisabled(t *testing.T) { + s := NewEnvTokenStrategy("", nil) + avail, err := s.Available(context.Background()) + if err != nil { + t.Fatalf("Available err = %v, want nil", err) + } + if avail { + t.Errorf("Available = true for empty token, want false") + } + if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrDisabled) { + t.Errorf("Validate('') for empty-token strategy = %v, want ErrDisabled", got) + } + if got := s.Validate(context.Background(), "anything"); !errors.Is(got, ErrDisabled) { + t.Errorf("Validate('anything') for empty-token strategy = %v, want ErrDisabled", got) + } +} + +// TestEnvTokenStrategy_WrongTokenReturnsInvalidToken pins that the +// strategy maps a token mismatch to ErrInvalidToken (HTTP 401), not +// ErrDisabled (410). Misclassifying these would let a probing attacker +// distinguish "no token set" from "wrong token" via response status. +func TestEnvTokenStrategy_WrongTokenReturnsInvalidToken(t *testing.T) { + s := NewEnvTokenStrategy("correct-token", nil) + if got := s.Validate(context.Background(), "wrong-token"); !errors.Is(got, ErrInvalidToken) { + t.Errorf("Validate(wrong) = %v, want ErrInvalidToken", got) + } + if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrInvalidToken) { + t.Errorf("Validate('') = %v, want ErrInvalidToken", got) + } + if s.IsConsumed() { + t.Errorf("strategy consumed after failed Validate; must remain available for retry") + } +} + +// TestEnvTokenStrategy_OneShotConsumption pins the invariant that the +// first valid Validate call locks the strategy. The bootstrap path is +// strictly one-shot; the second call MUST return ErrDisabled (HTTP +// 410), not ErrInvalidToken (which would suggest "wrong token, try +// again"). +func TestEnvTokenStrategy_OneShotConsumption(t *testing.T) { + s := NewEnvTokenStrategy("correct-token", nil) + if err := s.Validate(context.Background(), "correct-token"); err != nil { + t.Fatalf("first Validate = %v, want nil", err) + } + if !s.IsConsumed() { + t.Errorf("IsConsumed = false after successful Validate, want true") + } + if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, ErrDisabled) { + t.Errorf("second Validate = %v, want ErrDisabled", got) + } + avail, err := s.Available(context.Background()) + if err != nil { + t.Fatalf("Available err = %v", err) + } + if avail { + t.Errorf("Available = true after consumption, want false") + } +} + +// TestEnvTokenStrategy_AdminExistsClosesPath pins the invariant that +// the admin-existence probe gates Available + Validate. The strategy +// must NOT mint a second admin even if the operator forgot to unset +// CERTCTL_BOOTSTRAP_TOKEN after onboarding. +func TestEnvTokenStrategy_AdminExistsClosesPath(t *testing.T) { + probe := func(_ context.Context) (bool, error) { return true, nil } + s := NewEnvTokenStrategy("correct-token", probe) + avail, err := s.Available(context.Background()) + if err != nil { + t.Fatalf("Available err = %v", err) + } + if avail { + t.Errorf("Available = true with admin exists probe, want false") + } + if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, ErrDisabled) { + t.Errorf("Validate = %v with admin exists, want ErrDisabled", got) + } + if s.IsConsumed() { + t.Errorf("strategy must NOT be consumed when admin-existence probe rejects; allows retry after operator removes the duplicate admin") + } +} + +// TestEnvTokenStrategy_AdminProbeError surfaces the error to the +// caller without consuming the strategy. The HTTP handler maps this +// to 500; the operator can retry once the underlying issue is fixed. +func TestEnvTokenStrategy_AdminProbeError(t *testing.T) { + probeErr := errors.New("boom") + probe := func(_ context.Context) (bool, error) { return false, probeErr } + s := NewEnvTokenStrategy("correct-token", probe) + if _, err := s.Available(context.Background()); !errors.Is(err, probeErr) { + t.Errorf("Available err = %v, want probeErr", err) + } + if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, probeErr) { + t.Errorf("Validate err = %v, want probeErr", got) + } + if s.IsConsumed() { + t.Errorf("strategy must NOT be consumed on probe error") + } +} + +// TestEnvTokenStrategy_ZeroLengthRejectedEvenWithMatchingToken belt- +// and-braces against the ConstantTimeCompare("","")=1 footgun. A +// strategy explicitly constructed with token="" is born disabled +// (ErrDisabled); but if a future caller bypasses the constructor, the +// Validate path also rejects zero-length tokens up front. +func TestEnvTokenStrategy_ZeroLengthRejectedEvenWithMatchingToken(t *testing.T) { + // Directly construct a strategy with token="" + s := &EnvTokenStrategy{token: "", tokenLength: 0, consumed: false} + if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrInvalidToken) { + t.Errorf("Validate('','') = %v, want ErrInvalidToken (zero-length guard)", got) + } +} diff --git a/internal/auth/bootstrap/service.go b/internal/auth/bootstrap/service.go new file mode 100644 index 0000000..47df576 --- /dev/null +++ b/internal/auth/bootstrap/service.go @@ -0,0 +1,204 @@ +package bootstrap + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "time" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" +) + +// actorNameRe matches the operator-supplied admin-key name. Constraints: +// 3-64 chars, lowercase alphanumeric + hyphen + underscore. Strict +// charset prevents audit-attribution shenanigans (control characters, +// log-injection sequences, mixed-case look-alikes for an existing +// admin actor's name). +var actorNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,63}$`) + +// APIKeyMinter is the slice of APIKeyRepository the bootstrap service +// needs. Pulled out as a small interface so the service can be unit- +// tested with an in-memory fake. +type APIKeyMinter interface { + Create(ctx context.Context, key *authdomain.APIKey) error + GetByName(ctx context.Context, name string) (*authdomain.APIKey, error) +} + +// RoleGranter is the slice of ActorRoleRepository the bootstrap +// service needs. +type RoleGranter interface { + Grant(ctx context.Context, ar *authdomain.ActorRole) error +} + +// AuditRecorder is the slice of AuditService the bootstrap service +// needs. Phase 8 ships RecordEventWithCategory which classifies the +// row's event_category column directly; the bootstrap path always +// emits with category=auth. +type AuditRecorder interface { + RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, details map[string]interface{}) error +} + +// KeyStoreAdder is the runtime hook the bootstrap service uses to +// register the just-minted key with the auth middleware so the next +// request authenticates without a process restart. The HTTP-layer +// auth middleware exposes this via internal/auth.MutableKeyStore. +type KeyStoreAdder interface { + AddHashed(name, hashHex string, admin bool) +} + +// Service ties the bootstrap Strategy to the persistence layer. Kept +// separate from the HTTP handler so unit tests can drive it without +// httptest, and so the same service can back a future +// `certctl auth bootstrap` CLI command. +type Service struct { + strategy Strategy + keys APIKeyMinter + roles RoleGranter + audit AuditRecorder + keyStore KeyStoreAdder + hashAPIKey func(string) string // injected so the auth package's HashAPIKey doesn't import this package +} + +// NewService constructs a bootstrap Service. +// +// hashAPIKey takes the plaintext key and returns the SHA-256 hex used +// by the auth middleware's keystore lookup. Pass internal/auth.HashAPIKey +// at the production wire site; tests can pass a deterministic hash for +// matching against MutableKeyStore lookups. +// +// keyStore is optional. Production wires the same MutableKeyStore the +// auth middleware reads from so the minted key authenticates the next +// request; when nil the bootstrap still persists the key to the DB +// but the operator must restart to pick it up via the boot loader. +func NewService(strategy Strategy, keys APIKeyMinter, roles RoleGranter, audit AuditRecorder, keyStore KeyStoreAdder, hashAPIKey func(string) string) *Service { + return &Service{ + strategy: strategy, + keys: keys, + roles: roles, + audit: audit, + keyStore: keyStore, + hashAPIKey: hashAPIKey, + } +} + +// MintResult is the success payload returned to the HTTP handler. Key +// is the plaintext value the operator must capture before the response +// is dropped — the server holds it for ~milliseconds and never logs it. +type MintResult struct { + APIKey *authdomain.APIKey + KeyValue string +} + +// Available reports whether the bootstrap endpoint is currently +// callable. Returns the strategy's verdict plus a sentinel +// (ErrDisabled) when not. The HTTP handler maps the sentinel to 410 +// Gone before reading any token from the request body so a probing +// attacker can't distinguish "no token configured" from "wrong +// token". +func (s *Service) Available(ctx context.Context) (bool, error) { + if s == nil || s.strategy == nil { + return false, ErrDisabled + } + return s.strategy.Available(ctx) +} + +// ValidateAndMint consumes the strategy's credential and persists the +// first admin API key. The response carries the plaintext key value +// once; the operator MUST capture it before the response goes out the +// wire. Subsequent calls return ErrDisabled (one-shot semantics). +// +// Side effects: +// 1. Strategy.Validate atomically flips its consumed state. +// 2. A new row is written to api_keys (id, name, sha256(key), admin=true). +// 3. A new row is written to actor_roles (actor=name, role=r-admin). +// 4. The MutableKeyStore (if wired) gains a runtime entry so the next +// request authenticates without a restart. +// 5. An audit event records the bootstrap consumption with +// event_category=auth, action=bootstrap.consume. +// +// The plaintext key is NEVER logged. It exists in three places: +// - the random buffer this function generates, +// - the MintResult.KeyValue field (the handler writes it to the +// response then discards), +// - the HTTP response body itself. +// +// If the persistence calls fail AFTER the strategy is consumed, the +// service does NOT roll back the strategy state — by design. A failed +// ValidateAndMint call leaves bootstrap closed; the operator must +// recover via DB seeding (insert into actor_roles directly) rather +// than retry. The alternative (retry) opens a window for a successful +// validate-then-fail sequence to mint two admin keys on retry, which +// silently widens the trust radius. +func (s *Service) ValidateAndMint(ctx context.Context, token, actorName string) (*MintResult, error) { + if s == nil || s.strategy == nil || s.keys == nil || s.roles == nil { + return nil, ErrDisabled + } + if !actorNameRe.MatchString(actorName) { + return nil, ErrInvalidActorName + } + if err := s.strategy.Validate(ctx, token); err != nil { + return nil, err + } + // Strategy is now consumed; if anything below fails the operator + // has to recover via DB. See the docstring on MintFirstAdmin. + keyValue, err := generateAPIKey() + if err != nil { + return nil, fmt.Errorf("bootstrap: random key generation: %w", err) + } + keyHash := s.hashAPIKey(keyValue) + now := time.Now().UTC() + apiKey := &authdomain.APIKey{ + Name: actorName, + KeyHash: keyHash, + TenantID: authdomain.DefaultTenantID, + Admin: true, + CreatedBy: "bootstrap", + CreatedAt: now, + } + if err := s.keys.Create(ctx, apiKey); err != nil { + return nil, fmt.Errorf("bootstrap: persist key: %w", err) + } + if err := s.roles.Grant(ctx, &authdomain.ActorRole{ + ActorID: actorName, + ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), + RoleID: authdomain.RoleIDAdmin, + TenantID: authdomain.DefaultTenantID, + GrantedBy: "bootstrap", + }); err != nil { + return nil, fmt.Errorf("bootstrap: grant admin role: %w", err) + } + if s.keyStore != nil { + s.keyStore.AddHashed(actorName, keyHash, true) + } + if s.audit != nil { + // Phase 8 promotes event_category to a first-class column. + // Bootstrap is unambiguously an auth event. Errors from the + // audit write are intentionally ignored: the bootstrap mint + // succeeded and the consequent audit-row miss is preferable + // to surfacing a 500 to the operator after the admin-key + // already landed in the DB. The audit-row gap is detectable + // in monitoring (every successful mint should have a paired + // bootstrap.consume row). + _ = s.audit.RecordEventWithCategory(ctx, "bootstrap-token", domain.ActorTypeSystem, + "bootstrap.consume", domain.EventCategoryAuth, "api_key", apiKey.ID, + map[string]interface{}{ + "actor_name": actorName, + "role_id": authdomain.RoleIDAdmin, + }) + } + return &MintResult{APIKey: apiKey, KeyValue: keyValue}, nil +} + +// generateAPIKey returns 32 random bytes hex-encoded (64-char output). +// Same entropy budget as `openssl rand -hex 32` which the agent +// bootstrap docs recommend. +func generateAPIKey() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/auth/bootstrap/service_test.go b/internal/auth/bootstrap/service_test.go new file mode 100644 index 0000000..addc29a --- /dev/null +++ b/internal/auth/bootstrap/service_test.go @@ -0,0 +1,215 @@ +package bootstrap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" +) + +type fakeMinter struct { + created []*authdomain.APIKey + createErr error +} + +func (f *fakeMinter) Create(_ context.Context, k *authdomain.APIKey) error { + if f.createErr != nil { + return f.createErr + } + f.created = append(f.created, k) + return nil +} +func (f *fakeMinter) GetByName(_ context.Context, _ string) (*authdomain.APIKey, error) { + return nil, errors.New("not implemented for these tests") +} + +type fakeGranter struct { + grants []*authdomain.ActorRole + err error +} + +func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error { + f.grants = append(f.grants, ar) + return f.err +} + +type fakeAudit struct { + calls []map[string]interface{} + category string +} + +func (f *fakeAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, _ string, eventCategory, _ string, _ string, details map[string]interface{}) error { + f.calls = append(f.calls, details) + f.category = eventCategory + return nil +} + +type fakeKeyStore struct { + added []addedEntry +} + +type addedEntry struct { + name string + hash string + admin bool +} + +func (f *fakeKeyStore) AddHashed(name, hash string, admin bool) { + f.added = append(f.added, addedEntry{name: name, hash: hash, admin: admin}) +} + +func sha(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +// TestService_ValidateAndMint_HappyPath pins the load-bearing flow: +// valid token → strategy consumed → api_keys row created → admin role +// granted → keystore updated → audit row recorded → result carries the +// plaintext key + the persisted APIKey row. +func TestService_ValidateAndMint_HappyPath(t *testing.T) { + strategy := NewEnvTokenStrategy("the-token", nil) + minter := &fakeMinter{} + granter := &fakeGranter{} + audit := &fakeAudit{} + store := &fakeKeyStore{} + svc := NewService(strategy, minter, granter, audit, store, sha) + + result, err := svc.ValidateAndMint(context.Background(), "the-token", "first-admin") + if err != nil { + t.Fatalf("ValidateAndMint err = %v", err) + } + if result == nil || result.KeyValue == "" { + t.Fatalf("result.KeyValue empty") + } + if len(result.KeyValue) < 32 { + t.Errorf("KeyValue length = %d, want >= 32 (entropy budget)", len(result.KeyValue)) + } + if !strategy.IsConsumed() { + t.Errorf("strategy not consumed after successful mint") + } + if len(minter.created) != 1 { + t.Fatalf("minter.Create call count = %d, want 1", len(minter.created)) + } + apiKey := minter.created[0] + if apiKey.Name != "first-admin" || !apiKey.Admin || apiKey.CreatedBy != "bootstrap" { + t.Errorf("api_key wrong fields: %+v", apiKey) + } + if apiKey.KeyHash != sha(result.KeyValue) { + t.Errorf("KeyHash != sha(KeyValue); persistence shape is wrong") + } + if len(granter.grants) != 1 { + t.Fatalf("granter.Grant call count = %d, want 1", len(granter.grants)) + } + if granter.grants[0].RoleID != authdomain.RoleIDAdmin { + t.Errorf("granted role = %q, want %q", granter.grants[0].RoleID, authdomain.RoleIDAdmin) + } + if granter.grants[0].ActorID != "first-admin" { + t.Errorf("granted actor = %q, want first-admin", granter.grants[0].ActorID) + } + if granter.grants[0].GrantedBy != "bootstrap" { + t.Errorf("GrantedBy = %q, want bootstrap", granter.grants[0].GrantedBy) + } + if len(store.added) != 1 || store.added[0].name != "first-admin" || !store.added[0].admin { + t.Errorf("keystore.AddHashed not called with first-admin/admin=true: %+v", store.added) + } + if store.added[0].hash != apiKey.KeyHash { + t.Errorf("keystore hash != api_key hash; runtime auth would fail") + } + if len(audit.calls) != 1 { + t.Fatalf("audit RecordEventWithCategory calls = %d, want 1", len(audit.calls)) + } + if audit.calls[0]["actor_name"] != "first-admin" { + t.Errorf("audit details lost actor_name: %+v", audit.calls[0]) + } + if audit.category != "auth" { + t.Errorf("audit category = %q, want auth", audit.category) + } +} + +// TestService_ValidateAndMint_RejectsInvalidActorName pins the +// ErrInvalidActorName mapping (HTTP 400). Strict charset prevents +// log-injection / lookalike actor names. +func TestService_ValidateAndMint_RejectsInvalidActorName(t *testing.T) { + svc := NewService(NewEnvTokenStrategy("t", nil), &fakeMinter{}, &fakeGranter{}, nil, nil, sha) + cases := []string{ + "", // empty + "AB", // too short + "Has-Caps", // uppercase rejected + "contains spaces", // space rejected + strings.Repeat("a", 65), // 65 chars > 64 max + "newline\nsuffix", // log injection + "💀-evil", // non-ASCII + } + for _, name := range cases { + _, err := svc.ValidateAndMint(context.Background(), "t", name) + if !errors.Is(err, ErrInvalidActorName) { + t.Errorf("name=%q err = %v, want ErrInvalidActorName", name, err) + } + } +} + +// TestService_ValidateAndMint_PropagatesStrategyError pins that a +// failed Validate (wrong token / disabled / probe error) propagates +// without persisting anything. +func TestService_ValidateAndMint_PropagatesStrategyError(t *testing.T) { + strategy := NewEnvTokenStrategy("the-token", nil) + minter := &fakeMinter{} + granter := &fakeGranter{} + store := &fakeKeyStore{} + svc := NewService(strategy, minter, granter, nil, store, sha) + + _, err := svc.ValidateAndMint(context.Background(), "wrong-token", "first-admin") + if !errors.Is(err, ErrInvalidToken) { + t.Fatalf("err = %v, want ErrInvalidToken", err) + } + if len(minter.created) != 0 || len(granter.grants) != 0 || len(store.added) != 0 { + t.Errorf("persistence side effects fired despite Validate failure: minter=%d grants=%d keystore=%d", len(minter.created), len(granter.grants), len(store.added)) + } +} + +// TestService_ValidateAndMint_NilDepsReturnDisabled exercises the +// no-strategy / no-repo guard. Returns ErrDisabled (handler maps to +// 410). Belt-and-braces for partially-wired test or future call sites. +func TestService_ValidateAndMint_NilDepsReturnDisabled(t *testing.T) { + cases := []struct { + name string + svc *Service + }{ + {"nil service", nil}, + {"nil strategy", NewService(nil, &fakeMinter{}, &fakeGranter{}, nil, nil, sha)}, + {"nil minter", NewService(NewEnvTokenStrategy("t", nil), nil, &fakeGranter{}, nil, nil, sha)}, + {"nil granter", NewService(NewEnvTokenStrategy("t", nil), &fakeMinter{}, nil, nil, nil, sha)}, + } + for _, tc := range cases { + _, err := tc.svc.ValidateAndMint(context.Background(), "t", "first-admin") + if !errors.Is(err, ErrDisabled) { + t.Errorf("%s: err = %v, want ErrDisabled", tc.name, err) + } + } +} + +// TestService_GenerateAPIKey_HighEntropy pins the generated key shape: +// 64 hex chars (32 random bytes). Belt-and-braces against future +// refactors that might shrink the entropy budget. +func TestService_GenerateAPIKey_HighEntropy(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 100; i++ { + k, err := generateAPIKey() + if err != nil { + t.Fatalf("iter %d: %v", i, err) + } + if len(k) != 64 { + t.Errorf("len = %d, want 64", len(k)) + } + if seen[k] { + t.Errorf("key collision in 100 iters — entropy budget regressed") + } + seen[k] = true + } +} diff --git a/internal/auth/keystore.go b/internal/auth/keystore.go new file mode 100644 index 0000000..27e9c76 --- /dev/null +++ b/internal/auth/keystore.go @@ -0,0 +1,157 @@ +package auth + +import ( + "crypto/subtle" + "sync" +) + +// KeyStore is the lookup contract NewAuthWithKeyStore consults to +// resolve a Bearer token (already SHA-256 hashed by the middleware) to +// a NamedAPIKey identity. The interface exists so the same auth +// middleware can serve both the env-var-keys-only path (immutable +// in-memory hash table built at startup) and the bootstrap-extended +// path (env-var keys plus runtime-minted admin keys persisted in +// `api_keys`). Bundle 2 will plug in an OIDC-session lookup behind the +// same interface. +// +// LookupByHash MUST be safe for concurrent reads. Implementations that +// support runtime additions wrap their backing slice/map in a +// sync.RWMutex (see MutableKeyStore) so the request path remains lock- +// free in the steady state. +type KeyStore interface { + // LookupByHash returns the NamedAPIKey whose SHA-256 hash matches + // the supplied hex-encoded hash. The matched bool is false when no + // entry matches; callers MUST treat false as "wrong key" (HTTP + // 401) and never as "fall through to a default identity". + // + // The supplied hash is the output of HashAPIKey(token) — already a + // 64-char lowercase hex string. Implementations compare it against + // stored hashes via crypto/subtle.ConstantTimeCompare so a + // timing-attacking caller can't byte-by-byte recover a key. + LookupByHash(hash string) (NamedAPIKey, bool) +} + +// StaticKeyStore is the immutable Bundle-0 behaviour: the entries are +// fixed at construction and the lookup is a constant-time scan. Used +// by deployments that haven't enabled the Bundle-1 bootstrap flow and +// by tests that don't need runtime additions. +type StaticKeyStore struct { + entries []entry +} + +type entry struct { + hash string // SHA-256 hex + name string + admin bool +} + +// NewStaticKeyStore builds an immutable KeyStore from a slice of +// NamedAPIKey values. Each key is hashed once at construction. The +// returned store is safe for concurrent reads with no locking; mutation +// is not supported. +func NewStaticKeyStore(keys []NamedAPIKey) *StaticKeyStore { + out := &StaticKeyStore{ + entries: make([]entry, 0, len(keys)), + } + for _, nk := range keys { + out.entries = append(out.entries, entry{ + hash: HashAPIKey(nk.Key), + name: nk.Name, + admin: nk.Admin, + }) + } + return out +} + +// LookupByHash implements KeyStore. +func (s *StaticKeyStore) LookupByHash(hash string) (NamedAPIKey, bool) { + for i := range s.entries { + if subtle.ConstantTimeCompare([]byte(hash), []byte(s.entries[i].hash)) == 1 { + e := s.entries[i] + return NamedAPIKey{Name: e.name, Admin: e.admin}, true + } + } + return NamedAPIKey{}, false +} + +// Len reports how many entries the store holds. Test/debug helper; the +// request path uses LookupByHash which is the load-bearing contract. +func (s *StaticKeyStore) Len() int { return len(s.entries) } + +// MutableKeyStore is the Bundle-1 Phase 6 KeyStore that supports +// runtime additions. The Bundle 1 bootstrap flow inserts a new row +// into `api_keys`, then calls Add(...) so the just-minted key +// authenticates the very next request without a server restart. The +// backing store loads the same `api_keys` rows on startup so DB- +// persisted keys survive process restart. +// +// Concurrency: a sync.RWMutex guards a slice of entries. Reads +// (LookupByHash) take the read lock; Add takes the write lock. The +// in-memory slice mirrors the env-var named-key entries plus every +// `api_keys` row loaded at boot plus every Add that fires after +// startup. +type MutableKeyStore struct { + mu sync.RWMutex + entries []entry +} + +// NewMutableKeyStore seeds a MutableKeyStore with the provided keys. +// Pass the env-var named keys here at boot; Add additional keys +// (loaded from `api_keys` or minted by bootstrap) after construction. +func NewMutableKeyStore(seed []NamedAPIKey) *MutableKeyStore { + out := &MutableKeyStore{ + entries: make([]entry, 0, len(seed)), + } + for _, nk := range seed { + out.entries = append(out.entries, entry{ + hash: HashAPIKey(nk.Key), + name: nk.Name, + admin: nk.Admin, + }) + } + return out +} + +// LookupByHash implements KeyStore. +func (s *MutableKeyStore) LookupByHash(hash string) (NamedAPIKey, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.entries { + if subtle.ConstantTimeCompare([]byte(hash), []byte(s.entries[i].hash)) == 1 { + e := s.entries[i] + return NamedAPIKey{Name: e.name, Admin: e.admin}, true + } + } + return NamedAPIKey{}, false +} + +// Add registers a new key with the store. The plaintext key is hashed +// once and stored alongside the name + admin flag. Idempotent on +// duplicate hashes (an existing entry for the same hash is replaced +// in-place so re-running the bootstrap loader on startup is safe). +func (s *MutableKeyStore) Add(key NamedAPIKey) { + s.AddHashed(key.Name, HashAPIKey(key.Key), key.Admin) +} + +// AddHashed registers a key whose SHA-256 hash is already computed. +// Used by the api_keys boot loader (the DB stores the hash, not the +// plaintext, so the loader has no plaintext to re-hash). +func (s *MutableKeyStore) AddHashed(name, hashHex string, admin bool) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.entries { + if s.entries[i].hash == hashHex { + s.entries[i].name = name + s.entries[i].admin = admin + return + } + } + s.entries = append(s.entries, entry{hash: hashHex, name: name, admin: admin}) +} + +// Len reports the current entry count. Test helper. +func (s *MutableKeyStore) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.entries) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 8c4eaa2..cfad185 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -2,7 +2,6 @@ package auth import ( "context" - "crypto/subtle" "fmt" "log/slog" "net/http" @@ -44,27 +43,23 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl return next } } - - // Pre-compute hashes of all valid keys for constant-time comparison. - type keyEntry struct { - hash string - name string - admin bool - } - var entries []keyEntry - for _, nk := range namedKeys { - entries = append(entries, keyEntry{ - hash: HashAPIKey(nk.Key), - name: nk.Name, - admin: nk.Admin, - }) - } - - // Warn if only one key is configured in production mode - if len(entries) == 1 { + if len(namedKeys) == 1 { slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation") } + return NewAuthWithKeyStore(NewStaticKeyStore(namedKeys)) +} +// NewAuthWithKeyStore is the Bundle-1 Phase-6 entry point. It builds a +// Bearer-token middleware whose lookup table is supplied by the caller +// instead of being baked into the closure. Production wiring passes a +// MutableKeyStore so the bootstrap path can mint new admin keys at +// runtime; tests pass a StaticKeyStore for the immutable case. A nil +// store yields the demo-mode pass-through (matches NewAuthWithNamedKeys +// with an empty slice). +func NewAuthWithKeyStore(store KeyStore) func(http.Handler) http.Handler { + if store == nil { + return func(next http.Handler) http.Handler { return next } + } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") @@ -74,8 +69,6 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized) return } - - // Extract Bearer token if len(authHeader) < 8 || authHeader[:7] != "Bearer " { w.Header().Set("Content-Type", "application/json; charset=utf-8") http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer "}`, http.StatusUnauthorized) @@ -83,30 +76,20 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl } token := authHeader[7:] - tokenHash := HashAPIKey(token) - - // Check against all valid keys using constant-time comparison - var matched *keyEntry - for i := range entries { - if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 { - matched = &entries[i] - break - } - } - - if matched == nil { + matched, ok := store.LookupByHash(HashAPIKey(token)) + if !ok { w.Header().Set("Content-Type", "application/json; charset=utf-8") http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized) return } - // 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) + // Bundle 1 Phase 0 legacy UserKey/AdminKey + Phase 3 RBAC + // ActorIDKey/ActorTypeKey/TenantIDKey are populated on every + // authenticated request so downstream RequirePermission + + // audit-attribution code see a consistent actor. + 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)) diff --git a/internal/cli/auth_scope_down.go b/internal/cli/auth_scope_down.go new file mode 100644 index 0000000..7c16623 --- /dev/null +++ b/internal/cli/auth_scope_down.go @@ -0,0 +1,401 @@ +package cli + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" +) + +// ============================================================================= +// Bundle 1 Phase 7 — `certctl-cli auth keys list` + scope-down helper. +// +// The Phase 1 migration backfills every CERTCTL_API_KEYS_NAMED entry to +// the admin role on first boot (Decision 7's safe-for-back-compat +// default). Scope-down is the operator-driven downgrade of any keys that +// don't actually need admin power. This file ships: +// +// - AuthListKeys: GET /api/v1/auth/keys — render every actor + roles +// in tabular / json form. +// - AuthScopeDown: interactive flow that walks every key (skipping +// the synthetic actor-demo-anon) and prompts for a target role. +// - AuthScopeDownNonInteractive: take a JSON config {actor_id: role_id} +// and apply role changes without prompts; for automation. +// - AuthScopeDownSuggest: read 30 days of audit events per key and +// suggest a narrower role based on actual call patterns. The suggest +// mode still requires confirmation (or --apply for non-interactive). +// +// The scope-down flow uses revoke + grant as separate API calls +// (no batch endpoint yet — by design; auditing each role mutation +// individually is a Bundle 1 invariant). +// ============================================================================= + +// AuthKeyEntry mirrors handler.ListKeys's response shape without +// importing the handler package. +type AuthKeyEntry struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + TenantID string `json:"tenant_id"` + RoleIDs []string `json:"role_ids"` +} + +type authKeysListResponse struct { + Keys []AuthKeyEntry `json:"keys"` +} + +// AuthListKeys prints every actor in the tenant with their current role +// assignments. The synthetic actor-demo-anon is shown but flagged as +// "system-managed" so operators don't accidentally try to mutate it. +func (c *Client) AuthListKeys() error { + keys, err := c.fetchAuthKeys() + if err != nil { + return err + } + if c.format == "json" { + blob, _ := json.MarshalIndent(authKeysListResponse{Keys: keys}, "", " ") + fmt.Println(string(blob)) + return nil + } + fmt.Printf("%-28s %-12s %s\n", "ACTOR", "TYPE", "ROLES") + for _, k := range keys { + notes := "" + if k.ActorID == DemoAnonActorID { + notes = " (system-managed; scope-down skips this)" + } + fmt.Printf("%-28s %-12s %s%s\n", k.ActorID, k.ActorType, strings.Join(k.RoleIDs, ","), notes) + } + return nil +} + +// DemoAnonActorID is replicated from internal/auth/context.go so the +// CLI doesn't import internal/auth (the CLI binary stays small). +const DemoAnonActorID = "actor-demo-anon" + +// AuthScopeDown runs the interactive scope-down flow against stdin / +// stdout. Each non-system actor is shown with its current roles and +// the operator picks one of: keep, admin, operator, viewer, agent, +// mcp, cli, auditor. Empty input keeps the current assignment. +func (c *Client) AuthScopeDown() error { + keys, err := c.fetchAuthKeys() + if err != nil { + return err + } + keys = filterScopeDownCandidates(keys) + if len(keys) == 0 { + fmt.Println("no actors eligible for scope-down (only the system-managed actor-demo-anon exists, or no actors hold roles).") + return nil + } + fmt.Println("certctl-cli auth keys scope-down") + fmt.Println("================================") + fmt.Printf("Bundle 1 ships role-based authorization. Existing API keys backfill to r-admin (full power).\n") + fmt.Printf("Walk each key below and select a role that matches its actual usage. Empty input keeps the\n") + fmt.Printf("current assignment; type a single role name to replace it.\n\n") + reader := bufio.NewReader(os.Stdin) + plan, err := buildScopeDownPlan(keys, reader, os.Stdout) + if err != nil { + return err + } + return c.applyScopeDownPlan(plan) +} + +// AuthScopeDownNonInteractive applies a {actor_id: role_id} JSON +// config without prompts. Useful for automation / Helm post-upgrade +// hooks. Empty role_id revokes all current roles WITHOUT granting a +// replacement; the operator can then assign roles selectively via +// `certctl-cli auth keys assign`. +func (c *Client) AuthScopeDownNonInteractive(configPath string) error { + blob, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config %s: %w", configPath, err) + } + var cfg map[string]string + if err := json.Unmarshal(blob, &cfg); err != nil { + return fmt.Errorf("decode config %s: %w", configPath, err) + } + keys, err := c.fetchAuthKeys() + if err != nil { + return err + } + currentRoles := map[string][]string{} + for _, k := range keys { + currentRoles[k.ActorID] = k.RoleIDs + } + plan := []scopeDownAction{} + for actor, target := range cfg { + if actor == DemoAnonActorID { + fmt.Fprintf(os.Stderr, "skipping %s: reserved system actor\n", actor) + continue + } + current, ok := currentRoles[actor] + if !ok { + fmt.Fprintf(os.Stderr, "skipping %s: not in actor_roles (no grants to revoke)\n", actor) + continue + } + plan = append(plan, scopeDownAction{ + ActorID: actor, + CurrentRoles: current, + TargetRole: target, + }) + } + return c.applyScopeDownPlan(plan) +} + +// AuthScopeDownSuggest analyses 30 days of audit events per key and +// prints suggested role assignments. With apply=false (default) the +// suggestions are advisory and the operator follows up with a manual +// scope-down or scope-down-non-interactive call. With apply=true the +// suggestions are applied directly. +func (c *Client) AuthScopeDownSuggest(apply bool) error { + keys, err := c.fetchAuthKeys() + if err != nil { + return err + } + keys = filterScopeDownCandidates(keys) + plan := []scopeDownAction{} + fmt.Println("certctl-cli auth keys scope-down --suggest") + fmt.Println("==========================================") + fmt.Printf("%-28s %-15s %-15s %s\n", "ACTOR", "CURRENT ROLES", "SUGGESTED", "REASON") + for _, k := range keys { + events, fetchErr := c.fetchAuditEventsForActor(k.ActorID, 1000) + if fetchErr != nil { + fmt.Fprintf(os.Stderr, "fetch audit for %s: %v\n", k.ActorID, fetchErr) + continue + } + suggested, reason := SuggestRoleFromAuditEvents(events) + fmt.Printf("%-28s %-15s %-15s %s\n", + k.ActorID, + strings.Join(k.RoleIDs, ","), + suggested, + reason) + plan = append(plan, scopeDownAction{ + ActorID: k.ActorID, + CurrentRoles: k.RoleIDs, + TargetRole: suggested, + }) + } + if !apply { + fmt.Println("\n(dry run; pass --apply to execute the suggested role changes)") + return nil + } + return c.applyScopeDownPlan(plan) +} + +// ============================================================================= +// Internals +// ============================================================================= + +type scopeDownAction struct { + ActorID string + CurrentRoles []string + TargetRole string +} + +func (c *Client) fetchAuthKeys() ([]AuthKeyEntry, error) { + body, err := c.doGET("/api/v1/auth/keys") + if err != nil { + return nil, err + } + var resp authKeysListResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode /v1/auth/keys: %w", err) + } + return resp.Keys, nil +} + +func filterScopeDownCandidates(keys []AuthKeyEntry) []AuthKeyEntry { + out := make([]AuthKeyEntry, 0, len(keys)) + for _, k := range keys { + if k.ActorID == DemoAnonActorID { + continue + } + out = append(out, k) + } + return out +} + +// validRoles is the canonical list scope-down accepts as targets. +// Mirrors the Phase 1 default-role seeds; new operator-defined roles +// can be assigned via `certctl auth keys assign --role ` directly. +var validRoles = []string{"admin", "operator", "viewer", "agent", "mcp", "cli", "auditor"} + +func isValidRole(s string) bool { + for _, v := range validRoles { + if v == s { + return true + } + } + return false +} + +func buildScopeDownPlan(keys []AuthKeyEntry, in *bufio.Reader, out io.Writer) ([]scopeDownAction, error) { + plan := []scopeDownAction{} + for _, k := range keys { + fmt.Fprintf(out, "\n%s (current: %s)\n", k.ActorID, strings.Join(k.RoleIDs, ",")) + fmt.Fprintf(out, " enter target role [%s] or 'keep' (default): ", + strings.Join(validRoles, "|")) + line, err := in.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + choice := strings.TrimSpace(line) + if choice == "" || strings.EqualFold(choice, "keep") { + fmt.Fprintln(out, " → keeping existing roles") + continue + } + choice = strings.ToLower(choice) + if !isValidRole(choice) { + fmt.Fprintf(out, " → unknown role %q, keeping existing\n", choice) + continue + } + // Normalize target to r- for the API. + plan = append(plan, scopeDownAction{ + ActorID: k.ActorID, + CurrentRoles: k.RoleIDs, + TargetRole: "r-" + choice, + }) + } + return plan, nil +} + +// applyScopeDownPlan runs revoke+grant pairs for every action. +// Idempotent on the role layer (revoke a missing role yields 404; the +// CLI swallows that). +func (c *Client) applyScopeDownPlan(plan []scopeDownAction) error { + if len(plan) == 0 { + fmt.Println("\nno role changes to apply.") + return nil + } + fmt.Println("\nApplying role changes:") + var changed, kept int + for _, action := range plan { + // Skip actions whose target role is already exclusively + // held (no diff). This avoids spurious revoke+grant churn. + if len(action.CurrentRoles) == 1 && action.CurrentRoles[0] == action.TargetRole { + fmt.Printf(" %s: already at %s, skipping\n", action.ActorID, action.TargetRole) + kept++ + continue + } + // Revoke every current role. + for _, current := range action.CurrentRoles { + if err := c.AuthRevokeRoleFromKey(action.ActorID, current); err != nil { + return fmt.Errorf("revoke %s/%s: %w", action.ActorID, current, err) + } + } + // Grant the target. Empty target = revoke-only (operator + // will assign roles selectively via `auth keys assign`). + if action.TargetRole != "" { + if err := c.AuthAssignRoleToKey(action.ActorID, action.TargetRole); err != nil { + return fmt.Errorf("grant %s/%s: %w", action.ActorID, action.TargetRole, err) + } + } + changed++ + } + fmt.Printf("\nDone. %d actor(s) changed, %d kept.\n", changed, kept) + return nil +} + +// ============================================================================= +// --suggest mode: audit-event analyser. Pure function for ease of +// testing; no I/O. +// ============================================================================= + +// AuditEventLite is the subset of fields the suggest analyser +// consumes. The audit list endpoint returns full domain.AuditEvent +// rows; we only care about the action / resource_type / resource_id +// path classification. +type AuditEventLite struct { + Action string `json:"action"` + ResourceType string `json:"resource_type"` +} + +// SuggestRoleFromAuditEvents inspects an actor's recent audit-event +// history and returns the narrowest role that covers the observed +// usage pattern, plus a one-line reason. +// +// Classification (priority order): +// +// 1. Any admin-shaped action (role/key/hierarchy/bulk_revoke/admin) → admin. +// 2. Every event is an MCP-shaped action (mcp.*) → mcp. +// 3. Every event is read-only (*.read / *.list) → viewer. +// 4. Every event is agent-shaped (agent.* OR cert.read OR cert.issue) → agent. +// 5. Otherwise → operator. +// +// Empty event list → "viewer" (the safest default). +func SuggestRoleFromAuditEvents(events []AuditEventLite) (role string, reason string) { + if len(events) == 0 { + return "viewer", "no audit history; defaulting to read-only" + } + var ( + hasAdmin bool + allMCP = true + allReadOnly = true + allAgent = true + ) + for _, e := range events { + action := strings.ToLower(e.Action) + // Admin-only signals — earliest exit. + if strings.HasPrefix(action, "auth.role.") || + strings.HasPrefix(action, "auth.key.") || + strings.HasPrefix(action, "ca.hierarchy.") || + strings.Contains(action, "bulk_revoke") || + strings.HasPrefix(action, "scep.admin") || + strings.HasPrefix(action, "est.admin") || + strings.HasPrefix(action, "crl.admin") { + hasAdmin = true + } + if !strings.HasPrefix(action, "mcp.") { + allMCP = false + } + if !strings.HasSuffix(action, ".read") && !strings.HasSuffix(action, ".list") { + allReadOnly = false + } + isAgentShape := strings.HasPrefix(action, "agent.") || + action == "cert.issue" || action == "cert.read" + if !isAgentShape { + allAgent = false + } + } + switch { + case hasAdmin: + return "admin", "called admin-only action (role mgmt / bulk revoke / hierarchy)" + case allMCP: + return "mcp", "only MCP-shaped actions observed" + case allReadOnly: + return "viewer", "all observed actions are read-only" + case allAgent: + return "agent", "only agent + cert read/issue actions observed" + default: + return "operator", "cert / profile / target lifecycle mutations observed; no admin signals" + } +} + +// fetchAuditEventsForActor pulls audit events filtered by actor=actorID +// from /v1/audit. Bundle 1 Phase 7 doesn't yet ship a per-actor query +// param; we filter client-side from the paginated list endpoint. +func (c *Client) fetchAuditEventsForActor(actorID string, limit int) ([]AuditEventLite, error) { + body, err := c.doGET(fmt.Sprintf("/api/v1/audit?per_page=%d", limit)) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + Actor string `json:"actor"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode /v1/audit: %w", err) + } + out := make([]AuditEventLite, 0, len(resp.Data)) + for _, e := range resp.Data { + if e.Actor != actorID { + continue + } + out = append(out, AuditEventLite{Action: e.Action, ResourceType: e.ResourceType}) + } + return out, nil +} diff --git a/internal/cli/auth_scope_down_test.go b/internal/cli/auth_scope_down_test.go new file mode 100644 index 0000000..b4267ce --- /dev/null +++ b/internal/cli/auth_scope_down_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +// TestSuggestRoleFromAuditEvents_TablePins the audit-event analyser +// classification rules. Pure function; no I/O. Adding a new role +// pattern means adding a row here. +func TestSuggestRoleFromAuditEvents_Table(t *testing.T) { + cases := []struct { + name string + events []AuditEventLite + wantRole string + reasonHint string + }{ + { + name: "empty history → viewer", + events: nil, + wantRole: "viewer", + reasonHint: "no audit history", + }, + { + name: "only cert.read → viewer", + events: []AuditEventLite{ + {Action: "cert.read"}, + {Action: "cert.read"}, + {Action: "issuer.read"}, + }, + wantRole: "viewer", + reasonHint: "read-only", + }, + { + name: "agent + cert.issue → agent", + events: []AuditEventLite{ + {Action: "agent.heartbeat"}, + {Action: "agent.job.poll"}, + {Action: "cert.issue"}, + {Action: "cert.read"}, + }, + wantRole: "agent", + reasonHint: "agent", + }, + { + name: "cert lifecycle without admin → operator", + events: []AuditEventLite{ + {Action: "cert.issue"}, + {Action: "cert.revoke"}, + {Action: "profile.edit"}, + {Action: "target.edit"}, + }, + wantRole: "operator", + reasonHint: "lifecycle", + }, + { + name: "any auth.role.assign → admin", + events: []AuditEventLite{ + {Action: "auth.role.assign"}, + }, + wantRole: "admin", + reasonHint: "admin-only", + }, + { + name: "any cert.bulk_revoke → admin", + events: []AuditEventLite{ + {Action: "cert.bulk_revoke"}, + }, + wantRole: "admin", + reasonHint: "admin-only", + }, + { + name: "ca.hierarchy.* → admin", + events: []AuditEventLite{ + {Action: "ca.hierarchy.add_child"}, + }, + wantRole: "admin", + reasonHint: "admin-only", + }, + { + name: "MCP-only history → mcp", + events: []AuditEventLite{ + {Action: "mcp.list_certificates"}, + {Action: "mcp.get_issuer"}, + }, + wantRole: "mcp", + reasonHint: "MCP", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + role, reason := SuggestRoleFromAuditEvents(tc.events) + if role != tc.wantRole { + t.Errorf("role = %q, want %q (reason=%q)", role, tc.wantRole, reason) + } + if !strings.Contains(strings.ToLower(reason), strings.ToLower(tc.reasonHint)) { + t.Errorf("reason %q does not contain hint %q", reason, tc.reasonHint) + } + }) + } +} + +// TestFilterScopeDownCandidates_HidesDemoAnon pins the invariant that +// the synthetic actor-demo-anon row never reaches the prompt loop. +func TestFilterScopeDownCandidates_HidesDemoAnon(t *testing.T) { + in := []AuthKeyEntry{ + {ActorID: "alice", RoleIDs: []string{"r-admin"}}, + {ActorID: DemoAnonActorID, RoleIDs: []string{"r-admin"}}, + {ActorID: "bob", RoleIDs: []string{"r-viewer"}}, + } + got := filterScopeDownCandidates(in) + if len(got) != 2 { + t.Fatalf("got %d candidates, want 2", len(got)) + } + for _, k := range got { + if k.ActorID == DemoAnonActorID { + t.Errorf("filter let actor-demo-anon through") + } + } +} + +// TestBuildScopeDownPlan_KeepEmptyAndUnknown pins the prompt-loop +// behaviour: empty input or "keep" leaves the row alone; unknown role +// names also fall through (operator can re-run the flow). +func TestBuildScopeDownPlan_KeepEmptyAndUnknown(t *testing.T) { + keys := []AuthKeyEntry{ + {ActorID: "alice", RoleIDs: []string{"r-admin"}}, + {ActorID: "bob", RoleIDs: []string{"r-admin"}}, + {ActorID: "carol", RoleIDs: []string{"r-admin"}}, + } + // alice keeps; bob → operator; carol → bogus role (no change). + in := bufio.NewReader(strings.NewReader("\noperator\nbogus\n")) + var out bytes.Buffer + plan, err := buildScopeDownPlan(keys, in, &out) + if err != nil { + t.Fatalf("plan err = %v", err) + } + if len(plan) != 1 { + t.Fatalf("plan size = %d, want 1 (only bob changes)", len(plan)) + } + if plan[0].ActorID != "bob" || plan[0].TargetRole != "r-operator" { + t.Errorf("plan[0] = %+v, want bob → r-operator", plan[0]) + } +} + +// TestBuildScopeDownPlan_ApplyRolePrefix pins that the "operator" +// input becomes "r-operator" downstream — the API accepts the +// prefixed role IDs and the plan-builder normalizes. +func TestBuildScopeDownPlan_ApplyRolePrefix(t *testing.T) { + keys := []AuthKeyEntry{{ActorID: "alice", RoleIDs: []string{"r-admin"}}} + for _, role := range []string{"admin", "operator", "viewer", "agent", "mcp", "cli", "auditor"} { + in := bufio.NewReader(strings.NewReader(role + "\n")) + var out bytes.Buffer + plan, err := buildScopeDownPlan(keys, in, &out) + if err != nil { + t.Fatalf("role=%s: %v", role, err) + } + if len(plan) != 1 || plan[0].TargetRole != "r-"+role { + t.Errorf("role=%s: plan[0].TargetRole = %q, want r-%s", role, plan[0].TargetRole, role) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 168131e..422009d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1566,6 +1566,25 @@ type AuthConfig struct { // Generation guidance: `openssl rand -hex 32` (256-bit entropy). // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable. AgentBootstrapToken string + + // BootstrapToken is the one-shot pre-shared secret that gates the + // Bundle 1 Phase 6 bootstrap endpoint (POST /v1/auth/bootstrap). When + // set at server startup AND no admin-roled actors exist, the + // bootstrap endpoint becomes callable: an operator POSTs the token + // and a desired admin-key name; the server mints a fresh API key, + // grants it the r-admin role, and returns the key value once. The + // token is then invalidated in memory; subsequent calls return 410 + // Gone. The endpoint also returns 410 Gone when admin actors already + // exist (no need for the bootstrap path). + // + // Server NEVER logs this token. The minted admin key is returned in + // the HTTP response body only; not logged. Operators who lose track + // of the minted key can rotate it via the regular RBAC API after + // bootstrap. + // + // Generation guidance: `openssl rand -hex 32` (256-bit entropy). + // Setting: CERTCTL_BOOTSTRAP_TOKEN environment variable. + BootstrapToken string } // RateLimitConfig contains rate limiting configuration. @@ -1687,6 +1706,10 @@ func Load() (*Config, error) { // Bundle-5 / Audit H-007: agent-registration bootstrap secret. // Empty (default) = warn-mode pass-through; v2.2.0 will require it. AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""), + // Bundle 1 Phase 6: one-shot bootstrap token for the + // /v1/auth/bootstrap endpoint that mints the first admin + // key. Empty = bootstrap endpoint disabled (default). + BootstrapToken: getEnv("CERTCTL_BOOTSTRAP_TOKEN", ""), }, RateLimit: RateLimitConfig{ Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true), diff --git a/internal/domain/audit.go b/internal/domain/audit.go index 0c29ee9..aae3e1a 100644 --- a/internal/domain/audit.go +++ b/internal/domain/audit.go @@ -15,8 +15,36 @@ type AuditEvent struct { ResourceID string `json:"resource_id"` Details json.RawMessage `json:"details"` Timestamp time.Time `json:"timestamp"` + + // EventCategory (Bundle 1 Phase 8) classifies the event into one + // of "cert_lifecycle", "auth", or "config" so the auditor role + // can filter to authentication / authorization events without + // also seeing every cert.issue. The persistence layer treats an + // empty value as "cert_lifecycle" (the migration default + the + // DB CHECK constraint). + EventCategory string `json:"event_category,omitempty"` } +// Audit event-category constants. Bundle 1 Phase 8 ships exactly +// three; future bundles extend the enum (and the migration's CHECK +// constraint) without reshaping the column. +const ( + // EventCategoryCertLifecycle is the default for cert.* / + // agent.* / deployment.* / verification.* events. + EventCategoryCertLifecycle = "cert_lifecycle" + + // EventCategoryAuth covers every auth.role.* / auth.key.* / + // auth.bootstrap.* event plus the bootstrap.consume action + // recorded by Phase 6. Auditors filter to this category to + // review who minted / granted / revoked roles. + EventCategoryAuth = "auth" + + // EventCategoryConfig covers issuer / target / settings + // mutations. Distinct from cert_lifecycle so a regulator can + // review configuration changes separately from cert ops. + EventCategoryConfig = "config" +) + // ActorType represents the entity performing an action. type ActorType string diff --git a/internal/domain/auth/apikey.go b/internal/domain/auth/apikey.go new file mode 100644 index 0000000..a215e44 --- /dev/null +++ b/internal/domain/auth/apikey.go @@ -0,0 +1,24 @@ +package auth + +import "time" + +// APIKey is the runtime-minted operator API key (Bundle 1 Phase 6). +// Stored in the `api_keys` table with the SHA-256 hash of the key +// value; the plaintext is returned to the operator on creation and +// never persisted. Name is the canonical actor identity that joins +// against actor_roles.actor_id. The Admin flag is a denormalized hint +// replicated from the actor's standing role grant so the auth +// middleware can populate the legacy AdminKey context without joining +// actor_roles on every request; the actor_roles row remains the +// source of truth for authorization. +type APIKey struct { + ID string `json:"id"` // prefix `ak-` + Name string `json:"name"` + KeyHash string `json:"-"` // never serialized + TenantID string `json:"tenant_id"` + Admin bool `json:"admin"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} diff --git a/internal/domain/auth/auditor_test.go b/internal/domain/auth/auditor_test.go new file mode 100644 index 0000000..9026b97 --- /dev/null +++ b/internal/domain/auth/auditor_test.go @@ -0,0 +1,83 @@ +package auth + +import "testing" + +// ============================================================================= +// Bundle 1 Phase 8 — auditor role invariants. Pin the seeded permission +// set so a future refactor that accidentally widens it gets caught. +// ============================================================================= + +// TestAuditorRoleHoldsExactlyAuditReadAndExport pins the load-bearing +// invariant that the auditor role has read-only audit access AND +// nothing else. Any drift here breaks the SOC 2 / FedRAMP separation +// the prompt requires. +func TestAuditorRoleHoldsExactlyAuditReadAndExport(t *testing.T) { + got, ok := DefaultRoles[RoleIDAuditor] + if !ok { + t.Fatalf("auditor role missing from DefaultRoles") + } + want := map[string]bool{ + "audit.read": true, + "audit.export": true, + } + if len(got) != len(want) { + t.Errorf("auditor permission count = %d, want %d (auditor role widened?)", len(got), len(want)) + } + for _, p := range got { + if !want[p] { + t.Errorf("auditor holds %q but should not — auditor must be read-only", p) + } + } + for w := range want { + found := false + for _, p := range got { + if p == w { + found = true + break + } + } + if !found { + t.Errorf("auditor role missing %q", w) + } + } +} + +// TestAuditorRoleDoesNotHoldMutatingOrReadingNonAuditPerms pins that +// the auditor role grants ZERO mutating perms (cert.*, profile.*, +// issuer.*, target.*, agent.*) AND zero non-audit read perms. The +// auditor is "audit-only", not "read-only across everything". +func TestAuditorRoleDoesNotHoldMutatingOrReadingNonAuditPerms(t *testing.T) { + got := DefaultRoles[RoleIDAuditor] + for _, p := range got { + switch p { + case "audit.read", "audit.export": + // allowed + default: + t.Errorf("auditor holds non-audit permission %q — should be audit-only", p) + } + } +} + +// TestAuditorRoleSeparateFromViewer pins that auditor and viewer +// permission sets are disjoint EXCEPT for nothing — viewer gets +// resource-read perms (cert/profile/issuer/target/agent) which auditor +// must NOT inherit. Closes the "auditor sees customer cert data" leg. +func TestAuditorRoleSeparateFromViewer(t *testing.T) { + auditorSet := map[string]bool{} + for _, p := range DefaultRoles[RoleIDAuditor] { + auditorSet[p] = true + } + viewerSet := map[string]bool{} + for _, p := range DefaultRoles[RoleIDViewer] { + viewerSet[p] = true + } + for v := range viewerSet { + if v == "audit.read" { + // shared by design (viewer can read audit) + continue + } + if auditorSet[v] { + t.Errorf("auditor inherits viewer permission %q — must be disjoint except audit.read", v) + } + } +} diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 7c44e5e..3c5e789 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -101,6 +101,40 @@ type ActorRoleRepository interface { // gated request; implementations should cache or use SQL JOINs // for performance. EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]EffectivePermission, error) + + // AdminExists reports whether ANY actor in the tenant currently + // holds the r-admin role. Bundle 1 Phase 6's bootstrap probe + // uses this to gate the day-0 endpoint: once the answer flips + // from false to true the bootstrap path stays closed forever + // (the seeded actor-demo-anon admin only exists in demo mode; + // in api-key mode the operator either uses bootstrap or + // CERTCTL_API_KEYS_NAMED to mint the first admin). The query + // excludes the synthetic actor-demo-anon so demo-mode deploys + // can still bootstrap a real admin if/when the operator + // switches to api-key mode without re-migrating. + AdminExists(ctx context.Context, tenantID string) (bool, error) + + // ListDistinctActors returns one row per (actor_id, actor_type) + // pair with at least one actor_roles grant in the tenant. + // Bundle 1 Phase 7's `auth keys list` + scope-down helper use + // this to enumerate the actor population without joining + // against the env-var-loaded namedKeys (whose canonical record + // is the actor_roles backfill from Phase 1 / C2). The synthetic + // actor-demo-anon is included so the GUI can render it as + // "system-managed, scope-down hidden"; Phase 7's interactive + // flow filters it out of the prompt loop. + ListDistinctActors(ctx context.Context, tenantID string) ([]ActorWithRoles, error) +} + +// ActorWithRoles is the (actor, roles) projection returned by +// ActorRoleRepository.ListDistinctActors. Roles is the slice of role +// IDs the actor holds; the caller can resolve role names via the +// RoleRepository or the CLI's already-cached role list. +type ActorWithRoles struct { + ActorID string + ActorType authdomain.ActorTypeValue + TenantID string + RoleIDs []string } // EffectivePermission is the (permission, scope) pair returned by @@ -112,3 +146,25 @@ type EffectivePermission struct { ScopeType authdomain.ScopeType ScopeID *string // NULL = global } + +// APIKeyRepository wraps the api_keys table. Bundle 1 Phase 6 ships +// this so the bootstrap endpoint (POST /v1/auth/bootstrap) can mint +// the first admin API key without needing the operator to roundtrip +// through CERTCTL_API_KEYS_NAMED. Operator-tier keys live here; +// agent-tier keys remain on the agents table (`api_key_hash` column). +type APIKeyRepository interface { + // Create stores a new key row. ID + CreatedAt default if zero. + // The plaintext key is NOT stored — callers pass only the + // SHA-256 hex hash. Returns ErrAuthDuplicateName when the + // (name) UNIQUE constraint fires. + Create(ctx context.Context, key *authdomain.APIKey) error + // GetByName returns a single row by operator-visible name. + // Returns ErrAuthNotFound when no row matches. + GetByName(ctx context.Context, name string) (*authdomain.APIKey, error) + // List returns every key row across the tenant. Bundle 1 ships + // single-tenant so tenantID is typically t-default. + List(ctx context.Context, tenantID string) ([]*authdomain.APIKey, error) + // Delete removes a key row by name. Used by the RBAC API's key + // rotation/revocation paths. + Delete(ctx context.Context, name string) error +} diff --git a/internal/repository/filters.go b/internal/repository/filters.go index 111a2d3..ebf237b 100644 --- a/internal/repository/filters.go +++ b/internal/repository/filters.go @@ -47,10 +47,14 @@ type AuditFilter struct { ActorType string // "user", "agent", "system" ResourceType string // e.g., "certificate", "policy", "agent" ResourceID string - From time.Time - To time.Time - Page int - PerPage int + // EventCategory (Bundle 1 Phase 8) filters by event_category + // column. Allowed values: "cert_lifecycle", "auth", "config". + // Empty string disables the filter (all categories returned). + EventCategory string + From time.Time + To time.Time + Page int + PerPage int } // NotificationFilter defines filtering criteria for notification queries. diff --git a/internal/repository/postgres/audit.go b/internal/repository/postgres/audit.go index a55b540..d9c934c 100644 --- a/internal/repository/postgres/audit.go +++ b/internal/repository/postgres/audit.go @@ -39,14 +39,21 @@ func (r *AuditRepository) CreateWithTx(ctx context.Context, q repository.Querier if event.ID == "" { event.ID = uuid.New().String() } + // Bundle 1 Phase 8: empty EventCategory defaults to + // cert_lifecycle (matches the migration's DEFAULT clause + the + // DB CHECK constraint). The boundary catches callers that + // haven't yet been migrated to the categorized API. + if event.EventCategory == "" { + event.EventCategory = domain.EventCategoryCertLifecycle + } err := q.QueryRowContext(ctx, ` INSERT INTO audit_events ( - id, actor, actor_type, action, resource_type, resource_id, details, timestamp - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + id, actor, actor_type, action, resource_type, resource_id, details, timestamp, event_category + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id `, event.ID, event.Actor, event.ActorType, event.Action, event.ResourceType, - event.ResourceID, event.Details, event.Timestamp).Scan(&event.ID) + event.ResourceID, event.Details, event.Timestamp, event.EventCategory).Scan(&event.ID) if err != nil { return fmt.Errorf("failed to create audit event: %w", err) @@ -104,6 +111,11 @@ func (r *AuditRepository) List(ctx context.Context, filter *repository.AuditFilt args = append(args, filter.To) argCount++ } + if filter.EventCategory != "" { + whereConditions = append(whereConditions, fmt.Sprintf("event_category = $%d", argCount)) + args = append(args, filter.EventCategory) + argCount++ + } whereClause := "" if len(whereConditions) > 0 { @@ -120,7 +132,7 @@ func (r *AuditRepository) List(ctx context.Context, filter *repository.AuditFilt // Get paginated results offset := (filter.Page - 1) * filter.PerPage query := fmt.Sprintf(` - SELECT id, actor, actor_type, action, resource_type, resource_id, details, timestamp + SELECT id, actor, actor_type, action, resource_type, resource_id, details, timestamp, event_category FROM audit_events %s ORDER BY timestamp DESC @@ -139,7 +151,7 @@ func (r *AuditRepository) List(ctx context.Context, filter *repository.AuditFilt for rows.Next() { var event domain.AuditEvent if err := rows.Scan(&event.ID, &event.Actor, &event.ActorType, &event.Action, - &event.ResourceType, &event.ResourceID, &event.Details, &event.Timestamp); err != nil { + &event.ResourceType, &event.ResourceID, &event.Details, &event.Timestamp, &event.EventCategory); err != nil { return nil, fmt.Errorf("failed to scan audit event: %w", err) } events = append(events, &event) diff --git a/internal/repository/postgres/auth.go b/internal/repository/postgres/auth.go index e4fcfda..ff04e88 100644 --- a/internal/repository/postgres/auth.go +++ b/internal/repository/postgres/auth.go @@ -388,6 +388,61 @@ func (r *ActorRoleRepository) Revoke(ctx context.Context, actorID string, actorT return nil } +func (r *ActorRoleRepository) ListDistinctActors(ctx context.Context, tenantID string) ([]repository.ActorWithRoles, error) { + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + rows, err := r.db.QueryContext(ctx, ` + SELECT actor_id, actor_type, + array_agg(role_id ORDER BY role_id) AS role_ids + FROM actor_roles + WHERE tenant_id = $1 + AND (expires_at IS NULL OR expires_at > NOW()) + GROUP BY actor_id, actor_type + ORDER BY actor_id ASC + `, tenantID) + if err != nil { + return nil, fmt.Errorf("actorRole.listDistinctActors: %w", err) + } + defer rows.Close() + var out []repository.ActorWithRoles + for rows.Next() { + var a repository.ActorWithRoles + var actorType string + // pq.StringArray decodes the postgres array_agg result. + var roles pq.StringArray + if err := rows.Scan(&a.ActorID, &actorType, &roles); err != nil { + return nil, fmt.Errorf("actorRole.listDistinctActors scan: %w", err) + } + a.ActorType = authdomain.ActorTypeValue(actorType) + a.TenantID = tenantID + a.RoleIDs = []string(roles) + out = append(out, a) + } + return out, rows.Err() +} + +func (r *ActorRoleRepository) AdminExists(ctx context.Context, tenantID string) (bool, error) { + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + // Exclude the seeded synthetic demo actor so a demo deploy that + // later switches to api-key mode can still bootstrap the first + // real admin. Matches the carve-out documented on the interface. + var count int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM actor_roles + WHERE role_id = $1 + AND tenant_id = $2 + AND actor_id != $3 + AND (expires_at IS NULL OR expires_at > NOW()) + `, authdomain.RoleIDAdmin, tenantID, authdomain.DemoAnonActorID).Scan(&count) + if err != nil { + return false, fmt.Errorf("actorRole.adminExists: %w", err) + } + return count > 0, nil +} + func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) { rows, err := r.db.QueryContext(ctx, ` SELECT DISTINCT p.name, rp.scope_type, rp.scope_id @@ -440,3 +495,115 @@ func scanActorRoles(rows *sql.Rows) ([]*authdomain.ActorRole, error) { } return out, rows.Err() } + +// ============================================================================= +// APIKeyRepository (Bundle 1 Phase 6 — bootstrap path) +// ============================================================================= + +// APIKeyRepository is the postgres implementation of +// repository.APIKeyRepository. Stores SHA-256 hashes only; the +// plaintext key value is never persisted. +type APIKeyRepository struct { + db *sql.DB +} + +// NewAPIKeyRepository constructs an APIKeyRepository. +func NewAPIKeyRepository(db *sql.DB) *APIKeyRepository { + return &APIKeyRepository{db: db} +} + +func (r *APIKeyRepository) Create(ctx context.Context, k *authdomain.APIKey) error { + if k.ID == "" { + k.ID = "ak-" + uuid.NewString() + } + if k.TenantID == "" { + k.TenantID = authdomain.DefaultTenantID + } + if k.CreatedAt.IsZero() { + k.CreatedAt = time.Now().UTC() + } + var expires interface{} + if k.ExpiresAt != nil { + expires = *k.ExpiresAt + } + _, err := r.db.ExecContext(ctx, ` + INSERT INTO api_keys (id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, k.ID, k.Name, k.KeyHash, k.TenantID, k.Admin, k.CreatedBy, k.CreatedAt, expires) + if err != nil { + // Translate UNIQUE-constraint violations to the canonical + // auth sentinel so the service layer can return 409. + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return repository.ErrAuthDuplicateName + } + return fmt.Errorf("apiKey.create: %w", err) + } + return nil +} + +func (r *APIKeyRepository) GetByName(ctx context.Context, name string) (*authdomain.APIKey, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at, last_used_at + FROM api_keys WHERE name = $1 + `, name) + var k authdomain.APIKey + var expires, lastUsed sql.NullTime + if err := row.Scan(&k.ID, &k.Name, &k.KeyHash, &k.TenantID, &k.Admin, &k.CreatedBy, &k.CreatedAt, &expires, &lastUsed); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, repository.ErrAuthNotFound + } + return nil, fmt.Errorf("apiKey.getByName: %w", err) + } + if expires.Valid { + t := expires.Time + k.ExpiresAt = &t + } + if lastUsed.Valid { + t := lastUsed.Time + k.LastUsedAt = &t + } + return &k, nil +} + +func (r *APIKeyRepository) List(ctx context.Context, tenantID string) ([]*authdomain.APIKey, error) { + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at, last_used_at + FROM api_keys WHERE tenant_id = $1 ORDER BY created_at DESC + `, tenantID) + if err != nil { + return nil, fmt.Errorf("apiKey.list: %w", err) + } + defer rows.Close() + var out []*authdomain.APIKey + for rows.Next() { + var k authdomain.APIKey + var expires, lastUsed sql.NullTime + if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.TenantID, &k.Admin, &k.CreatedBy, &k.CreatedAt, &expires, &lastUsed); err != nil { + return nil, fmt.Errorf("apiKey.list scan: %w", err) + } + if expires.Valid { + t := expires.Time + k.ExpiresAt = &t + } + if lastUsed.Valid { + t := lastUsed.Time + k.LastUsedAt = &t + } + out = append(out, &k) + } + return out, rows.Err() +} + +func (r *APIKeyRepository) Delete(ctx context.Context, name string) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM api_keys WHERE name = $1`, name) + if err != nil { + return fmt.Errorf("apiKey.delete: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return repository.ErrAuthNotFound + } + return nil +} diff --git a/internal/service/audit.go b/internal/service/audit.go index 7cccc59..b5bdaea 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -31,9 +31,21 @@ func NewAuditService(auditRepo repository.AuditRepository) *AuditService { // `redacted_keys` array so operators can audit the redactor itself during // a compliance review. See internal/service/audit_redact.go. func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error { - // Bundle-6: scrub credentials + PII before persistence. Returns nil - // for nil/empty input, preserving pre-Bundle-6 behaviour for callers - // that pass nil details. + return s.RecordEventWithCategory(ctx, actor, actorType, action, "", resourceType, resourceID, details) +} + +// RecordEventWithCategory is the Bundle 1 Phase 8 categorized variant +// of RecordEvent. eventCategory is one of +// domain.EventCategoryCertLifecycle, domain.EventCategoryAuth, +// domain.EventCategoryConfig — empty defaults to cert_lifecycle in +// the persistence layer + DB CHECK constraint. +// +// Existing 90+ call sites that don't yet pass a category route +// through the legacy RecordEvent and inherit the cert_lifecycle +// default; new callers (auth handlers, bootstrap, config-mutation +// handlers) call this method directly with their explicit category. +// Both paths share the same redaction + marshaling contract. +func (s *AuditService) RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, details map[string]interface{}) error { redacted := RedactDetailsForAudit(details) detailsJSON, err := json.Marshal(redacted) if err != nil { @@ -41,14 +53,15 @@ func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType } event := &domain.AuditEvent{ - ID: generateID("audit"), - Timestamp: time.Now(), - Actor: actor, - ActorType: actorType, - Action: action, - ResourceType: resourceType, - ResourceID: resourceID, - Details: json.RawMessage(detailsJSON), + ID: generateID("audit"), + Timestamp: time.Now(), + Actor: actor, + ActorType: actorType, + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + Details: json.RawMessage(detailsJSON), + EventCategory: eventCategory, } if err := s.auditRepo.Create(ctx, event); err != nil { @@ -157,6 +170,12 @@ func (s *AuditService) ListByAction(ctx context.Context, action string, from, to // ListAuditEvents returns paginated audit events (handler interface method). func (s *AuditService) ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) { + return s.ListAuditEventsByCategory(ctx, "", page, perPage) +} + +// ListAuditEventsByCategory is the Bundle 1 Phase 8 categorized variant. +// Empty eventCategory disables the filter. +func (s *AuditService) ListAuditEventsByCategory(ctx context.Context, eventCategory string, page, perPage int) ([]domain.AuditEvent, int64, error) { if page < 1 { page = 1 } @@ -165,8 +184,9 @@ func (s *AuditService) ListAuditEvents(ctx context.Context, page, perPage int) ( } filter := &repository.AuditFilter{ - Page: page, - PerPage: perPage, + EventCategory: eventCategory, + Page: page, + PerPage: perPage, } events, err := s.auditRepo.List(ctx, filter) diff --git a/internal/service/auth/actor_role_service.go b/internal/service/auth/actor_role_service.go index 2875d02..3851eab 100644 --- a/internal/service/auth/actor_role_service.go +++ b/internal/service/auth/actor_role_service.go @@ -137,6 +137,27 @@ func (s *ActorRoleService) EffectivePermissions(ctx context.Context, caller *Cal return s.repo.EffectivePermissions(ctx, actorID, authdomain.ActorTypeValue(actorType), s.tenantOf(caller)) } +// ListKeys (Bundle 1 Phase 7) returns every actor in the tenant that +// holds at least one role grant. Permission `auth.role.list` is +// required (or the caller must be system). The CLI's `auth keys list` +// + scope-down helper consume this to enumerate the operator-key +// population without a separate /v1/auth/keys-by-name surface. +func (s *ActorRoleService) ListKeys(ctx context.Context, caller *Caller) ([]repository.ActorWithRoles, error) { + if caller == nil { + return nil, ErrUnauthenticated + } + if !caller.IsSystem { + ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list") + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("%w: auth.role.list required to list keys", ErrForbidden) + } + } + return s.repo.ListDistinctActors(ctx, s.tenantOf(caller)) +} + func (s *ActorRoleService) tenantOf(caller *Caller) string { if caller != nil && caller.TenantID != "" { return caller.TenantID @@ -148,5 +169,9 @@ func (s *ActorRoleService) recordAudit(ctx context.Context, caller *Caller, acti if s.audit == nil || caller == nil { return } - _ = s.audit.RecordEvent(ctx, caller.ActorID, caller.ActorType, action, resourceType, resourceID, details) + // Bundle 1 Phase 8: every actor-role grant/revoke is an + // authentication / authorization event. The auditor role queries + // /v1/audit?category=auth to surface this slice without + // also pulling in cert.* events. + _ = s.audit.RecordEventWithCategory(ctx, caller.ActorID, caller.ActorType, action, domain.EventCategoryAuth, resourceType, resourceID, details) } diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 604da00..c5b54fa 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -49,7 +49,10 @@ var ( // AuditService is the audit-recording dependency the service layer // expects. Mirrors the existing service.AuditService interface so -// Bundle 1 doesn't introduce a parallel concept. +// Bundle 1 doesn't introduce a parallel concept. Bundle 1 Phase 8 +// adds RecordEventWithCategory; the auth service uses the +// categorized variant exclusively (event_category=auth) so the +// auditor role can filter to authentication / authorization events. type AuditService interface { RecordEvent( ctx context.Context, @@ -58,6 +61,13 @@ type AuditService interface { action, resourceType, resourceID string, details map[string]interface{}, ) error + RecordEventWithCategory( + ctx context.Context, + actor string, + actorType domain.ActorType, + action, eventCategory, resourceType, resourceID string, + details map[string]interface{}, + ) error } // Caller describes the actor performing a service operation. Bundle 1 diff --git a/internal/service/auth/role_service.go b/internal/service/auth/role_service.go index 1388d08..41e1995 100644 --- a/internal/service/auth/role_service.go +++ b/internal/service/auth/role_service.go @@ -191,11 +191,15 @@ func (s *RoleService) requirePermission(ctx context.Context, caller *Caller, per // recordAudit emits an audit row tied to the caller. Best-effort: audit // failures are logged via panic-recover but do not fail the operation. +// +// Bundle 1 Phase 8: every role-mutation is an authentication / +// authorization event. The auditor role queries +// /v1/audit?category=auth to surface this slice. func (s *RoleService) recordAudit(ctx context.Context, caller *Caller, action, resourceType, resourceID string, details map[string]interface{}) { if s.audit == nil || caller == nil { return } - _ = s.audit.RecordEvent(ctx, caller.ActorID, caller.ActorType, action, resourceType, resourceID, details) + _ = s.audit.RecordEventWithCategory(ctx, caller.ActorID, caller.ActorType, action, domain.EventCategoryAuth, resourceType, resourceID, details) } // Ensure the compile-time pin: domain.ActorType is convertible to diff --git a/internal/service/auth/service_test.go b/internal/service/auth/service_test.go index 70c9cc9..cfd6983 100644 --- a/internal/service/auth/service_test.go +++ b/internal/service/auth/service_test.go @@ -170,19 +170,53 @@ func (f *fakeActorRoleRepo) Revoke(_ context.Context, actorID string, actorType f.grants = out return nil } +func (f *fakeActorRoleRepo) AdminExists(_ context.Context, _ string) (bool, error) { + for _, g := range f.grants { + if g.RoleID == authdomain.RoleIDAdmin && g.ActorID != authdomain.DemoAnonActorID { + return true, nil + } + } + return false, nil +} +func (f *fakeActorRoleRepo) ListDistinctActors(_ context.Context, _ string) ([]repository.ActorWithRoles, error) { + seen := map[string]*repository.ActorWithRoles{} + for _, g := range f.grants { + k := string(g.ActorType) + ":" + g.ActorID + if seen[k] == nil { + seen[k] = &repository.ActorWithRoles{ + ActorID: g.ActorID, + ActorType: g.ActorType, + TenantID: g.TenantID, + } + } + seen[k].RoleIDs = append(seen[k].RoleIDs, g.RoleID) + } + out := make([]repository.ActorWithRoles, 0, len(seen)) + for _, v := range seen { + out = append(out, *v) + } + return out, nil +} func (f *fakeActorRoleRepo) EffectivePermissions(_ context.Context, actorID string, actorType authdomain.ActorTypeValue, _ string) ([]repository.EffectivePermission, error) { return f.perms[actorKey(actorID, actorType)], nil } type fakeAudit struct { calls []struct { - Actor, ActorType, Action, ResourceID string + Actor, ActorType, Action, Category, ResourceID string } } func (f *fakeAudit) RecordEvent(_ context.Context, actor string, actorType domain.ActorType, action, resourceType, resourceID string, _ map[string]interface{}) error { - f.calls = append(f.calls, struct{ Actor, ActorType, Action, ResourceID string }{ - actor, string(actorType), action, resourceID, + f.calls = append(f.calls, struct{ Actor, ActorType, Action, Category, ResourceID string }{ + actor, string(actorType), action, "", resourceID, + }) + return nil +} + +func (f *fakeAudit) RecordEventWithCategory(_ context.Context, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, _ map[string]interface{}) error { + f.calls = append(f.calls, struct{ Actor, ActorType, Action, Category, ResourceID string }{ + actor, string(actorType), action, eventCategory, resourceID, }) return nil } diff --git a/migrations/000031_api_keys.down.sql b/migrations/000031_api_keys.down.sql new file mode 100644 index 0000000..7203a0c --- /dev/null +++ b/migrations/000031_api_keys.down.sql @@ -0,0 +1,7 @@ +-- Bundle 1 Phase 6: drop the operator API-keys table. Down is destructive; +-- keys minted by bootstrap will fail to authenticate after this runs. +BEGIN; +DROP INDEX IF EXISTS idx_api_keys_created_by; +DROP INDEX IF EXISTS idx_api_keys_tenant_id; +DROP TABLE IF EXISTS api_keys; +COMMIT; diff --git a/migrations/000031_api_keys.up.sql b/migrations/000031_api_keys.up.sql new file mode 100644 index 0000000..9c741c2 --- /dev/null +++ b/migrations/000031_api_keys.up.sql @@ -0,0 +1,47 @@ +-- Bundle 1 Phase 6 (bootstrap path): runtime-minted operator API keys. +-- +-- Pre-Bundle-1 the only operator API keys lived in CERTCTL_API_KEYS_NAMED +-- (env-var config; static at boot). The bootstrap endpoint +-- POST /v1/auth/bootstrap mints the first admin key without requiring +-- the operator to know the env-var format up front; that key has to +-- survive a process restart and authenticate against the auth +-- middleware's keystore on subsequent requests, which means it lives +-- here. +-- +-- Storage rules: ONLY the SHA-256 hash of the key value is stored +-- (key_hash). The plaintext key value is returned to the operator in +-- the bootstrap HTTP response body once and never persisted. Lost? +-- Mint a new admin key via the regular RBAC API and revoke the old +-- one — the api_keys row is the source of truth for "this name + +-- hash authenticates", so revoking it via the RBAC API removes the +-- row and the next request lookup fails 401. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS, indexes IF NOT EXISTS. + +BEGIN; + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, -- prefix `ak-` + name TEXT NOT NULL UNIQUE, -- operator-visible name; matches actor_roles.actor_id + key_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of the plaintext key + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + -- Admin is a denormalized hint replicated from the actor's + -- standing role grant so the auth middleware can populate + -- AdminKey context without joining actor_roles on every request. + -- Source of truth remains actor_roles; this column is rebuilt by + -- the boot loader from "actor holds r-admin?" queries. + admin BOOLEAN NOT NULL DEFAULT FALSE, + created_by TEXT NOT NULL, -- actor_id of the creator; "bootstrap" for the first one + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Decoration columns for forward-compat: bundle 2 will add + -- expiry + last_used + rotation tracking. Reserved as nullable + -- now so the migration in Bundle 2 doesn't reshape the table. + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_id ON api_keys(tenant_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_created_by ON api_keys(created_by); + +COMMIT; diff --git a/migrations/000032_audit_category.down.sql b/migrations/000032_audit_category.down.sql new file mode 100644 index 0000000..7a3bcae --- /dev/null +++ b/migrations/000032_audit_category.down.sql @@ -0,0 +1,8 @@ +-- Bundle 1 Phase 8 down: drop the event_category column + indexes. +-- Destructive — auditor-filter queries stop working after this runs. +BEGIN; +DROP INDEX IF EXISTS idx_audit_events_category_timestamp; +DROP INDEX IF EXISTS idx_audit_events_event_category; +ALTER TABLE audit_events DROP CONSTRAINT IF EXISTS audit_events_event_category_check; +ALTER TABLE audit_events DROP COLUMN IF EXISTS event_category; +COMMIT; diff --git a/migrations/000032_audit_category.up.sql b/migrations/000032_audit_category.up.sql new file mode 100644 index 0000000..d1bedb4 --- /dev/null +++ b/migrations/000032_audit_category.up.sql @@ -0,0 +1,62 @@ +-- Bundle 1 Phase 8 — categorize audit events. +-- +-- Why: post-Phase-1 the auditor role holds only audit.read + +-- audit.export. Without a category column the auditor surface +-- co-mingles cert-lifecycle events with auth-config mutations and +-- config edits, which makes a "show me only the auth changes from +-- last week" query impossible. Phase 8 adds the column + enum CHECK +-- constraint + index so auditors can filter to the slice they care +-- about. +-- +-- Storage rules: +-- +-- - cert_lifecycle (default): cert.issue, cert.renew, cert.revoke, +-- cert.bulk_revoke, deployment.*, agent.heartbeat, etc. +-- Existing rows backfill here. +-- - auth: every auth.role.* / auth.key.* / auth.bootstrap.* event, +-- plus the day-0 bootstrap.consume action from Phase 6. +-- - config: issuer config edits, target config edits, settings +-- mutations. Distinct from cert_lifecycle so a regulator can +-- review "who changed the issuer wiring" separately from "who +-- issued certs". +-- +-- WORM trigger continues to enforce append-only at the DB layer +-- (migration 000018). The ALTER TABLE itself is DDL, not DML, so +-- it's not blocked by the trigger. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS, ADD CONSTRAINT IF NOT EXISTS +-- (Postgres 15+; uses DO blocks for older versions). The migration +-- runner re-applies safely if the migration was partially completed. + +BEGIN; + +ALTER TABLE audit_events + ADD COLUMN IF NOT EXISTS event_category TEXT NOT NULL DEFAULT 'cert_lifecycle'; + +-- CHECK constraint (idempotent via DO block; ADD CONSTRAINT IF NOT +-- EXISTS is Postgres 15+ only). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'audit_events_event_category_check' + ) THEN + ALTER TABLE audit_events + ADD CONSTRAINT audit_events_event_category_check + CHECK (event_category IN ('cert_lifecycle', 'auth', 'config')); + END IF; +END$$; + +-- Index for the auditor-filter query path. Single-column btree +-- because event_category is low-cardinality (3 values today); the +-- planner can still bitmap-scan with a small index. +CREATE INDEX IF NOT EXISTS idx_audit_events_event_category + ON audit_events(event_category); + +-- Composite index for the most common auditor query: "auth events +-- from last 7 days, newest first". The (category, timestamp DESC) +-- shape lets the planner serve LIMIT-20 dashboards without sorting. +CREATE INDEX IF NOT EXISTS idx_audit_events_category_timestamp + ON audit_events(event_category, timestamp DESC); + +COMMIT;