# 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.
5.4 KiB
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 legacyCERTCTL_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:
# 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.jsonThe synthetic
actor-demo-anonactor (used whenCERTCTL_AUTH_TYPE=noneis 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_rolestables (migration 000029); 33-permission canonical catalogue; 7 default roles (admin,operator,viewer,agent,mcp,cli,auditor); per-handler permission gates viaauth.RequirePermissionmiddleware (replaces the legacyIsAdminboolean check on the 5 admin-only handlers). - Day-0 admin bootstrap. Set
CERTCTL_BOOTSTRAP_TOKENon a fresh deploy and POST a single curl call against/v1/auth/bootstrapto mint the first admin API key; one-shot, never logged, and locks closed once any admin actor exists. Migration 000031 ships theapi_keystable that stores the SHA-256 hash; the plaintext is shown in the response body once and never persisted. - Auditor role split. New
auditorrole holds onlyaudit.readaudit.export. Compliance reviewers can read the audit trail without holding mutation power. Migration 000032 addsaudit_events.event_categoryso auditors can filter to authentication-related events specifically.
/v1/auth/checkenrichment. Response now includes the actor's standing roles and effective permissions, so the GUI gates affordances from a single fetch on app boot.- OpenAPI catalogues every new route. Every Bundle 1 endpoint
ships with an
operationId; the parity test guards against drift. - Bundle 2 (OIDC + sessions) starts after Bundle 1 lands on
master. Roadmap entry remains in
cowork/auth-bundle-2-prompt.md.
Migration ordering, idempotency, and downgrade are documented in
docs/migration/api-keys-to-rbac.md.
v2.0.68 — Image registry path changed ⚠️
Image registry path changed. Starting this release, container images publish to
ghcr.io/certctl-io/certctl-serverandghcr.io/certctl-io/certctl-agent. Existing pulls fromghcr.io/shankar0123/certctl-{server,agent}:<tag>continue to work for previously-published tags (the registry never deletes images), but the:latesttag at the old path stops moving forward at this release. Update yourdocker pullpaths,docker-compose.ymlimage:keys, or Helmimage.repositoryvalues to receive future updates. Oldgit clone/git push/ install-script / API URLs continue to redirect forever — only the container-registry path changed.
This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from shankar0123/certctl to certctl-io/certctl (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the GitHub release page.
certctl no longer maintains a hand-edited per-version changelog. Per-release notes are auto-generated from commit messages between consecutive tags.
Where to find what changed in a given release:
- GitHub Releases — every tag has an auto-generated "What's Changed" section pulled from the commits between that tag and the previous one, plus per-release supply-chain verification instructions (Cosign / SLSA / SBOM).
git log <prev-tag>..<this-tag> --oneline— same content, locally.
Why no hand-edited CHANGELOG.md:
certctl is solo-developed and pushes directly to master. Maintaining a
hand-edited CHANGELOG meant the file drifted (entries piled into
[unreleased] and never got promoted to per-version sections when tags were
cut). A stale CHANGELOG is worse than no CHANGELOG — it signals abandoned
maintenance to security-conscious operators doing diligence.
The auto-generated release notes work here because commit messages follow a
descriptive convention: <area>: <summary> with a longer body for non-trivial
changes (see git log v2.0.50..HEAD for the established pattern). Anyone
reading the GitHub Releases page can see exactly what landed in each version
without depending on the author to manually update a separate file.
For the historical record: earlier versions (pre-v2.2.0 and the [2.2.0] tag itself) had a hand-edited CHANGELOG. That content is preserved in git history at the v2.2.0 tag.