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:
shankar0123
2026-05-09 20:15:43 +00:00
parent 99826c11e6
commit 3c605d5618
38 changed files with 3159 additions and 140 deletions
+62
View File
@@ -1,5 +1,67 @@
# Changelog # 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 ⚠️ ## 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. > **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.
+132
View File
@@ -220,6 +220,80 @@ paths:
# lifecycle, `auth.key.*` for key management. Read endpoints require # lifecycle, `auth.key.*` for key management. Read endpoints require
# `auth.role.list`. The /v1/auth/me endpoint has no permission gate # `auth.role.list`. The /v1/auth/me endpoint has no permission gate
# (every authenticated caller can read their own permissions). # (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: /api/v1/auth/me:
get: get:
tags: [Auth] tags: [Auth]
@@ -462,6 +536,43 @@ paths:
"403": { description: Forbidden } "403": { description: Forbidden }
"404": { description: Role or permission grant not found } "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: /api/v1/auth/keys/{id}/roles:
post: post:
tags: [Auth] tags: [Auth]
@@ -3057,10 +3168,22 @@ paths:
get: get:
tags: [Audit] tags: [Audit]
summary: List audit events 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 operationId: listAuditEvents
parameters: parameters:
- $ref: "#/components/parameters/page" - $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_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: responses:
"200": "200":
description: Paginated list of audit events description: Paginated list of audit events
@@ -3075,6 +3198,8 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/AuditEvent" $ref: "#/components/schemas/AuditEvent"
"400":
description: Invalid `category` value
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
@@ -5699,6 +5824,13 @@ components:
timestamp: timestamp:
type: string type: string
format: date-time 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 ─────────────────────────────────────────────── # ─── Notifications ───────────────────────────────────────────────
NotificationType: NotificationType:
+37 -1
View File
@@ -427,10 +427,12 @@ func handleAuthPermissions(client *cli.Client, args []string) error {
func handleAuthKeys(client *cli.Client, args []string) error { func handleAuthKeys(client *cli.Client, args []string) error {
if len(args) == 0 { 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 return nil
} }
switch args[0] { switch args[0] {
case "list":
return client.AuthListKeys()
case "assign": case "assign":
// auth keys assign <key-id> --role <role-id> // auth keys assign <key-id> --role <role-id>
if len(args) < 4 || args[2] != "--role" { if len(args) < 4 || args[2] != "--role" {
@@ -445,8 +447,42 @@ func handleAuthKeys(client *cli.Client, args []string) error {
return nil return nil
} }
return client.AuthRevokeRoleFromKey(args[1], args[3]) 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: default:
fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0]) fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0])
return nil 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
}
}
+47
View File
@@ -2,13 +2,60 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"strings"
"github.com/certctl-io/certctl/internal/auth" "github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/config"
"github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
authdomain "github.com/certctl-io/certctl/internal/domain/auth" 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 // actorRoleGranter is the narrow interface backfillNamedKeyActorRoles
// needs from the postgres ActorRoleRepository. Pulled out so the unit // needs from the postgres ActorRoleRepository. Pulled out so the unit
// test can inject a fake without spinning up the full repo / DB. // test can inject a fake without spinning up the full repo / DB.
+69 -63
View File
@@ -22,6 +22,7 @@ import (
"github.com/certctl-io/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
"github.com/certctl-io/certctl/internal/auth" "github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/auth/bootstrap"
"github.com/certctl-io/certctl/internal/config" "github.com/certctl-io/certctl/internal/config"
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm" discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv" discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
@@ -264,11 +265,68 @@ func main() {
authRoleRepo := postgres.NewRoleRepository(db) authRoleRepo := postgres.NewRoleRepository(db)
authPermRepo := postgres.NewPermissionRepository(db) authPermRepo := postgres.NewPermissionRepository(db)
authActorRoleRepo := postgres.NewActorRoleRepository(db) authActorRoleRepo := postgres.NewActorRoleRepository(db)
authAPIKeyRepo := postgres.NewAPIKeyRepository(db)
authAuthorizer := authsvc.NewAuthorizer(authActorRoleRepo) authAuthorizer := authsvc.NewAuthorizer(authActorRoleRepo)
// authCheckerAdapter bridges authsvc.Authorizer (typed-string args) // authCheckerAdapter bridges authsvc.Authorizer (typed-string args)
// to the auth.PermissionChecker interface (plain-string args) so // to the auth.PermissionChecker interface (plain-string args) so
// internal/auth doesn't have to import internal/service/auth. // internal/auth doesn't have to import internal/service/auth.
authCheckerAdapter := authPermissionCheckerAdapter{a: authAuthorizer} 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 := service.NewPolicyService(policyRepo, auditService)
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules). // G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
@@ -1001,6 +1059,10 @@ func main() {
authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService), authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService),
authCheckerAdapter, 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 // Checker is the load-bearing auth.PermissionChecker that
// auth.RequirePermission middleware uses to gate the legacy admin // auth.RequirePermission middleware uses to gate the legacy admin
// handlers (Bundle 1 Phase 3.5: bulk_revocation, admin_crl_cache, // handlers (Bundle 1 Phase 3.5: bulk_revocation, admin_crl_cache,
@@ -1523,75 +1585,19 @@ func main() {
// Build middleware stack. // Build middleware stack.
// //
// Authentication unification (M-002): every authenticated request now // Bundle 1 Phase 6: namedKeys + authKeyStore + bootstrap service
// carries a named actor in the request context so audit events record // are now constructed earlier (right after the auth repos) so the
// the real key identity instead of the hardcoded "api-key-user" string. // HandlerRegistry can wire the bootstrap handler. The auth
// Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward // middleware below reads from the same authKeyStore reference, so
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N // runtime additions from bootstrap propagate without restart.
// 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.
var authMiddleware func(http.Handler) http.Handler var authMiddleware func(http.Handler) http.Handler
switch config.AuthType(cfg.Auth.Type) { switch config.AuthType(cfg.Auth.Type) {
case config.AuthTypeNone: case config.AuthTypeNone:
authMiddleware = auth.NewDemoModeAuth() authMiddleware = auth.NewDemoModeAuth()
default: default:
authMiddleware = auth.NewAuthWithNamedKeys(namedKeys) authMiddleware = auth.NewAuthWithKeyStore(authKeyStore)
} }
_ = bootstrapHandler // referenced by HandlerRegistry above
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{ corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedOrigins: cfg.CORS.AllowedOrigins,
}) })
+34 -2
View File
@@ -14,6 +14,12 @@ import (
type AuditService interface { type AuditService interface {
ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error)
GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, 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. // AuditHandler handles HTTP requests for audit event operations.
@@ -27,7 +33,12 @@ func NewAuditHandler(svc AuditService) AuditHandler {
} }
// ListAuditEvents lists audit events. // 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) { func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed") Error(w, http.StatusMethodNotAllowed, "Method not allowed")
@@ -49,8 +60,29 @@ func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
perPage = parsed 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 { if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
return return
+157
View File
@@ -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
+13 -2
View File
@@ -15,8 +15,9 @@ import (
// mockAuditService implements AuditService for testing. // mockAuditService implements AuditService for testing.
type mockAuditService struct { type mockAuditService struct {
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error) listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
getFunc func(id string) (*domain.AuditEvent, 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) { 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 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) { func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
if m.getFunc != nil { if m.getFunc != nil {
return m.getFunc(id) return m.getFunc(id)
+39
View File
@@ -58,6 +58,12 @@ type AuthActorRoleService interface {
Revoke(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType, roleID string) error 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) 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) 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 // 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}) 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. // AddRolePermission handles POST /api/v1/auth/roles/{id}/permissions.
func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) { func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) {
caller, err := callerFromRequest(r) caller, err := callerFromRequest(r)
+127
View File
@@ -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.",
})
}
+275
View File
@@ -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
+12
View File
@@ -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) { func (f *fakeAuthActorSvc) EffectivePermissions(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]repository.EffectivePermission, error) {
return f.effective, nil 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 { type fakePermChecker struct {
check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
+29 -4
View File
@@ -78,10 +78,12 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
// The TestRouter_AuthExemptAllowlist regression test below pins the slice // The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI. // to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{ var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer "GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness 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/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key "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 // AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
@@ -131,6 +133,13 @@ type HandlerRegistry struct {
// PermissionService dependencies. Phase 5 ships the CLI mirror. // PermissionService dependencies. Phase 5 ships the CLI mirror.
Auth handler.AuthHandler 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 // Checker is the load-bearing auth.PermissionChecker that
// auth.RequirePermission middleware uses to gate the legacy admin // auth.RequirePermission middleware uses to gate the legacy admin
// handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres // 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) // Auth check endpoint (uses full middleware chain via r.Register)
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck)) 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 // RBAC management routes (Bundle 1 Phase 4). Permission gates are
// enforced inside each handler via the service layer; the Phase 3 // enforced inside each handler via the service layer; the Phase 3
// auth.RequirePermission middleware factory will wrap these in a // 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("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("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("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("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)) r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))
+194
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+204
View File
@@ -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
}
+215
View File
@@ -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
}
}
+157
View File
@@ -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
View File
@@ -2,7 +2,6 @@ package auth
import ( import (
"context" "context"
"crypto/subtle"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -44,27 +43,23 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl
return next return next
} }
} }
if len(namedKeys) == 1 {
// 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 {
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation") 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") 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) http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
return return
} }
// Extract Bearer token
if len(authHeader) < 8 || authHeader[:7] != "Bearer " { if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized) 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:] token := authHeader[7:]
tokenHash := HashAPIKey(token) matched, ok := store.LookupByHash(HashAPIKey(token))
if !ok {
// 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 {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized) http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return return
} }
// Store the authenticated identity and admin flag in context. // Bundle 1 Phase 0 legacy UserKey/AdminKey + Phase 3 RBAC
// Bundle 1 Phase 0: legacy UserKey + AdminKey for back-compat. // ActorIDKey/ActorTypeKey/TenantIDKey are populated on every
// Bundle 1 Phase 3: new ActorIDKey + ActorTypeKey + TenantIDKey // authenticated request so downstream RequirePermission +
// for RBAC-aware downstream code (RequirePermission, etc.). // audit-attribution code see a consistent actor.
ctx := context.WithValue(r.Context(), UserKey{}, matched.name) ctx := context.WithValue(r.Context(), UserKey{}, matched.Name)
ctx = context.WithValue(ctx, AdminKey{}, matched.admin) ctx = context.WithValue(ctx, AdminKey{}, matched.Admin)
ctx = context.WithValue(ctx, ActorIDKey{}, matched.name) ctx = context.WithValue(ctx, ActorIDKey{}, matched.Name)
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey) ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID) ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
+401
View File
@@ -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
}
+165
View File
@@ -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)
}
}
}
+23
View File
@@ -1566,6 +1566,25 @@ type AuthConfig struct {
// Generation guidance: `openssl rand -hex 32` (256-bit entropy). // Generation guidance: `openssl rand -hex 32` (256-bit entropy).
// Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable. // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable.
AgentBootstrapToken string 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. // RateLimitConfig contains rate limiting configuration.
@@ -1687,6 +1706,10 @@ func Load() (*Config, error) {
// Bundle-5 / Audit H-007: agent-registration bootstrap secret. // Bundle-5 / Audit H-007: agent-registration bootstrap secret.
// Empty (default) = warn-mode pass-through; v2.2.0 will require it. // Empty (default) = warn-mode pass-through; v2.2.0 will require it.
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""), 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{ RateLimit: RateLimitConfig{
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true), Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
+28
View File
@@ -15,8 +15,36 @@ type AuditEvent struct {
ResourceID string `json:"resource_id"` ResourceID string `json:"resource_id"`
Details json.RawMessage `json:"details"` Details json.RawMessage `json:"details"`
Timestamp time.Time `json:"timestamp"` 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. // ActorType represents the entity performing an action.
type ActorType string type ActorType string
+24
View File
@@ -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"`
}
+83
View File
@@ -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)
}
}
}
+56
View File
@@ -101,6 +101,40 @@ type ActorRoleRepository interface {
// gated request; implementations should cache or use SQL JOINs // gated request; implementations should cache or use SQL JOINs
// for performance. // for performance.
EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]EffectivePermission, error) 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 // EffectivePermission is the (permission, scope) pair returned by
@@ -112,3 +146,25 @@ type EffectivePermission struct {
ScopeType authdomain.ScopeType ScopeType authdomain.ScopeType
ScopeID *string // NULL = global 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
}
+8 -4
View File
@@ -47,10 +47,14 @@ type AuditFilter struct {
ActorType string // "user", "agent", "system" ActorType string // "user", "agent", "system"
ResourceType string // e.g., "certificate", "policy", "agent" ResourceType string // e.g., "certificate", "policy", "agent"
ResourceID string ResourceID string
From time.Time // EventCategory (Bundle 1 Phase 8) filters by event_category
To time.Time // column. Allowed values: "cert_lifecycle", "auth", "config".
Page int // Empty string disables the filter (all categories returned).
PerPage int EventCategory string
From time.Time
To time.Time
Page int
PerPage int
} }
// NotificationFilter defines filtering criteria for notification queries. // NotificationFilter defines filtering criteria for notification queries.
+17 -5
View File
@@ -39,14 +39,21 @@ func (r *AuditRepository) CreateWithTx(ctx context.Context, q repository.Querier
if event.ID == "" { if event.ID == "" {
event.ID = uuid.New().String() 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, ` err := q.QueryRowContext(ctx, `
INSERT INTO audit_events ( INSERT INTO audit_events (
id, actor, actor_type, action, resource_type, resource_id, details, timestamp id, actor, actor_type, action, resource_type, resource_id, details, timestamp, event_category
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id RETURNING id
`, event.ID, event.Actor, event.ActorType, event.Action, event.ResourceType, `, 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 { if err != nil {
return fmt.Errorf("failed to create audit event: %w", err) 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) args = append(args, filter.To)
argCount++ argCount++
} }
if filter.EventCategory != "" {
whereConditions = append(whereConditions, fmt.Sprintf("event_category = $%d", argCount))
args = append(args, filter.EventCategory)
argCount++
}
whereClause := "" whereClause := ""
if len(whereConditions) > 0 { if len(whereConditions) > 0 {
@@ -120,7 +132,7 @@ func (r *AuditRepository) List(ctx context.Context, filter *repository.AuditFilt
// Get paginated results // Get paginated results
offset := (filter.Page - 1) * filter.PerPage offset := (filter.Page - 1) * filter.PerPage
query := fmt.Sprintf(` 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 FROM audit_events
%s %s
ORDER BY timestamp DESC ORDER BY timestamp DESC
@@ -139,7 +151,7 @@ func (r *AuditRepository) List(ctx context.Context, filter *repository.AuditFilt
for rows.Next() { for rows.Next() {
var event domain.AuditEvent var event domain.AuditEvent
if err := rows.Scan(&event.ID, &event.Actor, &event.ActorType, &event.Action, 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) return nil, fmt.Errorf("failed to scan audit event: %w", err)
} }
events = append(events, &event) events = append(events, &event)
+167
View File
@@ -388,6 +388,61 @@ func (r *ActorRoleRepository) Revoke(ctx context.Context, actorID string, actorT
return nil 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) { func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT p.name, rp.scope_type, rp.scope_id 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() 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
View File
@@ -31,9 +31,21 @@ func NewAuditService(auditRepo repository.AuditRepository) *AuditService {
// `redacted_keys` array so operators can audit the redactor itself during // `redacted_keys` array so operators can audit the redactor itself during
// a compliance review. See internal/service/audit_redact.go. // 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 { 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 return s.RecordEventWithCategory(ctx, actor, actorType, action, "", resourceType, resourceID, details)
// for nil/empty input, preserving pre-Bundle-6 behaviour for callers }
// that pass nil 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) redacted := RedactDetailsForAudit(details)
detailsJSON, err := json.Marshal(redacted) detailsJSON, err := json.Marshal(redacted)
if err != nil { if err != nil {
@@ -41,14 +53,15 @@ func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType
} }
event := &domain.AuditEvent{ event := &domain.AuditEvent{
ID: generateID("audit"), ID: generateID("audit"),
Timestamp: time.Now(), Timestamp: time.Now(),
Actor: actor, Actor: actor,
ActorType: actorType, ActorType: actorType,
Action: action, Action: action,
ResourceType: resourceType, ResourceType: resourceType,
ResourceID: resourceID, ResourceID: resourceID,
Details: json.RawMessage(detailsJSON), Details: json.RawMessage(detailsJSON),
EventCategory: eventCategory,
} }
if err := s.auditRepo.Create(ctx, event); err != nil { 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). // ListAuditEvents returns paginated audit events (handler interface method).
func (s *AuditService) ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) { 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 { if page < 1 {
page = 1 page = 1
} }
@@ -165,8 +184,9 @@ func (s *AuditService) ListAuditEvents(ctx context.Context, page, perPage int) (
} }
filter := &repository.AuditFilter{ filter := &repository.AuditFilter{
Page: page, EventCategory: eventCategory,
PerPage: perPage, Page: page,
PerPage: perPage,
} }
events, err := s.auditRepo.List(ctx, filter) events, err := s.auditRepo.List(ctx, filter)
+26 -1
View File
@@ -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)) 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 { func (s *ActorRoleService) tenantOf(caller *Caller) string {
if caller != nil && caller.TenantID != "" { if caller != nil && caller.TenantID != "" {
return 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 { if s.audit == nil || caller == nil {
return 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)
} }
+11 -1
View File
@@ -49,7 +49,10 @@ var (
// AuditService is the audit-recording dependency the service layer // AuditService is the audit-recording dependency the service layer
// expects. Mirrors the existing service.AuditService interface so // 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 { type AuditService interface {
RecordEvent( RecordEvent(
ctx context.Context, ctx context.Context,
@@ -58,6 +61,13 @@ type AuditService interface {
action, resourceType, resourceID string, action, resourceType, resourceID string,
details map[string]interface{}, details map[string]interface{},
) error ) 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 // Caller describes the actor performing a service operation. Bundle 1
+5 -1
View File
@@ -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 // recordAudit emits an audit row tied to the caller. Best-effort: audit
// failures are logged via panic-recover but do not fail the operation. // 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{}) { func (s *RoleService) recordAudit(ctx context.Context, caller *Caller, action, resourceType, resourceID string, details map[string]interface{}) {
if s.audit == nil || caller == nil { if s.audit == nil || caller == nil {
return 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 // Ensure the compile-time pin: domain.ActorType is convertible to
+37 -3
View File
@@ -170,19 +170,53 @@ func (f *fakeActorRoleRepo) Revoke(_ context.Context, actorID string, actorType
f.grants = out f.grants = out
return nil 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) { func (f *fakeActorRoleRepo) EffectivePermissions(_ context.Context, actorID string, actorType authdomain.ActorTypeValue, _ string) ([]repository.EffectivePermission, error) {
return f.perms[actorKey(actorID, actorType)], nil return f.perms[actorKey(actorID, actorType)], nil
} }
type fakeAudit struct { type fakeAudit struct {
calls []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 { 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 }{ f.calls = append(f.calls, struct{ Actor, ActorType, Action, Category, ResourceID string }{
actor, string(actorType), action, resourceID, 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 return nil
} }
+7
View File
@@ -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;
+47
View File
@@ -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;
+62
View File
@@ -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;