Files
certctl/CHANGELOG.md
T
shankar0123 3ef45e2ad4 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.
2026-05-09 20:15:43 +00:00

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 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:

# 1. List every key with its current role:
certctl-cli auth keys list

# 2. Walk an interactive prompt that downgrades each key:
certctl-cli auth keys scope-down

# 3. Or get a heuristic suggestion based on 30 days of audit history:
certctl-cli auth keys scope-down --suggest
certctl-cli auth keys scope-down --suggest --apply   # applies the suggestion

# 4. Or drive scope-down from a JSON config (Helm post-upgrade hook):
certctl-cli auth keys scope-down --non-interactive ./scope-down.json

The synthetic actor-demo-anon actor (used when CERTCTL_AUTH_TYPE=none is configured) is system-managed and excluded from the prompt loop.

What else changed in v2.1.0:

  • RBAC primitive shipped. tenants, roles, permissions, role_permissions, actor_roles tables (migration 000029); 33-permission canonical catalogue; 7 default roles (admin, operator, viewer, agent, mcp, cli, auditor); per-handler permission gates via auth.RequirePermission middleware (replaces the legacy IsAdmin boolean check on the 5 admin-only handlers).
  • Day-0 admin bootstrap. Set CERTCTL_BOOTSTRAP_TOKEN on a fresh deploy and POST a single curl call against /v1/auth/bootstrap to mint the first admin API key; one-shot, never logged, and locks closed once any admin actor exists. Migration 000031 ships the api_keys table that stores the SHA-256 hash; the plaintext is shown in the response body once and never persisted.
  • Auditor role split. New auditor role holds only audit.read
    • audit.export. Compliance reviewers can read the audit trail without holding mutation power. Migration 000032 adds audit_events.event_category so auditors can filter to authentication-related events specifically.
  • /v1/auth/check enrichment. Response now includes the actor's standing roles and effective permissions, so the GUI gates affordances from a single fetch on app boot.
  • OpenAPI catalogues every new route. Every Bundle 1 endpoint ships with an operationId; the parity test guards against drift.
  • Bundle 2 (OIDC + sessions) starts after Bundle 1 lands on master. Roadmap entry remains in cowork/auth-bundle-2-prompt.md.

Migration ordering, idempotency, and downgrade are documented in docs/migration/api-keys-to-rbac.md.

v2.0.68 — Image registry path changed ⚠️

Image registry path changed. Starting this release, container images publish to ghcr.io/certctl-io/certctl-server and ghcr.io/certctl-io/certctl-agent. Existing pulls from ghcr.io/shankar0123/certctl-{server,agent}:<tag> continue to work for previously-published tags (the registry never deletes images), but the :latest tag at the old path stops moving forward at this release. Update your docker pull paths, docker-compose.yml image: keys, or Helm image.repository values to receive future updates. Old git clone / git push / install-script / API URLs continue to redirect forever — only the container-registry path changed.

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.