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_typeper 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.
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.
Related
- Continuous Control Monitoring — turns these tagged records into a control-effectiveness time series.
- Rule-Based Drift Scoring — produces the scored signals this map labels by criterion.
- Audit Report Artifact Formats — packages the per-criterion evidence into an auditor deliverable.