Skip to main content

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:

ToggleSettingGates
Issuerequire_approval_for_issueNew certificate requests
Reissuerequire_approval_for_reissueReissue with new parameters / key rotation
Renewrequire_approval_for_renewManual renewals
Revokerequire_approval_for_revokeRevocation at the CA
Downloadrequire_approval_for_downloadPrivate-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):

ToggleSettingDefaultGates
Require approval for configuration changesconfig_change_approval_disabled (kill-switch)ONOperator 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 distributionrequire_approval_for_distributionOFFOperator 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.

Scheduler auto-deploy is NOT gated

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_edit applies synchronously — Execute calls the target service's Create / Update / Delete directly; there is no job.
  • distribute enqueues 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.

Import bypasses the approval gates

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

StatusMeaning
pending_approvalAwaiting a reviewer.
approvedAccepted — waiting for an explicit Execute.
executingOperation in flight.
executedRan successfully (job outcome tracked on the job itself).
execution_failedOperation errored; retryable failures can go back to approved.
rejectedDeclined with a reason.
cancelledWithdrawn by the requester or an admin before execution.
expiredNot 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_edit change synchronously (Create / Update / Delete on the resource — no job), or
  • enqueues the distribution execute / rollback job for a distribute request.

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:

  1. Requester asks to download; a download approval request is created (or auto-approved for admins).
  2. A project admin approves.
  3. Requester (or admin) executes — the response contains a raw download token.
  4. 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_approval request you can act on, with a live count badge.

Actions offered per status (the API additionally lets project admins execute / retry / cancel — see endpoints):

StatusRequester (My Requests)Project admin (Pending Reviews)
pending_approvalCancelApprove · Reject · Cancel
approvedExecute · Cancelview only
execution_failedRetry (if retryable) + error detailview only
executedLink to the resulting jobview only

Notifications

Route these through notification rules:

EventFires when
approval.requestedA request enters pending_approval (not emitted for auto-approved requests).
approval.approvedA request is approved — including admin auto-approval at submit time.
approval.rejectedA reviewer rejects.
approval.executedExecution completed successfully.
approval.expiredA 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.

MethodPathWho
GET/approval-requests/myAny user — own requests
GET/approval-requests/:idAny user in the org
POST/approval-requests/:id/approveProject admin (not the requester)
POST/approval-requests/:id/rejectProject admin
POST/approval-requests/:id/executeRequester or project admin
POST/approval-requests/:id/retryRequester or project admin
POST/approval-requests/:id/cancelRequester or project admin
GET/downloads/:tokenHolder of a valid, unused token (user-bound)
GET/projects/:projectId/approval-requestsProject 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.

See also