Dual control (the two-person rule) is an Enterprise feature that forces high-risk operations through a second person: a requester initiates, the request sits as pending, and a different approver must approve before the operation executes. Self-approval is blocked.
Gated operations
Nine operations can be gated:
legal_hold.release— release a legal holduser.delete— delete a useraccount.delete— delete an email accountmailbox.delete— delete a mailboxmessage.delete— delete messagestoken.revoke— revoke an API tokenexport.batch— run a batch exportgdpr.erasure— execute a GDPR Art. 17 erasure requestretention.update— weaken an account's retention policy (shorten the maximum-retention window, or shorten/remove the minimum-retention lock)
The gate is implemented as route middleware in front of the destructive endpoints; it is off by default, and only the operations an administrator lists in ops are actually gated. Listing an operation in the code is not enough to turn it on.
For export.batch, the approval records the exact selection: the saved search id, or the ad-hoc filter frozen at request time. No artifact is produced while the request is pending — the API answers 202 and no export job exists yet. Approving runs that recorded selection (one-hour artifact TTL applies); rejecting or expiring the request leaves nothing behind.
For retention.update, only weakening changes are gated. Enabling or lengthening retention, or adding/extending a deletion lock, applies directly — requiring a second signature to keep data longer would train approvers to click through requests without reading them. The approval records the before and after values of both the maximum-retention window and the minimum-retention lock, and the executor applies exactly those values (it does not re-read the account, so a second change made while the request waits cannot be overwritten).
For message.delete on a search result, the approval records the exact frozen id set, not the filter: the matching messages are enumerated when the request is queued, and only those ids are deleted after approval. Mail that arrives while the request waits is not included, so the approver signs a concrete list rather than a query that keeps moving. Because the list can be very large, it is stored in a detail file under <root>/approval-details/; the ledger row keeps the count, the per-account split and a SHA-256 of the file. The queue computes the same set at submission time, and re-submitting an identical pending selection is refused instead of queueing a second approval for the same messages.
Approving runs the deletion through the same asynchronous worker as an ordinary batch delete: it is chunked, writes per-batch audit events, and appears in the requester's delete history (the action is theirs). The job also names the approver as its executor, and every audit row it emits carries the deletion job id — the audit log can be filtered by that id, so a decision can be walked to the batches it produced. A deletion started from an approval is still on the community plan's terms: permanent, no trash, with tombstones so a gap-fill does not re-download the messages.
Request lifecycle
pending → approved → executing → executed, with rejected, expired (decision window: 72 hours by default), and interrupted as terminal states. The decision ledger is flush-persisted and retained for 365 days.
Break-glass
Emergencies happen. After a server-enforced wait (break_glass_min_hours, default 1 hour, -1 disables the mechanism entirely), a root admin can approve alone via POST /api/v1/approvals/{id}/break-glass. The audit record is flagged with how long was waited, so the override is visible in every audit.
API
| Method | Path | Description |
|---|---|---|
GET | /api/v1/approvals | Approval history |
GET | /api/v1/approvals/pending-accounts | Accounts with pending requests |
GET/POST | /api/v1/approvals/config | Enable / configure the gate |
POST | /api/v1/approvals/{id}/approve | Approve |
POST | /api/v1/approvals/{id}/reject | Reject |
POST | /api/v1/approvals/{id}/break-glass | Emergency override |
Approvers need the approval:decide permission — which the built-in compliance officer role carries. That role exists while an Enterprise license is active; uploading the license on a running instance creates it immediately, so no restart is needed before the first approval.
Policy settings
GET/POST /api/v1/approvals/config reads and updates the policy:
| Field | Default | Meaning |
|---|---|---|
enabled | false | Master switch |
ops | [] | Operations to gate; only the eight names above are accepted. An operation not listed here is not gated, even when enabled is true. |
expires_after_hours | 72 | How long a pending request stays decidable (0 = default) |
retention_days | 365 | How long decided requests are kept (0 = default) |
break_glass_min_hours | 1 | Wait before break-glass becomes available; 0 = default, -1 disables break-glass |
Enabling the master switch alone changes nothing: the operation must also be listed in ops. This is deliberate — shipping a new gateable operation must not silently start gating it on existing deployments.