Approval workflow
A four-eyes gate on top of RBAC: when a gate is on, operators don't execute the operation directly — they file a request that a project admin approves, and only then does it run. This page deepens the approval overview.
What can be gated
The gate covers two families of operator action: the certificate lifecycle and the distribution-side supply chain. All toggles live under Settings → General → Approval Requirements (org-wide, stored in general settings).
Certificate-lifecycle toggles
Five independent toggles, all off by default:
| Toggle | Setting | Gates |
|---|---|---|
| Issue | require_approval_for_issue | New certificate requests |
| Reissue | require_approval_for_reissue | Reissue with new parameters / key rotation |
| Renew | require_approval_for_renew | Manual renewals |
| Revoke | require_approval_for_revoke | Revocation at the CA |
| Download | require_approval_for_download | Private-key export |
Supply-chain toggles
Two toggles that gate what operators may change in the distribution infrastructure and how they deploy (operation types config_edit and distribute):
| Toggle | Setting | Default | Gates |
|---|---|---|---|
| Require approval for configuration changes | config_change_approval_disabled (kill-switch) | ON | Operator create / update / delete of an ActionSet, PathSet, project-variable, Target, TargetGroup, or ModuleConfig (including group member add / remove). Module credentials stay admin-only (they hold secrets); the module health-check stays admin. |
| Require approval for distribution | require_approval_for_distribution | OFF | Operator distribution link / update / unlink / execute / rollback. |
Config-change is default ON, stored as a kill-switch. Because general settings are one JSON blob (no per-field migration), the "require approval for configuration changes" toggle is persisted inverted as a config_change_approval_disabled boolean — false (the default, including on upgraded settings documents) means the gate is ON. The UI presents it positively as "Require approval for configuration changes." Rationale for default-on: these resources were previously admin-only — operators could not touch them at all. Their write routes were relaxed from admin-only to operator, so opening write access only through approval is a net-safe expansion.
Distribution is default OFF (opt-in). Operators already link and run distributions today; defaulting the gate on would break existing deploy flows, so require_approval_for_distribution starts off and must be enabled deliberately.
The distribute gate only covers the manual, operator-initiated action through the HTTP handler. The scheduler's automatic post-renewal distribution sweep bypasses the handler and is therefore not gated — gating it would block renewals from ever reaching their targets. Shell-mode distributions are also unaffected by the gate: they still require the Admin role to execute, and that check runs before the gate.
Scheduler-driven auto-renewals are likewise not affected by the certificate-lifecycle gates — those gates sit on the API operations only.
Generic resource model
Originally every approval request referenced a certificate. With config_edit and distribute the request is generalised: an ApprovalRequest now carries resource_type / resource_id / resource_name so it can reference an action_set, path_set, variable, target, target_group, module_config, or distribution — not just a certificate. An empty resource_type (legacy rows) or certificate keeps the original cert-centric behaviour. For a create / link request where the object does not exist yet, resource_id is a sentinel new:<kind>:<name>.
The proposed change or action is stashed in the request's Params at submit time and replayed on Execute, exactly like the certificate flow. Two application shapes:
config_editapplies synchronously — Execute calls the target service's Create / Update / Delete directly; there is no job.distributeenqueues a job — Execute enqueues the distribution execute / rollback job and links its ID to the request.
Stale guard (RESOURCE_CONFLICT). A config_edit update / delete / member-op Execute is rejected with error code RESOURCE_CONFLICT if the live resource changed since the request was raised — its UpdatedAt no longer matches the value captured at submit time. Replaying a stale edit would clobber newer content (an admin's direct edit, or another approved change that landed first). It is not retryable; the requester must resubmit the edit.
Secret project-variable encryption. When a config_edit request carries a secret-flagged project variable, its value is envelope-encrypted inside the stashed Params — the secret is never at rest in plaintext in the approval record. Execute decrypts it back only inside the apply call's scope, and the variable service re-encrypts on save.
Certificate import / re-import activates and distributes a certificate without going through the approval workflow (deferring it would require persisting the uploaded private key in the approval record). To stop a non-privileged operator from using import to sidestep "require approval for issue," import and re-import require the Admin project role rather than operator.
Lifecycle
| Status | Meaning |
|---|---|
pending_approval | Awaiting a reviewer. |
approved | Accepted — waiting for an explicit Execute. |
executing | Operation in flight. |
executed | Ran successfully (job outcome tracked on the job itself). |
execution_failed | Operation errored; retryable failures can go back to approved. |
rejected | Declined with a reason. |
cancelled | Withdrawn by the requester or an admin before execution. |
expired | Not completed within 7 days of creation. |
Only one open request may exist per resource + operation (certificate + op, or resource + op for config_edit / distribute) — a duplicate submit is rejected until the first request reaches a terminal state.
Who approves — and who bypasses
- Approve / reject requires the project admin role (or higher) on the request's project.
- Self-approval is blocked at the service layer — the approver cannot be the requester ("cannot approve your own request").
- Admins and owners auto-approve: when a privileged user submits a gated operation, the request is created directly in
approved(with themselves recorded as approver) — they still go through the request/execute flow, so the audit trail is uniform, but no second person is required.
Net effect: the gate adds a second pair of eyes for operator-initiated actions; admin/owner bypass all approval (auto-approved, auto-executed, and recorded in the audit trail) across every operation type — lifecycle, config_edit, and distribute alike.
Execution
Approval does not auto-run the operation. After a request reaches approved, the requester or a project admin clicks Execute (or calls the execute endpoint). Execution:
- enqueues the underlying job for issue / reissue / renew / revoke (the job ID is linked from the request), or
- mints a one-time download token for download requests, or
- applies the
config_editchange synchronously (Create / Update / Delete on the resource — no job), or - enqueues the distribution execute / rollback job for a
distributerequest.
If execution fails, the request lands in execution_failed with an error code. The UI offers Retry for transient codes (JOB_QUEUE_ERROR, CA_ERROR, STATUS_CONFLICT); retry moves the request back to approved and executes again — no re-approval needed. RESOURCE_CONFLICT (a config_edit whose live resource changed since the request was raised) is not retryable — resubmit the edit against the current resource.
Expiry
Every request gets a fixed 7-day TTL at creation (not configurable). There is no background sweep — expiry is enforced lazily: any approve / reject / execute / cancel attempt on an overdue request atomically flips it to expired, emits approval.expired, and fails the attempted action. Expired requests never execute; re-submit if still needed.
Approved downloads
Private-key export through the gate ends in a token exchange rather than a direct file response:
- Requester asks to download; a
downloadapproval request is created (or auto-approved for admins). - A project admin approves.
- Requester (or admin) executes — the response contains a raw download token.
- The browser fetches
GET /api/v1/downloads/{token}immediately.
Token properties: 60-second TTL, single use (atomically consumed), bound to the executing user, and stored only as a SHA-256 hash. Expired, reused, or wrong-user tokens all fail identically. See Certificate download for formats.
Preflight warnings
Independent of the approval gates, MSCA issuance runs preflight checks and can pause submission with warnings that the requester must explicitly confirm (re-submit with the confirmation flag):
- template cache older than 30 days — consider syncing templates,
- connection not validated in over 24 hours,
- credentials changed after the last successful authentication.
Other issuer types skip preflight. The confirmation is part of the request, so approvers review a warned-and-acknowledged submission.
My Requests page
My Requests in the main navigation:
- My Requests view — everything you submitted, filterable by status (All / Pending / Approved / Rejected / Executed / Failed / Expired / Cancelled). Rows expand to show reason, reviewer, rejection comment, and execution errors.
- Pending Reviews view (project admins only) — every
pending_approvalrequest you can act on, with a live count badge.
Actions offered per status (the API additionally lets project admins execute / retry / cancel — see endpoints):
| Status | Requester (My Requests) | Project admin (Pending Reviews) |
|---|---|---|
pending_approval | Cancel | Approve · Reject · Cancel |
approved | Execute · Cancel | view only |
execution_failed | Retry (if retryable) + error detail | view only |
executed | Link to the resulting job | view only |
Notifications
Route these through notification rules:
| Event | Fires when |
|---|---|
approval.requested | A request enters pending_approval (not emitted for auto-approved requests). |
approval.approved | A request is approved — including admin auto-approval at submit time. |
approval.rejected | A reviewer rejects. |
approval.executed | Execution completed successfully. |
approval.expired | A stale request is flipped to expired. |
Payloads carry operation_type, primary_domain, and the requester / reviewer display names.
API endpoints
All under /api/v1, authenticated. The action endpoints are rate-limited to 10 approval actions per user per minute.
| Method | Path | Who |
|---|---|---|
| GET | /approval-requests/my | Any user — own requests |
| GET | /approval-requests/:id | Any user in the org |
| POST | /approval-requests/:id/approve | Project admin (not the requester) |
| POST | /approval-requests/:id/reject | Project admin |
| POST | /approval-requests/:id/execute | Requester or project admin |
| POST | /approval-requests/:id/retry | Requester or project admin |
| POST | /approval-requests/:id/cancel | Requester or project admin |
| GET | /downloads/:token | Holder of a valid, unused token (user-bound) |
| GET | /projects/:projectId/approval-requests | Project admin — review queue |
Troubleshooting
"an open request already exists for this certificate"
One open request per certificate + operation. Find it on My Requests and cancel or complete it first.
Approved, but nothing happened
Approval doesn't execute. Open My Requests, find the approved row, click Execute.
"cannot approve your own request"
Separation of duties — another project admin must review it. (If you're an admin submitting your own operations, they auto-approve at submit time instead.)
Download token "expired" seconds after execute
The token lives 60 seconds and works once, for the user who executed. Execute again to mint a fresh token and let the browser follow it immediately.