Self-audit on ba68f9a flagged the prompt's 'zero em dashes'
discipline rule. The four new Phase 13 docs and the v2.1.0
CHANGELOG section had 97 em-dash hits between them; this commit
sweeps them all to ASCII hyphens.
Counts before -> after:
docs/operator/rbac.md 28 -> 0
docs/operator/auth-threat-model.md 36 -> 0
docs/migration/api-keys-to-rbac.md 16 -> 0
docs/operator/security.md 8 -> 0
docs/reference/profiles.md 3 -> 0
CHANGELOG.md 6 -> 0
Mechanical: ' - ' (spaced em dash) and bare em-dash both replaced
with spaced ASCII hyphen, then double-spaces collapsed. Markdown
list bullets ('^- ', '^ - ', '^ - ') verified intact across
all six files. Internal-link sweep also re-run.
Also fixes a pre-existing broken link the audit caught:
docs/operator/security.md:70 referenced
'../internal/crypto/encryption.go' which is a 1-level-up jump
from docs/operator/, not the 2-level-up jump it actually needs
('../../internal/crypto/encryption.go'). Pre-Bundle-1 link rot;
fixed in lockstep so the merge gate's docs validation passes
cleanly.
Final state across the Phase-13 docs + CHANGELOG:
- 0 em dashes
- 0 broken internal links
- Last-reviewed: 2026-05-09 header on every new doc
Bundle 1 documentation is now ready for the operator-side merge
gate review.
5.8 KiB
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:
- Issuance + renewal of every cert pointing at the profile gates
on a non-requester admin's approval. The scheduler enqueues a
Jobat statusAwaitingApproval; the linkedissuance_approval_requestsrow stays atpendinguntil either approved (job →Pending, scheduler dispatches) or rejected (job →Cancelled). Same actor cannot self-approve. - 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, setrequires_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=trueAND the operator submits any edit (regardless of whether the edit changes the flag). - The live profile has
requires_approval=falseAND the operator submits an edit that would set it totrue(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.RequestProfileEditApprovalwhich writes a row toissuance_approval_requestswithapproval_kind=profile_edit. The pending profile diff is serialized topayload(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 - addsapproval_kind+payload+ nullable cert/job FKs)internal/service/approval.go::RequestProfileEditApprovalinternal/service/profile.go::UpdateProfile(gate)internal/api/handler/profiles.go::UpdateProfile(202 mapping)cowork/auth-bundle-1-prompt.md(Phase 9 spec)