Skip to content
Docs/CITADEL Governance/Separation of Duties
Edit on GitHub

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:

  1. sod.operator_user_id ≠ sod.verifier_user_id — the same person cannot be both proposer and approver.
  2. 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:

  1. An operator proposes an action by filling out a Kerkese.
  2. A verifier approves it by supplying their user_id.
  3. 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 groupCanonical identifierRoles it contains (example)
Operationssig_operatorops-oncall, ops-shift-lead, ops-junior
Securitysig_verifiersecurity-engineer, soc-analyst, sec-lead
Auditsig_auditorauditor, compliance-reviewer
Administrationsig_adminadmin, 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.

Note: The "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

internal/marshal/marshal.go — Gate 3 (NDS)
Go
"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")
}
ConditionStatusReason string
Operator ID == verifier IDHARD_STOPNDS_SAME_IDENTITY: operator and verifier are the same user
Operator or verifier has no valid sessionFAILNDS_FAIL: operator/verifier has no valid session
Both users in the same role group (not "unknown")HARD_STOPNDS_SAME_GROUP: operator and verifier are both in role group "X"
Both IDs are zero (read-only / single-principal action)PASSGate 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 outcomeReason
user 42 / sig_operatoruser 77 / sig_verifierPASSDifferent IDs, different groups — valid pair
user 42 / sig_operatoruser 99 / sig_adminPASSDifferent IDs, different groups — valid pair
user 42 / sig_adminuser 43 / sig_auditorPASSDifferent IDs, different groups — valid pair
user 42 / sig_operatoruser 42 / sig_verifierHARD_STOPSame user_id regardless of role group
user 42 / sig_operatoruser 55 / sig_operatorHARD_STOPDifferent IDs but same role group
user 42 / sig_verifieruser 66 / sig_verifierHARD_STOPDifferent IDs but same role group
user 42 / sig_operatoruser 77 / unknownPASS (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 typeWhy SoD is required
CONTAINDisruptive to production; mistakes cost availability
DELETE_RESOURCEDestructive — cannot be undone
DATA_EXPORTExfiltration risk — also subject to AUGUR rule_03 (HARD_STOP if no incident_id)
CREDENTIAL_ROTATEBreaks active sessions; peer review prevents accidental lockouts
POLICY_OVERRIDEAffects 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_STOP creates 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:

  1. Operator logs in, receives a JWT with sub=alice.
  2. Operator drafts an action and POSTs it to IRFlow.
  3. IRFlow checks actor_id (from JWT) ≠ verifier_id (from request body) at the application layer — before calling CITADEL at all.
  4. 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:

Kerkese sod block
Bash
"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:

Query WORM for Gate 3 HARD_STOP entries
Bash
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.

Warning: A 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.

SIN — Security Intelligence Network — Open-source cybersecurity & compliance for the EU Digital Decade.

Apache-2.0 & AGPL-3.0 · GitHub · Contribute