Separation of Duties
NDS (Ndarja e Detyrimeve të Sigurisë — Separation of Security Duties) is MARSHAL's Gate 3. It ensures that no single operator can unilaterally authorise a governance-relevant action: every such action requires an initiating identity (the operator) and a verifying identity (the verifier) that are cryptographically distinct at both the user and role-group levels.
Unlike an access-control policy that an administrator can relax or override, NDS is enforced at the cryptographic protocol level: a stolen operator credential cannot self-approve a privileged action because it cannot also control the verifier account. Violating the SoD constraint causes an immediate HARD_STOP verdict and an automatic P1 incident in IRFlow — not a permission-denied error that an operator might attempt to route around.
Overview
Gate 3 runs after Gate 1 (AuthN) and Gate 2 (AuthZ). By the time NDS evaluates, the actor's session has been confirmed valid and the requested action type is known to be permitted for the actor's role. NDS then asks: is the action backed by two genuinely independent identities?
The check is expressed as two constraints:
sod.operator_user_id ≠ sod.verifier_user_id— the same person cannot be both proposer and approver.- The operator and verifier must belong to different role groups — two colleagues in the same team (same group) cannot form a valid pair, because the assumption is they could trivially collude.
The two-key principle
The NDS design follows the classical two-key principle from military and nuclear safety: no single person can launch the missile. Translated to software governance:
- An operator proposes an action by filling out a Kerkese.
- A verifier approves it by supplying their
user_id. - MARSHAL evaluates both identities independently — the verifier is not a rubber-stamp; they carry their own session and role, and MARSHAL checks both.
If the operator and verifier are the same person — literally (same user_id) or effectively (same role group, so they could trivially cooperate in bad faith) — MARSHAL issues a HARD_STOP.
Canonical role groups
A role group is a coarser classification than a role. CITADEL maintains the grouping out of band. Four groups are canonical in a typical deployment:
| Role group | Canonical identifier | Roles it contains (example) |
|---|---|---|
| Operations | sig_operator | ops-oncall, ops-shift-lead, ops-junior |
| Security | sig_verifier | security-engineer, soc-analyst, sec-lead |
| Audit | sig_auditor | auditor, compliance-reviewer |
| Administration | sig_admin | admin, superadmin |
Two operators both in sig_operator cannot form a valid SoD pair. A pair must span two groups, for example sig_operator ↔ sig_verifier or sig_admin ↔ sig_verifier.
"unknown" group is a sentinel for misconfigured sessions. Gate 3 deliberately does not force a HARD_STOP on unknown groups to avoid breaking deployments during a migration. Set role groups explicitly in production — empty role groups log a WARN on CITADEL startup.Gate 3 checks in order
"cm">// 1. Same-identity check — HARD_STOP if sod.OperatorUserID == sod.VerifierUserID { return HARD_STOP("NDS_SAME_IDENTITY: operator and verifier are the same user") } "cm">// 2. Both parties must have valid sessions — FAIL if not opSession := store.SessionExists(operatorUserID) vfSession := store.SessionExists(verifierUserID) "cm">// 3. Role-group check — HARD_STOP if opSession.roleGroup == vfSession.roleGroup && opSession.roleGroup != "unknown" { return HARD_STOP("NDS_SAME_GROUP: operator and verifier are both in role group X") }
| Condition | Status | Reason string |
|---|---|---|
| Operator ID == verifier ID | HARD_STOP | NDS_SAME_IDENTITY: operator and verifier are the same user |
| Operator or verifier has no valid session | FAIL | NDS_FAIL: operator/verifier has no valid session |
| Both users in the same role group (not "unknown") | HARD_STOP | NDS_SAME_GROUP: operator and verifier are both in role group "X" |
| Both IDs are zero (read-only / single-principal action) | PASS | Gate short-circuits — SoD not required for this action type |
Why HARD_STOP not REFUSE?
Passing the same user_id for both operator and verifier is not a configuration mistake — it is a deliberate attempt to defeat the separation control. Downgrading it to REFUSE would invite a retry loop where the caller keeps submitting variations until one slips through.
HARD_STOP escalates immediately:
- IRFlow auto-creates a P1 incident record.
- The WORM chain is anchored immediately — not at the next 100-entry boundary.
- The action context is locked; no further evaluation of this Kerkese is possible.
- Operators must resolve the incident through normal governance channels — there is no administrative override.
Allowed vs refused pairs
The following table shows example operator/verifier combinations and their Gate 3 outcome:
| Operator (user_id / group) | Verifier (user_id / group) | Gate 3 outcome | Reason |
|---|---|---|---|
user 42 / sig_operator | user 77 / sig_verifier | PASS | Different IDs, different groups — valid pair |
user 42 / sig_operator | user 99 / sig_admin | PASS | Different IDs, different groups — valid pair |
user 42 / sig_admin | user 43 / sig_auditor | PASS | Different IDs, different groups — valid pair |
user 42 / sig_operator | user 42 / sig_verifier | HARD_STOP | Same user_id regardless of role group |
user 42 / sig_operator | user 55 / sig_operator | HARD_STOP | Different IDs but same role group |
user 42 / sig_verifier | user 66 / sig_verifier | HARD_STOP | Different IDs but same role group |
user 42 / sig_operator | user 77 / unknown | PASS (with WARN) | Unknown group treated as distinct — gate does not block, but logs a warning |
SoD-scoped action types
By convention, the following action types always require SoD. Gate 2 (AuthZ) encodes this intent in the RBAC matrix; Gate 3 then enforces it:
| Action type | Why SoD is required |
|---|---|
CONTAIN | Disruptive to production; mistakes cost availability |
DELETE_RESOURCE | Destructive — cannot be undone |
DATA_EXPORT | Exfiltration risk — also subject to AUGUR rule_03 (HARD_STOP if no incident_id) |
CREDENTIAL_ROTATE | Breaks active sessions; peer review prevents accidental lockouts |
POLICY_OVERRIDE | Affects the rule that just rejected something — especially sensitive |
Read-only actions such as GET_INCIDENT or LIST_IOCS should not carry SoD. Gate 3 short-circuits PASS when both sod.operator_user_id and sod.verifier_user_id are zero.
Protocol-level, not policy-level
Most SoD implementations are policy-level: an administrator configures a rule, and the same administrator can turn the rule off. CITADEL's NDS is different in a critical respect: it is enforced by the cryptographic session model, not by a configurable rule.
The operator and verifier are required to have independently authenticated sessions — a JWT for user 42 cannot impersonate user 77. Constructing a Kerkese where the same human controls both sides requires a session theft, not a policy change. This means:
- No admin flag can weaken the check for a single request.
- A compromised operator credential alone is insufficient — the attacker also needs a separate verifier credential in a different role group.
- The constraint is auditable: every Gate 3 evaluation appears in the WORM chain, and every
HARD_STOPcreates an immutable incident record.
Caller's responsibility
SoD works only if the initiating and verifying identities are cryptographically distinct before the Kerkese reaches CITADEL. IRFlow enforces this at the service layer as the first line of defence:
- Operator logs in, receives a JWT with
sub=alice. - Operator drafts an action and POSTs it to IRFlow.
- IRFlow checks
actor_id (from JWT) ≠ verifier_id (from request body)at the application layer — before calling CITADEL at all. - The verifier must have separately authenticated and provided their ID. A genuinely distinct identity check cannot be spoofed from within a single session.
CITADEL's Gate 3 is the second line of defence: even if IRFlow were bypassed entirely, MARSHAL still checks the identities against the session store.
The Kerkese sod block looks like:
"sod": {
"operator_user_id": 42,
"verifier_user_id": 77
}Attack surface and mitigations
Gate 3 catches all same-identity and same-role-group attempts automatically. Two residual attack surfaces are worth understanding:
Collusion between role groups. Gate 3 cannot detect two distinct users in different role groups deliberately cooperating in bad faith. Example: a security engineer and an operations lead agree to exfiltrate data and approve each other's actions.
Mitigation: AUGUR rule_03 forces a HARD_STOP on any DATA_EXPORT without an incident_id, regardless of SoD. The attack then requires fabricating an incident, which is visible to anyone querying the incident list. The raised visibility significantly increases detection risk.
Future work (v1.2+): third-party approval for extremely sensitive actions — for example POLICY_OVERRIDE might require three distinct signatures, not two.
Session hijack. An attacker holding valid JWTs for both the operator and verifier passes Gate 3 trivially.
Mitigations: short token TTL (8 h default in sinauth), MFA on token issuance, WORM audit log review, anomaly detection via AUGUR rule_01 (off-hours) and rule_02 (high-frequency submissions).
Role-group misconfiguration. If role groups are not configured, Gate 3 falls back to the same-identity check only — collusion between two users with the same role but different IDs becomes possible. Empty role groups log a WARN on CITADEL startup for exactly this reason.
Auditing SoD decisions
Every Gate 3 decision is recorded in the WORM chain. To find all HARD_STOP events from Gate 3:
SELECT ts_utc, payload FROM worm_entries WHERE event_type = 'marshal.decision' AND payload::jsonb -> 'gates' @> '[{"gate":3,"status":"HARD_STOP"}]';
Any result is a potential insider threat event. The false-positive rate is approximately zero — every match represents a Kerkese where someone attempted a single-identity or same-group SoD bypass. Investigate every one.
HARD_STOP from Gate 3 automatically creates a P1 incident in IRFlow. Do not close the incident without a documented investigation. The WORM entry is immutable and will be available to auditors for the full retention period.