mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
auth-bundle-1 Phase 6-7-8: bootstrap path + scope-down CLI + auditor-role split
# Phase 6 — day-0 admin bootstrap * internal/auth/bootstrap/ (new package): Strategy interface + EnvTokenStrategy with constant-time compare, one-shot consumption via sync.Mutex, optional admin-existence probe. Bundle 2's OIDC- first-admin will plug in alongside as an alternate Strategy. * BootstrapService.ValidateAndMint: validates the operator's CERTCTL_BOOTSTRAP_TOKEN, mints a 32-byte (64-hex-char) random API key value, persists the SHA-256 hash to api_keys, grants r-admin via actor_roles, AddHashed's the runtime keystore so the just- minted key authenticates the next request without restart, and records bootstrap.consume to the audit trail with category=auth. * internal/auth/keystore.go (new): KeyStore interface + StaticKeyStore (immutable env-var-only path) + MutableKeyStore (env-var keys + DB-loaded api_keys + runtime AddHashed). The auth middleware now consumes a KeyStore so the bootstrap path can extend the lookup table at runtime. * migrations/000031_api_keys.up/down.sql: api_keys table with (id, name UNIQUE, key_hash UNIQUE, tenant_id, admin, created_by, created_at, expires_at, last_used_at). Idempotent. * /v1/auth/bootstrap GET (probe) + POST (mint) — auth-exempt. Both routes documented in api/openapi.yaml + AuthExemptRouterRoutes allowlist updated. The token never leaves internal/auth/bootstrap; the minted plaintext key flows only into the HTTP response body. * Startup warning emitted when CERTCTL_BOOTSTRAP_TOKEN is set AND admin actors already exist (config drift signal). * Tests: 4 strategy invariants (empty token born disabled, wrong token=ErrInvalidToken without consumption, one-shot consumption, admin-exists closes path), 5 service tests (happy path + actor- name validation + propagation of strategy errors + nil-deps guard + 32-byte entropy budget), 8 HTTP-handler tests (status 201/410/401/400 mapping + token-leak hygiene scan of slog + audit details + Location header). Token-leak test redirects slog.Default to a buffer for the test scope. # Phase 7 — API-key migration + scope-down CLI * GET /v1/auth/keys handler + service method ListKeys backed by ActorRoleRepository.ListDistinctActors. Returns one row per (actor_id, actor_type) pair with the slice of role IDs they hold. Permission: auth.role.list. * internal/cli/auth_scope_down.go: AuthListKeys, AuthScopeDown (interactive), AuthScopeDownNonInteractive (JSON config), AuthScopeDownSuggest (--suggest with optional --apply). The synthetic actor-demo-anon is filtered out of every interactive / bulk path; non-interactive flow logs and skips it explicitly. * SuggestRoleFromAuditEvents (pure function): walks 30 days of audit events per actor and returns the narrowest matching role (admin / mcp / viewer / agent / operator) plus a one-line reason. Classification: any admin-shaped action wins; otherwise all-MCP → mcp; all-read-only → viewer; all-agent-shaped → agent; otherwise operator. Test table pins all six classifications. * CLI subcommand tree extended: 'auth keys list' + 'auth keys scope-down [--non-interactive <cfg>] [--suggest [--apply]]'. * CHANGELOG.md leads v2.1.0 with the SECURITY: AUDIT YOUR API KEYS call-out + four flow examples. # Phase 8 — auditor role + event_category column * migrations/000032_audit_category.up/down.sql: ALTER TABLE audit_events ADD COLUMN event_category TEXT NOT NULL DEFAULT 'cert_lifecycle' + CHECK constraint (cert_lifecycle/auth/config) + (event_category) and (event_category, timestamp DESC) indexes for the auditor-filter query path. WORM trigger from migration 000018 continues to enforce append-only at the DB layer (DDL is not blocked). * domain.AuditEvent gains EventCategory string (omitempty); domain.EventCategoryCertLifecycle / Auth / Config constants. * AuditService.RecordEventWithCategory sibling of RecordEvent; legacy callers stay on RecordEvent (defaults to cert_lifecycle). Auth callers (RoleService, ActorRoleService, BootstrapService) switched to RecordEventWithCategory(..., 'auth', ...). * GET /v1/audit?category=<cat>: handler accepts the optional query param, validates against the enum (400 on invalid value), dispatches through ListAuditEventsByCategory. OpenAPI updated with the new query param + AuditEvent.event_category schema. * Postgres AuditRepository.Create now writes event_category; AuditRepository.List filters on it; AuditFilter.EventCategory gates the WHERE clause. * Tests: 5 audit-category-filter HTTP tests (dispatch routing, back-compat fallback, 400 for invalid values, all 3 enum values accepted, page+category combine, JSON output surfaces the field). 3 auditor-role invariants (auditor holds exactly audit.read+audit.export, no mutating perms, disjoint from viewer except audit.read). # Cross-phase wiring * HandlerRegistry.Bootstrap field added; cmd/server/main.go wires the bootstrap service ahead of RegisterHandlers (extracted assembleNamedAPIKeys helper into auth_backfill.go, moved the keystore + bootstrap construction up alongside the auth repos). * AuthCheckResolver / AuthActorRoleService extended with ListKeys to satisfy the Phase 7 surface; existing fakes updated. * fakeAudit + mockAuditService stubs in tests gain RecordEventWithCategory + ListAuditEventsByCategory; existing tests untouched. # Verifications * gofmt -l: clean across every modified file. * go vet ./...: clean. * staticcheck across internal/auth + handler + router + cli + service + repository + cmd + domain: clean. * go test -short -count=1: green across every Bundle-1-touched package — internal/auth (incl. bootstrap), internal/api/handler, internal/api/router, internal/cli, internal/service/auth, internal/service, internal/domain/auth, internal/repository/postgres, cmd/server, cmd/cli, plus internal/scheduler, internal/api/middleware, cmd/agent, internal/mcp.
This commit is contained in:
@@ -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}:<tag>` 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+37
-1
@@ -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 <assign|revoke> [...]\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys <list|assign|revoke|scope-down> [...]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListKeys()
|
||||
case "assign":
|
||||
// auth keys assign <key-id> --role <role-id>
|
||||
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
|
||||
// <config.json>, 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 <config> → 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 <config.json>\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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+69
-63
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+23
-40
@@ -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 <token>"}`, 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))
|
||||
|
||||
@@ -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 <id>` 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-<name> 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+33
-13
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user