Detecting Toxic Access with Graph-Based SoD Engines

Static role matrices fail the moment your enterprise crosses roughly 15,000 active entitlements spread across ERP, cloud IAM, and internal tooling. Compliance teams running quarterly access reviews in spreadsheets cannot detect a segregation of duties violation that spans three systems and two approval chains — by the time the audit surfaces it, the toxic combination has existed in production for months. This is the exact failure mode that graph-based segregation of duties engines are built to close: real-time, cross-system conflict detection that treats entitlements as a traversable graph rather than a flat access control list.
Segregation of duties is not a new control. NIST SP 800-53’s AC-5 control has mandated it for decades. What has changed is the topology of enterprise access — federated identity, SCIM-provisioned SaaS roles, and ephemeral cloud IAM policies mean that a single user’s effective privilege set is no longer visible in any one system’s console. Enforcing segregation of duties now requires a control plane that ingests entitlements from every authoritative source and evaluates conflict rules against the union of that access, not against each system in isolation.
#The Architectural Bottleneck
Conventional SoD tooling built into GRC suites (SAP GRC, SailPoint’s separation module) models conflicts as a matrix of role pairs: Role A conflicts with Role B. This works when entitlements are coarse and confined to a single ERP instance. It collapses under three conditions common in modern estates:
Break-Glass Access: When Your IdP Goes Dark
- Entitlements are fine-grained (AWS IAM actions, Salesforce permission sets) rather than coarse business roles, producing combinatorial explosion in the conflict matrix.
- Access is transitively inherited through nested groups, meaning a toxic combination can exist without either conflicting entitlement being directly assigned.
- Toxic combinations span multiple systems — creating a vendor in the ERP and approving a payment run in the treasury platform are never visible in the same access review because no single system owns both facts.
A matrix-based model is O(n²) in the number of entitlements and cannot express transitive or cross-system paths without exponential rule proliferation. The fix is to move the conflict detection problem into a graph, where transitive inheritance and cross-system paths are native traversal operations rather than bespoke joins.
#Graph Model for Segregation of Duties
The graph schema needs three node types and two relationship classes to be useful in production:
- User nodes, keyed by a canonical identity attribute (typically an immutable HR employee ID, not an email address, to survive rename events).
- Entitlement nodes, representing the atomic permission — an ERP transaction code, an IAM action, a permission set — tagged with the owning system.
- Group/Role nodes, representing the aggregation layer through which entitlements are typically granted.
- HAS_MEMBER and GRANTS relationships connecting users through roles to entitlements, with nested group membership expressed as recursive HAS_MEMBER edges.
Segregation of duties conflict rules are then expressed as graph patterns: find any user with a path to both Entitlement X and Entitlement Y, regardless of how many role hops separate the user from each. This is precisely what property graph query languages such as Cypher were designed for, and it is why Neo4j and Amazon Neptune are the de facto backbone of mature segregation of duties platforms rather than relational schemas with recursive CTEs bolted on.
#Conflict Rule as a Graph Query
1// Detect users with a transitive path to both a toxic pair of entitlements
2MATCH (u:User)-[:HAS_MEMBER*0..4]->(:Role)-[:GRANTS]->(e1:Entitlement {code: 'VENDOR_CREATE'}),
3 (u)-[:HAS_MEMBER*0..4]->(:Role)-[:GRANTS]->(e2:Entitlement {code: 'PAYMENT_APPROVE'})
4WHERE e1 e2
5RETURN u.employeeId AS user, e1.code AS entitlementA, e2.code AS entitlementBThe bounded variable-length path (*0..4) is deliberate — unbounded traversal on a supernode (a group with 40,000 members) will blow the query budget. Capping hop depth to the deepest legitimate nesting level in your directory (usually 3-4 for Active Directory-derived hierarchies) keeps this query sub-second even against graphs with tens of millions of edges.

#Implementation Logic
Building this out as a production control involves four discrete stages, and the ordering matters because each stage has a different latency budget.
#1. Entitlement Ingestion
Every authoritative system needs a connector that emits entitlement grants and revocations as events, not just a nightly full export. SCIM 2.0’s /Groups and /Users endpoints cover SaaS applications; for ERP and mainframe systems you are typically parsing scheduled CSV exports or CDC streams off the underlying database. The critical design decision is normalising every source into a common entitlement taxonomy before it touches the graph — without this, your conflict rules have to know about every system’s native permission naming convention, which does not scale past a handful of integrations.
#2. Graph Load and Incremental Sync
Full graph rebuilds are acceptable for the initial load; after that, apply deltas via MERGE operations keyed on stable identifiers to avoid duplicate node proliferation. A nightly full reconciliation pass against the source-of-truth systems catches drift introduced by direct database writes that bypassed the event stream.
#3. Conflict Evaluation
Run the segregation of duties ruleset in two modes simultaneously: a batch sweep across the entire graph nightly for compliance reporting, and a real-time check triggered on every provisioning event so that a toxic grant is blocked before it commits rather than discovered a week later. The real-time path cannot afford a full graph traversal per request at scale — it should query only the neighbourhood of the user being provisioned, which is why the same Cypher pattern above, scoped with a WHERE u.employeeId = $id clause, is cheap enough to sit in the provisioning critical path.
#4. Policy Enforcement at the Provisioning Gateway
The enforcement point should be the identity gateway or IGA workflow engine issuing the grant, evaluated as policy-as-code rather than embedded application logic, so the same segregation of duties ruleset governs every connected system consistently. Open Policy Agent is a reasonable fit here because it decouples the rule authoring from the enforcement runtime — see the official Rego policy language reference for the underlying evaluation semantics.
1package sod.provisioning
2
3import future.keywords.in
4
5default allow := true
6
7# Deny if the requested entitlement, combined with the user's existing
8# entitlement set, matches a defined toxic pair.
9deny[msg] {
10 some pair in data.sod_rules.toxic_pairs
11 requested := input.requested_entitlement
12 existing := input.user.entitlements[_]
13 requested == pair.entitlement_b
14 existing == pair.entitlement_a
15 msg := sprintf("SoD violation: %v conflicts with existing %v", [requested, existing])
16}
17
18allow := false {
19 count(deny) > 0
20}The toxic_pairs data document is sourced directly from the graph’s conflict-rule catalogue via a scheduled export, keeping the enforcement policy declarative and version-controlled rather than hardcoded into application logic.

#Rule Catalogue Configuration
1sod_rules:
2 version: 3
3 toxic_pairs:
4 - id: FIN-001
5 entitlement_a: VENDOR_CREATE
6 entitlement_b: PAYMENT_APPROVE
7 severity: critical
8 owning_control: AC-5
9 - id: FIN-002
10 entitlement_a: PO_CREATE
11 entitlement_b: PO_APPROVE
12 severity: high
13 owning_control: AC-5
14 - id: IAM-014
15 entitlement_a: aws:iam:CreateUser
16 entitlement_b: aws:iam:AttachUserPolicy
17 severity: critical
18 owning_control: AC-5
19 evaluation:
20 real_time: true
21 batch_schedule: "0 2 * * *"
22 fail_mode: closedNote the fail_mode: closed setting — this determines what happens when the segregation of duties evaluation service is unreachable, and it is the single most consequential configuration decision in the entire design, discussed further below.
#Failure Modes and Edge Cases
A graph-based segregation of duties engine introduces failure characteristics that a spreadsheet-based review never had to deal with, because it is now a live dependency in the provisioning path rather than a periodic audit artefact.
- Stale graph data producing false negatives. If the incremental sync from a source system lags (common with ERP batch exports that run every six hours), a user’s entitlement can be revoked upstream but still appear grantable in the graph, allowing a toxic combination to be approved against outdated state. Mitigate with a max-staleness SLA per connector and surface it as a health metric, not a silent assumption.
- Transitive escalation via group nesting. A user added to a low-privilege group that is itself nested inside a privileged group inherits entitlements invisibly. Bounded-depth traversal catches this only if the depth cap is set correctly for your directory’s actual nesting depth — audit this value whenever a new OU structure is introduced.
- Real-time check latency blocking provisioning. If the graph query against a supernode is not properly bounded, a provisioning request can time out, and depending on
fail_mode, either silently grant access (fail-open) or block a legitimate business-critical request (fail-closed) at the worst possible moment, such as during a financial close. - Rule drift on system onboarding. Every new connected application introduces new entitlement codes that are not yet mapped into the toxic-pair catalogue. Without a mandatory onboarding gate requiring SoD rule review before a new system goes live, coverage silently degrades over time.
- Supernode query degradation. Directory groups with tens of thousands of members create graph traversal hotspots. Materialising a denormalised
EFFECTIVE_GRANTSedge (precomputed nightly) for high-fan-out groups avoids re-traversing the same subgraph on every real-time check.
#Scaling and Security Trade-offs
Deploying a segregation of duties engine at enterprise scale forces several architectural trade-offs that should be decided explicitly rather than left as defaults, in line with broader architectural patterns for control-plane design.
- Batch-only vs hybrid real-time evaluation — batch sweeps are cheaper and easier to reason about but leave a detection window measured in hours to days; hybrid real-time evaluation closes that window to milliseconds at the cost of adding a hard dependency to every provisioning transaction.
- Fail-open vs fail-closed on evaluator outage — fail-closed is the compliant default for regulated environments (SOX, financial controls) but requires a well-tested break-glass path, or an outage in the graph database becomes an outage in HR onboarding and vendor provisioning.
- Relational vs native graph storage — recursive CTEs on a relational schema are viable up to roughly a few hundred thousand entitlement edges; beyond that, query planning cost grows non-linearly and a native graph engine with indexed relationship traversal becomes the only viable option for sub-second real-time checks.
- Coarse business roles vs fine-grained entitlements — coarse roles keep the conflict matrix small and auditable but hide the actual toxic combinations that live at the permission level; fine-grained modelling is accurate but multiplies the node and edge count the graph engine must index.
- Centralised rule catalogue vs per-system ownership — a single version-controlled catalogue guarantees consistent segregation of duties enforcement across systems but creates a change-management bottleneck; distributed ownership scales change velocity but risks contradictory or duplicate rule definitions across teams.
None of these trade-offs are free, and the correct choice depends on which regulatory framework is driving the control — a SOX-scoped financial system generally forces fail-closed and centralised ownership, whereas a lower-risk SaaS estate can tolerate batch-only evaluation with a longer remediation window.
Segregation of duties has moved from a compliance checkbox to a live architectural component that sits directly in the identity provisioning path. Treating it as a graph traversal problem, rather than a static matrix maintained in a spreadsheet, is what makes cross-system toxic combination detection tractable at the scale modern enterprise IAM estates now operate at.
Comments
Add a thoughtful note on Detecting Toxic Access with Graph-Based SoD Engines. Comments are checked for spam and held for moderation before appearing.




