# 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 at v2.1.0. 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-` | 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. | | `requires_approval` | false | 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 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 profile-edit 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` (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)