Mapping RBAC drift to SOC 2 Trust Service Criteria

This guide gives a concrete, defensible mapping from each RBAC drift signal to a specific SOC 2 Trust Service Criterion — CC6.1, CC6.2, or CC6.3 — states the evidence each pairing produces, and shows the Python that stamps every drift record with its criterion id so the trail is queryable by control.

An auditor does not want your drift dashboard; they want to know which control each finding exercises and where the evidence lives. A record that says excess_grant on prod.claims means nothing to a SOC 2 reviewer until it is labelled CC6.1 with an artifact attached. Mapping is the translation layer between engineering signals and the criteria the report is organized around, and it is the input to Continuous Control Monitoring.

When to use this — and when not to

Use an explicit drift-to-TSC map when:

  • You are preparing for or maintaining a SOC 2 Type II examination and the auditor expects evidence indexed by criterion, not by database.
  • Your drift engine already emits a stable signal_type per finding and you need every record to carry a control label before it reaches the evidence store.
  • You want control-level rollups (“how many open CC6.2 findings this period”) rather than raw finding counts.

Do not reach for this when:

  • You have not yet fixed detection recall. Tagging a leaky signal stream produces confident labels on incomplete evidence — worse than no map.
  • Your obligation is HIPAA or PCI DSS only. The signal taxonomy transfers, but the criterion ids do not; map to §164.312 or Requirement 7 instead.
  • The mapping would be one-to-many and ambiguous. If a signal genuinely spans two criteria, model that explicitly (shown below) rather than picking one and hoping.
Drift signals mapped to SOC 2 criteria and their evidence Six drift signals — excess grant, wildcard grant, orphaned role, missing deprovision, unauthorized membership, and stale revoke — connect to three SOC 2 Common Criteria: CC6.1 logical access restriction, CC6.2 user registration and deprovisioning, and CC6.3 access modification authorization. Each criterion emits one evidence artifact: a scored-and-revoked record for CC6.1, a deprovisioning-gap record for CC6.2, and a change-versus-ticket reconciliation for CC6.3. excess grant wildcard grant orphaned role missing deprovision unauthorized membership stale revoke CC6.1logical access restriction CC6.2registration · deprovisioning CC6.3access modification scored + revoked recordleast-privilege proof deprovisioning-gap recorddetected-to-cleared window change-vs-ticket reconciliationauthorization evidence
Figure 1 — Each drift signal is owned by one Common Criterion, and each criterion emits a named evidence artifact the auditor can request by control.

Step-by-step implementation

Step 1 — Fix the signal-to-criterion map

The mapping is a total function committed beside the policy manifest. The three Common Criteria in scope carve cleanly along the access lifecycle: CC6.1 governs whether access is restricted, CC6.2 governs registration and deprovisioning of principals, and CC6.3 governs modification of existing access. This is the concrete table:

Drift signal TSC criterion What it demonstrates Evidence record produced
Excess object grant beyond a role’s documented function CC6.1 Logical access is restricted to what the role needs Scored drift record plus the revoke that cleared it
Wildcard or PUBLIC grant on a sensitive object CC6.1 Broad grants do not bypass least privilege Scored record tagged with object sensitivity
Orphaned role retaining live privileges after a person left CC6.2 Deprovisioning is complete and timely Deprovisioning-gap record: detected-to-cleared window
Grant surviving a role’s removal (missing deprovision) CC6.2 No residual access outlives its owner Residual-access record with clearance timestamp
Membership change with no matching approval ticket CC6.3 Access changes are authorized Change-versus-ticket reconciliation record
Stale revoke never applied after an approved change CC6.3 Approved removals actually take effect Pending-change aging record

Step 2 — Tag every drift record with its criterion in Python

The tagger reads the record’s signal_type, looks up its criterion, and returns a new record carrying tsc_id. It raises on an unmapped signal so a coverage gap surfaces in CI, never in the audit.

from dataclasses import dataclass, replace

# One drift-signal class -> its governing SOC 2 Trust Service Criterion.
SIGNAL_TSC: dict[str, str] = {
    "excess_grant":            "CC6.1",
    "wildcard_grant":          "CC6.1",
    "orphaned_role":           "CC6.2",
    "missing_deprovision":     "CC6.2",
    "unauthorized_membership": "CC6.3",
    "stale_revoke":            "CC6.3",
}

@dataclass(frozen=True, slots=True)
class DriftRecord:
    signal_type: str
    principal: str
    obj: str
    detected_at: str
    remediated_at: str | None = None
    tsc_id: str | None = None

def tag_with_tsc(record: DriftRecord) -> DriftRecord:
    """Stamp a record with its Trust Service Criterion; raise if unmapped."""
    tsc = SIGNAL_TSC.get(record.signal_type)
    if tsc is None:
        raise ValueError(f"unmapped drift signal: {record.signal_type!r}")
    return replace(record, tsc_id=tsc)

Verify the tagger against the two anchor cases — an excess grant lands on CC6.1, an orphaned role on CC6.2:

r1 = tag_with_tsc(DriftRecord("excess_grant", "analytics_ro", "prod.claims", "2026-07-18T09:00:00Z"))
r2 = tag_with_tsc(DriftRecord("orphaned_role", "ex_contractor", "prod.ledger", "2026-07-18T09:00:00Z"))
assert (r1.tsc_id, r2.tsc_id) == ("CC6.1", "CC6.2")

Step 3 — Roll findings up by criterion and confirm coverage

Once records carry tsc_id, a single query gives the control-level view a SOC 2 reviewer asks for — open versus remediated findings per criterion over the examination period:

SELECT
    tsc_id,
    count(*)                                          AS findings,
    count(*) FILTER (WHERE remediated_at IS NOT NULL) AS remediated,
    count(*) FILTER (WHERE remediated_at IS NULL)     AS open_findings,
    max(detected_at)                                  AS latest_finding
FROM drift_record
WHERE detected_at >= now() - interval '1 year'
GROUP BY tsc_id
ORDER BY tsc_id;

If any row returns tsc_id IS NULL, an untagged signal reached the store — a coverage gap to close before the map is trusted.

Worked example: PostgreSQL 15, one examination batch

Scenario: PostgreSQL 15, a single continuous-monitoring run pulls three fresh drift records — an excess UPDATE on prod.claims, an orphaned analyst role, and an unauthorized membership grant with no ticket. Tag and roll up:

batch = [
    DriftRecord("excess_grant", "analytics_ro", "prod.claims", "2026-07-18T09:00:00Z",
                remediated_at="2026-07-18T09:40:00Z"),
    DriftRecord("orphaned_role", "ex_contractor", "prod.ledger", "2026-07-18T09:00:00Z"),
    DriftRecord("unauthorized_membership", "svc_batch", "role:writer", "2026-07-18T09:00:00Z"),
]
tagged = [tag_with_tsc(r) for r in batch]

from collections import Counter
by_criterion = Counter(r.tsc_id for r in tagged)
print(dict(sorted(by_criterion.items())))
open_by_criterion = Counter(r.tsc_id for r in tagged if r.remediated_at is None)
print(dict(sorted(open_by_criterion.items())))

Expected output:

{'CC6.1': 1, 'CC6.2': 1, 'CC6.3': 1}
{'CC6.2': 1, 'CC6.3': 1}

The batch touches all three criteria once each; the excess grant is already remediated, so only CC6.2 (the orphaned role) and CC6.3 (the unapproved membership) remain open. That two-line open-findings map is exactly what a Type II reviewer wants at the top of the CC6 section.

Gotchas and engine-specific notes

CC6.1 versus CC6.6 boundary. CC6.1 is internal logical access; CC6.6 concerns access from outside the system boundary. A database grant is almost always CC6.1 — do not scatter grant findings across CC6.6 because a principal happens to connect over the network.

MySQL role activation changes what “membership” means. On PostgreSQL an entry in pg_auth_members grants effective privilege immediately. On MySQL 8 a mysql.role_edges row confers nothing until the role is a default or is set with SET ROLE, so an unauthorized_membership signal must be raised only after resolving activation against mysql.default_roles; otherwise you tag inert grants as CC6.3 findings.

One signal can genuinely touch two criteria. A grant that outlives a deprovisioned user is both a residual-access (CC6.2) and a modification-authorization (CC6.3) concern. When that is real, model the value as a tuple, not a string, and explode it at rollup:

SIGNAL_TSC_MULTI: dict[str, tuple[str, ...]] = {
    "missing_deprovision": ("CC6.2", "CC6.3"),
}

Criterion ids drift between framework revisions. The 2017 and 2022 Trust Services Criteria share the CC6 numbering used here, but always confirm against your auditor’s current SOC 2 Trust Services Criteria mapping and version the map when it changes.

Compliance note

This mapping is what makes RBAC drift evidence legible to a SOC 2 Type II examination: every finding carries the exact Common Criterion it exercises, so the auditor selects evidence by control rather than sifting raw database output. The per-record tsc_id plus the detected-and-remediated timestamps produce the operating-effectiveness evidence CC6.1, CC6.2, and CC6.3 each require — a population the auditor can sample, with each sampled item traceable to the drift record and the remediation that closed it. Because tagging is deterministic and version-controlled, the reviewer can re-derive every label from the committed map, closing the “how did you decide this was a CC6.2 issue” gap that hand-classified evidence always leaves open.

Frequently asked questions

Which SOC 2 criteria do database RBAC findings actually map to? Almost all map to CC6.1, CC6.2, or CC6.3 — restriction of logical access, registration and deprovisioning of principals, and authorization of access modifications. Connection-security concerns may reach CC6.6 or CC6.7, but a grant, membership, or revoke finding belongs in CC6.1 through CC6.3.

What if one drift signal legitimately spans two criteria? Model it as a tuple of criteria and explode it during rollup, so the finding is counted under each control it exercises. Never silently pick one criterion — an auditor who finds a residual-access issue filed only under CC6.1 will question the whole map.

Do I remap when the Trust Services Criteria are revised? Yes, and version the map so historical evidence still resolves against the criteria in force when it was produced. The CC6 numbering has been stable across recent revisions, but confirm against your auditor’s current criteria set before an examination.

How is this different from continuous control monitoring? Mapping decides which criterion a signal exercises; continuous control monitoring decides whether that criterion is currently effective. Tagging is the input; the effectiveness time series is the output.

Up: Continuous Control Monitoring