Now domain-agnostic — configure any practice with Domain Packs (immigration is the reference pack)
Rbac Privilege

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

RoleIdentityTypical grants
attorneyFee-earner / partnerFull read + write + gate approval
paralegalCase managerRead + write (confirm) + draft
intake_coordinatorFront deskIntake workflow + contact search
operationsIT / adminConnector health + system config
agent_serviceMCP server identityMinimal: read + draft + workflow.run
adminSuper-userAll permissions
Agent service identity

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 / Actionattorneyparalegalintake_coordinatoroperationsagent_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 share

A 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=True can 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:

  1. Token validity — the token exists, has not expired, and has not been used
  2. Role requirement — the approver's role matches required_role (e.g., attorney)
  3. 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_grant

If 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

MistakeConsequenceFix
Granting admin role to the agent service identityAgent can do anything, including bypass normal gatesUse agent_service role with minimal grants; never elevate to admin
Assuming approval overrides the privilege gatePrivileged doc to external recipient is blocked regardless of approvalThe privilege gate runs before the approval gate; it is non-overridable
Not setting matter ACLsOnly role-based checks apply; no matter-level scopingSet ACL.principals on every matter to scope access
Using a single role for all staffOver-permissioned accounts increase blast radiusAssign the most restrictive role that covers the user's duties

Verification

After reading this page, verify your understanding:

  1. Explain why the privilege gate cannot be overridden even by an admin approval
  2. Describe the two checks required for a user to access a matter (role grant + ACL)
  3. Show what happens when two approvers try to approve the same gate simultaneously
  4. Name the minimal grant set for the agent_service role

Next steps


Last updated: 2026-06-01