Files
certctl/docs/reference/profiles.md
T
shankar0123 69a508dfcf auth-bundle-1 Phase 9 + 10: approval-bypass closure + RBAC GUI
# Phase 9 — approval-bypass closure (Decision 9, option a)

* Migration 000033_approval_kinds.up.sql: ALTER TABLE
  issuance_approval_requests ADD COLUMN approval_kind +
  payload JSONB; relax certificate_id + job_id to nullable;
  CHECK (approval_kind IN ('cert_issuance','profile_edit'))
  + CHECK (per-kind nullability invariant) + index on
  approval_kind. Idempotent throughout via DO blocks.
* domain.ApprovalKind enum (cert_issuance / profile_edit) +
  IsValidApprovalKind. ApprovalRequest gains Kind +
  Payload []byte for the pending profile diff.
* postgres.ApprovalRepository.Create + scanApprovalRow extended
  to round-trip the new columns; certificate_id + job_id
  switched to sql.NullString so profile_edit rows persist
  cleanly. Default Kind=cert_issuance preserves back-compat
  for every Phase-7-2026-05-03 caller.
* ApprovalService.RequestProfileEditApproval: new entry point
  that creates a pending profile-edit row carrying the
  serialized profile diff. Bypass mode (CERTCTL_APPROVAL_BYPASS)
  short-circuits the same way it does for cert_issuance.
* ApprovalService.SetProfileEditApply hook: cmd/server/main.go
  registers a closure that deserializes req.Payload + persists
  via profileRepo.Update + emits a profile.edit_applied audit
  row with category=auth. The hook avoids the Approval ↔
  Profile import cycle.
* ProfileService.UpdateProfile: gates when (a) the live
  profile carries RequiresApproval=true, OR (b) the proposed
  edit would set it true. Returns ErrProfileEditPendingApproval
  with the new approval ID; ProfileHandler maps to HTTP 202
  Accepted + {pending_approval_id}. Both arms close the
  flip-flop loophole because every transition through an
  approval-tier profile fires the gate.
* TestProfileEdit_RequiresApprovalLoopholeClosed pins all 3
  bypass attempts (flip-off / kept-on / flip-on) gated; nil-
  approval-service preserves pre-Phase-9 direct-apply for
  test fixtures.
* Approval service tests gain 4 profile_edit rows: pending row
  shape; same-actor self-approve rejected with
  ErrApproveBySameActor (load-bearing two-person integrity);
  approve fails-closed when apply callback unwired;
  apply callback invoked on approve.
* docs/reference/profiles.md (new) explains the gate +
  edit response shape (202) + same-actor invariant + bypass
  + audit hooks.

# Phase 10 — RBAC management GUI

* useAuthMe hook (web/src/hooks/useAuthMe.ts): TanStack Query
  fetches /api/v1/auth/me on app boot, caches for 60s, exposes
  hasPerm(p) + hasAnyPerm + isAdmin predicates. Every Phase-10
  page consumes this on mount + gates affordances against the
  cached effective_permissions slice. Server-side enforcement
  is the load-bearing gate; client-side hide/disable is UX.
* New routes:
   - /auth/roles — list (auth.role.list); create-role modal
     (auth.role.create) hidden when missing.
   - /auth/roles/:id — detail + permissions; edit
     (auth.role.edit), delete (auth.role.delete), add/remove
     permission affordances each gated.
   - /auth/keys — list of every actor with role grants; assign
     + revoke modals (auth.role.assign). actor-demo-anon
     flagged system-managed; mutation buttons hidden for it.
   - /auth/settings — stub showing /v1/auth/me identity +
     bootstrap-endpoint availability via /v1/auth/bootstrap.
* AuditPage extended with category filter ('All categories'
  + the 3 enum values from migration 000032). Selection flows
  to the API call params + the URL-driven query state.
* Layout: 3 new nav entries (Roles / API Keys / Auth Settings).
* api/client.ts: 12 new exported functions for the RBAC
  surface (authMe, list/get/create/update/delete role,
  list/add/remove role permissions, list keys, assign/revoke
  key role, bootstrap-availability probe).
* data-testid attributes on every interactive element so a
  future Playwright suite can assert behavior without brittle
  CSS selectors.
* Empty state, error state, and unsaved-changes warnings on
  every form per the prompt's implementation rules.

# Frontend tests

* RolesPage.test.tsx (6 tests): list render, empty state,
  error state, hide-create-button-without-perm,
  show-create-button-with-perm, submit-create-modal.
* KeysPage.test.tsx (3 tests): demo-anon flagged
  system-managed (no buttons), permission-gated affordance
  hide for auditor caller, assign-modal-POST contract.
* AuthSettingsPage.test.tsx (2 tests): identity surface,
  bootstrap-OPEN-status surface.
* AuditPage.test.tsx (+1): category-filter select renders
  with the 4 documented options.

15 frontend tests total in src/pages/auth/ + the audit
category-filter test; all pass via npx vitest run.

# Verifications

* go vet ./... clean.
* staticcheck across internal/auth + handler + router + cli +
  service + repository + cmd + domain: clean.
* gofmt -l clean repo-wide.
* go test -short -count=1 green across internal/service,
  internal/api/handler, internal/api/router, internal/auth,
  internal/auth/bootstrap, internal/service/auth,
  internal/domain/auth, cmd/server, cmd/cli, internal/cli.
* npx tsc --noEmit clean.
* npm run build green (vite build produces dist/index.html
  + 946KB JS bundle; chunk-size warning is pre-existing).
* npx vitest run src/pages/auth/ src/pages/AuditPage.test.tsx
  green (15 tests, 4 files).
2026-05-09 21:03:59 +00:00

114 lines
5.8 KiB
Markdown

# Certificate profiles
Last reviewed: 2026-05-09
A `CertificateProfile` is the policy object that groups every cert with
the same shape: which issuer mints it, which key algorithm + size are
allowed, what EKUs and SANs the issuer should emit, what renewal
window the scheduler uses, what targets get the cert deployed to. Every
managed certificate references exactly one profile; changing a
profile's policy retroactively affects renewal of every cert pointing
at it.
This file documents the profile lifecycle as it stands after Bundle 1.
For the schema, see `migrations/000003_certificate_profiles.up.sql` +
`migrations/000027_approval_workflow.up.sql` +
`migrations/000033_approval_kinds.up.sql`. For the API surface,
see `api/openapi.yaml` under `/api/v1/profiles`.
## Anatomy
| Field | Default | Purpose |
|---|---|---|
| `id` | autogenerated `prof-<slug>` | Stable opaque identifier; used by every other resource. |
| `name` | required | Human-readable label; rendered in the GUI's profile picker. |
| `issuer_id` | required | Which issuer (Local / Vault / EJBCA / ACME / SCEP / EST / ADCS / etc.) mints certs against this profile. |
| `default_validity_days` | 90 | Rendered into the issuer call as the requested NotAfter delta. |
| `renewal_window_days` | 30 | Scheduler enqueues a renewal Job when `cert.NotAfter - now < renewal_window_days`. |
| `allowed_key_algorithms` | RSA 2048+, ECDSA P-256+ | Validates incoming CSRs at issuance time. |
| `allowed_ekus` | server, client | RFC 5280 §4.2.1.12 EKU set. |
| `must_staple` | false | Per-profile RFC 7633 `id-pe-tlsfeature` extension toggle (Phase 5.6 of the SCEP master bundle). |
| `requires_approval` | false | Bundle 1 Phase 9 — gates issuance + renewal AND profile edits behind a four-eyes approval workflow. See below. |
## RequiresApproval and the approval workflow
Setting `requires_approval=true` on a profile does two things:
1. **Issuance + renewal of every cert pointing at the profile gates
on a non-requester admin's approval.** The scheduler enqueues a
`Job` at status `AwaitingApproval`; the linked
`issuance_approval_requests` row stays at `pending` until either
approved (job → `Pending`, scheduler dispatches) or rejected (job
`Cancelled`). Same actor cannot self-approve.
2. **Edits to the profile itself gate on a non-requester admin's
approval.** This is the Bundle 1 Phase 9 closure for the flip-flop
loophole — without it an admin could set `requires_approval=false`,
mutate any other field, set `requires_approval=true`, and the
approval workflow would only have been bypassed during the
"off" window. The Phase 9 gate fires under three conditions:
- The live profile has `requires_approval=true` AND the operator
submits any edit (regardless of whether the edit changes the
flag).
- The live profile has `requires_approval=false` AND the operator
submits an edit that would set it to `true` (the flag-flip
direction is gated too because otherwise the gate could be
enabled by anyone and have no review).
- Both arms route through `ApprovalService.RequestProfileEditApproval`
which writes a row to `issuance_approval_requests` with
`approval_kind=profile_edit`. The pending profile diff is
serialized to `payload` (JSONB).
**Edit response shape.** When the gate fires, `PUT /api/v1/profiles/{id}`
returns HTTP 202 Accepted with body
`{"status":"pending_approval","pending_approval_id":"ar-…"}`.
The operator copies the approval ID, hands it to a peer admin, and
the peer POSTs `/api/v1/approvals/{id}/approve` with their own
credentials. On approve, the server deserializes `payload`, applies
the diff against the live profile, and emits a
`profile.edit_applied` audit row with `event_category=auth`. On
reject, the pending row is dropped; the live profile is unchanged.
**Same-actor self-approve is rejected** with HTTP 403 and the existing
`ErrApproveBySameActor` sentinel. This is the load-bearing
two-person-integrity invariant that satisfies SOC 2 CC6.3 + NIST
SSDF PO.5.2.
**Bypass mode.** `CERTCTL_APPROVAL_BYPASS=true` short-circuits both
issuance approvals and profile-edit approvals; every request
auto-approves with `actor=system-bypass`. Used by dev / CI for fast
iteration; production deploys MUST leave it unset. A single SQL
query (`SELECT FROM audit_events WHERE actor='system-bypass'`)
confirms zero rows.
## Operator workflows
**Enable approval for an existing profile.** Edit the profile, set
`requires_approval=true`. The first time you do this, the edit
itself is gated (the live profile is non-approval but the proposed
state is approval-tier, so the flip-on direction still routes through
the workflow). Hand the approval ID to a peer; once approved, every
subsequent edit and every renewal of every cert pointing at the
profile gates on the workflow.
**Disable approval.** Edit the profile, set `requires_approval=false`.
This edit is gated because the live profile is currently
approval-tier. A peer must approve the disable. Once disabled,
subsequent edits flow through the direct-apply path again.
**Audit who approved what.** The audit trail records every approval
request + decision under `event_category=auth`. Filter via
`GET /api/v1/audit?category=auth` or the `auditor` role's
audit-only view. Each row carries the approval ID + the requester
+ the decider; the WORM trigger prevents tampering.
## Related
- `migrations/000027_approval_workflow.up.sql` (initial approval
schema, Rank 7 of the 2026-05-03 deep-research deliverable)
- `migrations/000033_approval_kinds.up.sql` (Phase 9 — adds
`approval_kind` + `payload` + nullable cert/job FKs)
- `internal/service/approval.go::RequestProfileEditApproval`
- `internal/service/profile.go::UpdateProfile` (gate)
- `internal/api/handler/profiles.go::UpdateProfile` (202 mapping)
- `cowork/auth-bundle-1-prompt.md` (Phase 9 spec)