Separation of duties in RBAC: stop self-approval bugs
Separation of duties in RBAC keeps people from approving their own work. The permission bugs I fixed in an approval workflow, and the rules I design with now.
By Hamza Ghouri
Separation of duties is easy to state and easy to break. The person who writes something should not be the person who approves it. Most RBAC setups can express that rule, and plenty of them still let an author approve their own work, because the approve action quietly reuses a permission the author already holds.
I spent a couple of weeks tightening the permissions in an approval workflow I built for a compliance product: risks, policies and acceptances, each with owners, reviewers and executive sign-off. This post walks through the separation of duties problems I found and fixed there, and the design rules I now start from. Every issue described here is fixed.
What is separation of duties in RBAC?
Separation of duties means no single role can both perform a sensitive action and approve it. In role-based access control, you get it by putting the "do" permission and the "check" permission in different roles, and by making sure no shortcut hands both to one person. NIST's glossary describes it as the principle that no user should be given enough privileges to misuse the system on their own.
Approval must be its own permission
The first bug was the classic one. Approve, resend and reject all required the edit permission, so anyone who could work on a risk could approve it: "an author approving their own work." The fix was a separate review permission that the editor role does not hold.
Policies got the same treatment. The person who writes a policy is not the person who approves it, so the owner role excludes the approve permission.
The general lesson: whenever an action changes who is accountable for a record (approve, sign off, accept, close), give it a permission of its own. Reusing edit because "editors are trusted" is how separation of duties disappears without anyone deciding to remove it.
Model roles as permission sets
Real people do not fit one role. In this product a person is often two roles at once. My commit message had the example: "the head of IT owns two risks and the controls under three others." Adding more values to a role enum cannot express that.
So the module roles sit on top of the platform RBAC as permission sets, and holding two roles gives you the union of both sets:
ROLE_PERMISSIONS = {
"risk_editor": {"risk.edit"},
"risk_reviewer": {"risk.review"},
"policy_owner": {"policy.edit"},
"policy_approver": {"policy.approve"},
}
def permissions_for(roles: list[str]) -> set[str]:
return set().union(*(ROLE_PERMISSIONS[r] for r in roles))
Union is the right default, and it is also where separation of duties needs a second check. A person who holds both editor and reviewer can still be the author of the record in front of them. A permission says a person may edit records, not which ones. So the record decides, by comparing the actor with the owner it carries. When ownership is handed over, the handover writes its audit entry in the same transaction, so the history and the current owner can never disagree.
Admin bypasses defeat the rule
The subtlest fix involved the admin role. The permission helper waved admins through every gate, and the admin role also carried the executive-approval permission outright. Removing only the bypass "would have changed nothing", because the permission was still there. Now executive authority comes only from holding the executive role or an explicit grant.
If your permission helper starts with if user.is_admin: return True, every separation of duties rule in the system has an exception for the most powerful accounts. I'd rather admins manage roles and hold no approval authority by default.
Route approvals by what is being approved
Approval routing has its own traps:
- An unconfigured authority matrix holds approvals instead of falling back to the lower approver, and a partially filled matrix is refused. A missing rule should stop the process, never relax it.
- Routing reads the rating being approved, not the one the record arrived with. A risk rescored during review goes to the approver its new score requires.
- Acceptances route by the worst risk they cover. An executive sign-off from anyone who is not an executive is refused, and the required level is worked out again at decision time, since the risks may have been rescored in the meantime.
Enforce scope on the server
Several fixes were about where the decision is made. The OWASP Authorization Cheat Sheet says to validate permissions on every request, and each of these was a place where the server had trusted the client:
- Action items. Any authenticated user could pass
mine=falseand read every action item in the tenant. Scope is now the server's decision, and a duplicate copy of the rule in the frontend was removed so the two cannot drift. - The owner picker is filtered on the server, because a list that merely looked right "would put an auditor one deploy away from owning what they review."
- Role edits. The user listing returned no module roles, so the edit screen read them as empty and wrote that back, "silently stripping every role a person held on an unrelated edit." The listing now returns them.
- Sessions. The login cookie lived seven days while the token expired in an hour, so the route guard admitted people whose every request was then refused. The cookie lifetime now comes from the token's
expclaim, and a 401 clears the session once.
Give automation its own account
The product has an AI engine that drafts and scores records. It became a real account per tenant, so its actions are attributed and checked like anyone else's. The account uses a .invalid email address and has no password hash and no route to one, since a password reset link would turn it into a login that acts under the engine's name.
The same thinking applies to any agent inside a product: give it an identity, give it only the permissions its job needs, and keep its approvals separate from a human's. I wrote about that for in-product agents in AI agent guardrails in production, and about tying roles to plan limits in AI assistant usage limits for SaaS.
Where I'd start
- List every action that changes accountability (approve, accept, sign off, close) and make sure each has its own permission.
- Check which roles hold both a "do" and a "check" permission for the same record type, and decide on purpose whether that is allowed.
- Find any admin bypass in your permission helper and remove it.
- Make missing configuration stop approvals instead of defaulting to the lowest approver.
- Move every scope decision to the server, and delete client copies of the rule.
- Give automated actors their own accounts with no way to log in.
In a multi-tenant product, every one of these checks sits inside the tenant boundary; I covered that boundary in multi-tenant SaaS architecture. If you are designing an approval flow and want a review, see my services.