RBAC & Privilege Gates
Audience: Engineers configuring role assignments or matter ACLs; compliance officers reviewing access control.
What you will accomplish: Understand the default-deny RBAC model, the non-overridable privilege gate, and how gate approval authorization works — so you can assign roles, configure ACLs, or audit access.
Prerequisites: Read Security overview and Audit & Logging first.
Estimated time: ~10 minutes.
Access control in the Case Automation MCP Server follows default-deny at every layer: tool invocation, matter access, document routing, and gate approval.
Role definitions
| Role | Identity | Typical grants |
|---|---|---|
attorney | Fee-earner / partner | Full read + write + gate approval |
paralegal | Case manager | Read + write (confirm) + draft |
intake_coordinator | Front desk | Intake workflow + contact search |
operations | IT / admin | Connector health + system config |
agent_service | MCP server identity | Minimal: read + draft + workflow.run |
admin | Super-user | All permissions |
The AI agent acts under the agent_service role, which has the minimal grant set:
matter.read, contact.read, document.read, deadline.read, email.draft, workflow.run, workflow.status.
It cannot send emails, create matters, or route documents without human gate approval.
Permission matrix (simplified)
| Tool / Action | attorney | paralegal | intake_coordinator | operations | agent_service |
|---|---|---|---|---|---|
matter.get | ✓ | ✓ | ✓ | ✓ | ✓ |
matter.create | ✓ | ✓ | ✗ | ✗ | ✗ |
contact.upsert | ✓ | ✓ | ✓ | ✗ | ✗ |
document.generate | ✓ | ✓ | ✗ | ✗ | ✗ |
document.route (internal) | ✓ | ✓ | ✗ | ✗ | ✗ |
document.route (external) | ✓ | ✗ | ✗ | ✗ | ✗ |
email.draft | ✓ | ✓ | ✓ | ✗ | ✓ |
email.send | ✓ | ✗ | ✗ | ✗ | ✗ |
deadline.compute | ✓ | ✓ | ✓ | ✓ | ✓ |
deadline.schedule | ✓ | ✓ | ✗ | ✗ | ✗ |
workflow.run | ✓ | ✓ | ✓ | ✓ | ✓ |
workflow.approve | ✓ | ✗ | ✗ | ✗ | ✗ |
qc.verify | ✓ | ✓ | ✓ | ✓ | ✓ |
intake.run | ✓ | ✓ | ✓ | ✗ | ✗ |
Matter-level scoping
In addition to role-based permissions, every matter carries an ACL that scopes access to specific principals:
class ACL(BaseModel):
principals: list[str] # user IDs or role names
permission: Literal["read", "write", "none"] # access level
external: bool # whether this is an external shareA user must satisfy both their role grant and the matter's ACL to access a matter.
Privilege gate
The privilege gate is a non-overridable safety invariant:
A
Document.privileged=Truecan never reach an external recipient, regardless of approval tokens.
What counts as "external"
An external recipient is any principal not in matter.participants:
external = recipient_id not in set(p.id for p in matter.participants)Gate logic
if document.privileged and external:
return CheckResult(
status="fail",
check_id="privilege",
reason=f"Privileged document {document.id} cannot reach external recipient {recipient_id}",
)This check runs before any approval gate. Even if an attorney explicitly approves the routing, the privilege gate blocks it.
Testing
The privilege invariant is proven by a Hypothesis property-based test:
@given(st.builds(Document, privileged=st.just(True)), st.sampled_from(EXTERNAL_RECIPIENTS))
def test_pbt_privilege_never_passes_external(doc, recipient):
result = RoutingService.route(doc, recipient)
assert result.status == "blocked"This generates hundreds of (privileged, external) combinations and asserts that none pass.
Gate approval authorization
When a workflow hits a GATE:* step, the approver must satisfy:
- Token validity — the token exists, has not expired, and has not been used
- Role requirement — the approver's role matches
required_role(e.g.,attorney) - Single-use atomicity — the token is consumed atomically in the database; concurrent approvals race and only one succeeds
async def resolve_gate(
raw_token: str,
decision: Literal["approve", "reject"],
actor: str,
channel: Literal["mcp", "web", "email"],
signing_key: bytes,
store: Any,
audit_fn: Any | None = None,
) -> ApprovalDecision:
gate = await store.get_token(raw_token)
if gate is None or gate.expired:
raise GateResolutionError("Invalid or expired token")
if not authorize(actor, "workflow.approve"):
raise GateResolutionError(f"Requires role: {gate.required_role}")
# Atomicity handled inside RunStore via SQLAlchemy async session
if not await store.verify_and_consume(raw_token):
raise GateResolutionError("Token already consumed")
record = await store.save_decision(gate, actor, decision, channel)
if audit_fn:
await audit_fn("gate.resolved", {"gate_id": gate.id, "decision": decision, "actor": actor})
return ApprovalDecision(status=decision)Approval token security
Gate tokens are HMAC-SHA256 signed with these properties:
- Single-use: consumed atomically in a database transaction; concurrent approvals race and only one succeeds
- Expiring: TTL is configurable per gate (default 24 hours); expired tokens cannot be revived
- Bound: each token is bound to
(gate_request_id, run_id, step)— cannot be transferred to a different gate - Constant-time verification: HMAC comparison uses constant-time comparison to prevent timing attacks
- Only the hash is stored: the raw token is never persisted; verification re-derives the hash from the presented token
Token delivery uses three channels (MCP tool callback, web UI /approvals/{token}, email action link), all sharing the same token pool.
Default-deny implementation
Access control uses the standalone authorize() function:
from cam.security.rbac import authorize
# Both role grant and ACL must pass
def authorize(
principal: Principal,
permission: str,
resource_checker: Callable[[Principal, str], bool] | None = None,
) -> bool:
# 1. Role-based check — Principal.roles is a frozenset
role_grant = any(
_role_matrix.get(role, {}).get(permission, False)
for role in principal.roles
)
# 2. Resource-level ACL check if resource_checker is provided
if resource_checker:
acl_grant = resource_checker(principal, permission)
return role_grant and acl_grant
return role_grantIf a permission is not explicitly granted to a role, it is denied.
Audit integration
Every permission check and gate resolution writes to the audit log:
{
"actor": "user:attorney_42",
"action": "workflow.approve",
"inputs": {
"run_id": "run_abc123",
"token": "tok_xyz789",
"decision": "approve"
},
"approval": {
"decision": "approve",
"approver": "user:attorney_42",
"channel": "web"
},
"run_id": "run_abc123"
}Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Granting admin role to the agent service identity | Agent can do anything, including bypass normal gates | Use agent_service role with minimal grants; never elevate to admin |
| Assuming approval overrides the privilege gate | Privileged doc to external recipient is blocked regardless of approval | The privilege gate runs before the approval gate; it is non-overridable |
| Not setting matter ACLs | Only role-based checks apply; no matter-level scoping | Set ACL.principals on every matter to scope access |
| Using a single role for all staff | Over-permissioned accounts increase blast radius | Assign the most restrictive role that covers the user's duties |
Verification
After reading this page, verify your understanding:
- Explain why the privilege gate cannot be overridden even by an admin approval
- Describe the two checks required for a user to access a matter (role grant + ACL)
- Show what happens when two approvers try to approve the same gate simultaneously
- Name the minimal grant set for the
agent_servicerole
Next steps
- Audit & Logging — How every action is recorded
- Encryption & Secrets — How data is protected at rest and in transit
Last updated: 2026-06-01