mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
auth-bundle-1 Phase 6-7-8: bootstrap path + scope-down CLI + auditor-role split
# Phase 6 — day-0 admin bootstrap * internal/auth/bootstrap/ (new package): Strategy interface + EnvTokenStrategy with constant-time compare, one-shot consumption via sync.Mutex, optional admin-existence probe. Bundle 2's OIDC- first-admin will plug in alongside as an alternate Strategy. * BootstrapService.ValidateAndMint: validates the operator's CERTCTL_BOOTSTRAP_TOKEN, mints a 32-byte (64-hex-char) random API key value, persists the SHA-256 hash to api_keys, grants r-admin via actor_roles, AddHashed's the runtime keystore so the just- minted key authenticates the next request without restart, and records bootstrap.consume to the audit trail with category=auth. * internal/auth/keystore.go (new): KeyStore interface + StaticKeyStore (immutable env-var-only path) + MutableKeyStore (env-var keys + DB-loaded api_keys + runtime AddHashed). The auth middleware now consumes a KeyStore so the bootstrap path can extend the lookup table at runtime. * migrations/000031_api_keys.up/down.sql: api_keys table with (id, name UNIQUE, key_hash UNIQUE, tenant_id, admin, created_by, created_at, expires_at, last_used_at). Idempotent. * /v1/auth/bootstrap GET (probe) + POST (mint) — auth-exempt. Both routes documented in api/openapi.yaml + AuthExemptRouterRoutes allowlist updated. The token never leaves internal/auth/bootstrap; the minted plaintext key flows only into the HTTP response body. * Startup warning emitted when CERTCTL_BOOTSTRAP_TOKEN is set AND admin actors already exist (config drift signal). * Tests: 4 strategy invariants (empty token born disabled, wrong token=ErrInvalidToken without consumption, one-shot consumption, admin-exists closes path), 5 service tests (happy path + actor- name validation + propagation of strategy errors + nil-deps guard + 32-byte entropy budget), 8 HTTP-handler tests (status 201/410/401/400 mapping + token-leak hygiene scan of slog + audit details + Location header). Token-leak test redirects slog.Default to a buffer for the test scope. # Phase 7 — API-key migration + scope-down CLI * GET /v1/auth/keys handler + service method ListKeys backed by ActorRoleRepository.ListDistinctActors. Returns one row per (actor_id, actor_type) pair with the slice of role IDs they hold. Permission: auth.role.list. * internal/cli/auth_scope_down.go: AuthListKeys, AuthScopeDown (interactive), AuthScopeDownNonInteractive (JSON config), AuthScopeDownSuggest (--suggest with optional --apply). The synthetic actor-demo-anon is filtered out of every interactive / bulk path; non-interactive flow logs and skips it explicitly. * SuggestRoleFromAuditEvents (pure function): walks 30 days of audit events per actor and returns the narrowest matching role (admin / mcp / viewer / agent / operator) plus a one-line reason. Classification: any admin-shaped action wins; otherwise all-MCP → mcp; all-read-only → viewer; all-agent-shaped → agent; otherwise operator. Test table pins all six classifications. * CLI subcommand tree extended: 'auth keys list' + 'auth keys scope-down [--non-interactive <cfg>] [--suggest [--apply]]'. * CHANGELOG.md leads v2.1.0 with the SECURITY: AUDIT YOUR API KEYS call-out + four flow examples. # Phase 8 — auditor role + event_category column * migrations/000032_audit_category.up/down.sql: ALTER TABLE audit_events ADD COLUMN event_category TEXT NOT NULL DEFAULT 'cert_lifecycle' + CHECK constraint (cert_lifecycle/auth/config) + (event_category) and (event_category, timestamp DESC) indexes for the auditor-filter query path. WORM trigger from migration 000018 continues to enforce append-only at the DB layer (DDL is not blocked). * domain.AuditEvent gains EventCategory string (omitempty); domain.EventCategoryCertLifecycle / Auth / Config constants. * AuditService.RecordEventWithCategory sibling of RecordEvent; legacy callers stay on RecordEvent (defaults to cert_lifecycle). Auth callers (RoleService, ActorRoleService, BootstrapService) switched to RecordEventWithCategory(..., 'auth', ...). * GET /v1/audit?category=<cat>: handler accepts the optional query param, validates against the enum (400 on invalid value), dispatches through ListAuditEventsByCategory. OpenAPI updated with the new query param + AuditEvent.event_category schema. * Postgres AuditRepository.Create now writes event_category; AuditRepository.List filters on it; AuditFilter.EventCategory gates the WHERE clause. * Tests: 5 audit-category-filter HTTP tests (dispatch routing, back-compat fallback, 400 for invalid values, all 3 enum values accepted, page+category combine, JSON output surfaces the field). 3 auditor-role invariants (auditor holds exactly audit.read+audit.export, no mutating perms, disjoint from viewer except audit.read). # Cross-phase wiring * HandlerRegistry.Bootstrap field added; cmd/server/main.go wires the bootstrap service ahead of RegisterHandlers (extracted assembleNamedAPIKeys helper into auth_backfill.go, moved the keystore + bootstrap construction up alongside the auth repos). * AuthCheckResolver / AuthActorRoleService extended with ListKeys to satisfy the Phase 7 surface; existing fakes updated. * fakeAudit + mockAuditService stubs in tests gain RecordEventWithCategory + ListAuditEventsByCategory; existing tests untouched. # Verifications * gofmt -l: clean across every modified file. * go vet ./...: clean. * staticcheck across internal/auth + handler + router + cli + service + repository + cmd + domain: clean. * go test -short -count=1: green across every Bundle-1-touched package — internal/auth (incl. bootstrap), internal/api/handler, internal/api/router, internal/cli, internal/service/auth, internal/service, internal/domain/auth, internal/repository/postgres, cmd/server, cmd/cli, plus internal/scheduler, internal/api/middleware, cmd/agent, internal/mcp.
This commit is contained in:
@@ -220,6 +220,80 @@ paths:
|
||||
# lifecycle, `auth.key.*` for key management. Read endpoints require
|
||||
# `auth.role.list`. The /v1/auth/me endpoint has no permission gate
|
||||
# (every authenticated caller can read their own permissions).
|
||||
/api/v1/auth/bootstrap:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Probe whether the day-0 bootstrap endpoint is callable
|
||||
description: |
|
||||
Returns `{available: true}` when CERTCTL_BOOTSTRAP_TOKEN is set
|
||||
AND no admin-roled actor exists yet; otherwise `{available: false}`.
|
||||
Auth-exempt because it serves the GUI / install one-liner before
|
||||
the first admin key has been minted. Bundle 1 Phase 6.
|
||||
security: []
|
||||
operationId: getAuthBootstrap
|
||||
responses:
|
||||
"200":
|
||||
description: Bootstrap availability
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [available]
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Mint the first admin API key from a one-shot bootstrap token
|
||||
description: |
|
||||
Operator POSTs the CERTCTL_BOOTSTRAP_TOKEN value plus the desired
|
||||
admin-key name. Returns the freshly minted plaintext key value
|
||||
once; the server stores only the SHA-256 hash. Subsequent calls
|
||||
return 410 Gone (the strategy is one-shot AND the admin-existence
|
||||
probe re-closes the door once the new admin lands). Auth-exempt
|
||||
because the endpoint authenticates via the bootstrap token
|
||||
itself. Bundle 1 Phase 6.
|
||||
security: []
|
||||
operationId: postAuthBootstrap
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token, actor_name]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: The CERTCTL_BOOTSTRAP_TOKEN value (constant-time compared server-side).
|
||||
actor_name:
|
||||
type: string
|
||||
description: 3-64 chars, lowercase alphanumeric + hyphen + underscore.
|
||||
pattern: "^[a-z0-9][a-z0-9_-]{2,63}$"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin key minted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [actor_id, api_key_id, key_value, created_at, message]
|
||||
properties:
|
||||
actor_id: { type: string }
|
||||
api_key_id: { type: string }
|
||||
key_value:
|
||||
type: string
|
||||
description: The plaintext API key. Capture this — it is shown only once.
|
||||
created_at: { type: string, format: date-time }
|
||||
message: { type: string }
|
||||
"400": { description: Invalid actor_name or malformed body }
|
||||
"401": { description: Bootstrap token mismatch }
|
||||
"410":
|
||||
description: |
|
||||
Endpoint disabled. Either CERTCTL_BOOTSTRAP_TOKEN is unset,
|
||||
an admin actor already exists, or the strategy was already
|
||||
consumed by a successful prior call.
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
tags: [Auth]
|
||||
@@ -462,6 +536,43 @@ paths:
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role or permission grant not found }
|
||||
|
||||
/api/v1/auth/keys:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: List actors with role grants in the active tenant
|
||||
description: |
|
||||
Returns every distinct (actor_id, actor_type) pair in the
|
||||
tenant that holds at least one role grant. Bundle 1 Phase 7
|
||||
ships this so the CLI's `auth keys list` and scope-down helper
|
||||
can enumerate the operator-key population without joining
|
||||
against the env-var-loaded namedKeys directly. Permission
|
||||
`auth.role.list`.
|
||||
operationId: listAuthKeys
|
||||
responses:
|
||||
"200":
|
||||
description: Actor list with role assignments
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [actor_id, actor_type, tenant_id, role_ids]
|
||||
properties:
|
||||
actor_id: { type: string }
|
||||
actor_type:
|
||||
type: string
|
||||
enum: [User, System, Agent, APIKey, Anonymous]
|
||||
tenant_id: { type: string }
|
||||
role_ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
|
||||
/api/v1/auth/keys/{id}/roles:
|
||||
post:
|
||||
tags: [Auth]
|
||||
@@ -3057,10 +3168,22 @@ paths:
|
||||
get:
|
||||
tags: [Audit]
|
||||
summary: List audit events
|
||||
description: |
|
||||
Bundle 1 Phase 8 adds the optional `category` query parameter
|
||||
for auditor-role filtering. Allowed values: `cert_lifecycle`
|
||||
(cert/agent/deployment events), `auth` (role/key/bootstrap
|
||||
mutations), `config` (issuer/target/settings edits). Omitting
|
||||
the parameter returns every category.
|
||||
operationId: listAuditEvents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/page"
|
||||
- $ref: "#/components/parameters/per_page"
|
||||
- in: query
|
||||
name: category
|
||||
schema:
|
||||
type: string
|
||||
enum: [cert_lifecycle, auth, config]
|
||||
description: Filter to events of this event_category. (Bundle 1 Phase 8)
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated list of audit events
|
||||
@@ -3075,6 +3198,8 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditEvent"
|
||||
"400":
|
||||
description: Invalid `category` value
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -5699,6 +5824,13 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
event_category:
|
||||
type: string
|
||||
enum: [cert_lifecycle, auth, config]
|
||||
description: |
|
||||
Bundle 1 Phase 8: classifies the event for auditor-role
|
||||
filtering. Empty / absent on rows from pre-Phase-8
|
||||
deployments (the migration backfills "cert_lifecycle").
|
||||
|
||||
# ─── Notifications ───────────────────────────────────────────────
|
||||
NotificationType:
|
||||
|
||||
Reference in New Issue
Block a user