Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLIPublic
Assigneesstack72

Relationships

#1957 Separation of duties impossible: workflow.approve is authorized against the 'run' action, so approvers can start runs and operators can self-approve

Opened by lightning_swamp · 9/2/2026· Shipped 9/7/2026

Version: 20260902.023341.0-sha.820f22f0 Context: replacing a commercial cutover-runbook tool with swamp workflows, where four-eyes approval on the cutover gate is an audit requirement.

Problem

manual_approval steps cannot be used to implement separation of duties, because workflow.approve and workflow.resume are both authorized against the run action — the same action that permits starting a run.

Two consequences, both of which defeat the point of an approval gate:

  1. The approver must be allowed to start runs. To let an account release a gate you must grant it run on the workflow, which also lets it launch runs itself.
  2. Anyone who can start a run can approve their own gate. An operator with run (which they need in order to start anything) can approve the very run they submitted. Self-approval is silent and indistinguishable from a second-party approval in the grant model.

A deny grant cannot separate the two, because denying run also removes the ability to approve.

Evidence

Two accounts, user:runner and user:approver, against a workflow with a manual_approval step. Grants in grants/, swamp serve --auth-mode token --grants-dir grants.

Subject Grant on workflow:mvp-runbook workflow.approve workflow.resume workflow.run
user:approver [read, write] DENIED DENIED DENIED
user:approver [run, read, write] ALLOWED ALLOWED ALLOWED (unwanted)
user:runner [run, read] ALLOWED on its own run ALLOWED ALLOWED

Exact denial for the read+write approver:

Access denied: user:approver does not have 'run' on workflow:mvp-runbook

So write is not what gates approval, and there is no grant combination that expresses "may approve, may not start" or "may start, may not self-approve".

Requested change

A distinct action for releasing an approval gate, so the two can be granted independently:

grants:
  # second pair of eyes: can release the gate, cannot launch anything
  - subject: idp-group:change-approvers
    effect: allow
    actions: [approve, read]
    resource: workflow:prod-cutover

  # operator: can launch, cannot approve their own submission
  - subject: idp-group:migration-engineers
    effect: allow
    actions: [run, read]
    resource: workflow:prod-cutover

resume is worth considering separately too — "continue an approved run" is a different privilege from "start a new run", though it is a much less important distinction than approve.

Optionally: an explicit four-eyes constraint

Even with a separate approve action, someone holding both run and approve could still self-approve. A condition would close that:

  - subject: idp-group:change-approvers
    effect: allow
    actions: [approve]
    resource: workflow:prod-cutover
    when: 'run.initiatedBy != subject'

This would need the run's initiating principal exposed in the condition context. Today initiatedBy appears in /internal/runs, so the data exists.

Workaround and why it is not sufficient

Grant the approver run and rely on the audit trail: workflow.approve records the deciding principal, confirmed as decidedBy: user:approver. That gives after-the-fact attribution, which is genuinely useful.

It does not prevent self-approval, and it cannot be shown to an auditor as an enforced control — only as a detective one. For regulated cutovers the requirement is usually preventative.

The same grant model has a rough edge worth noting separately: listing runs and approvals is authorized against workflow:* rather than the individual workflow, so a grant scoped only to workflow:<name> leaves a client connected but every list empty, with does not have 'read' on workflow:*. Not part of this request, but it surprised us while building the grants above.

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW+ 7 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

9/7/2026, 8:00:53 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack729/4/2026, 5:36:37 PM
Editable. Press Enter to edit.

stack72 commented 9/7/2026, 8:01:58 AM

Thanks @lightning_swamp for reporting this! The fix has been merged and a release is on its way. We appreciate your contribution to swamp.

Sign in to post a ripple.