Continuous control monitoring
Continuous control monitoring (CCM) reframes RBAC drift detection from a periodic audit chore into a standing control that reports its own effectiveness every time it runs. Instead of asking “was access correct on the day we sampled it,” a CCM pipeline asks “is the control that keeps access correct working right now, and can I prove it for every hour since the last audit?” Each drift signal the detection engine emits is bound to a named control, every control carries a live pass or fail state, and the evidence trail is regenerated continuously rather than reconstructed under deadline pressure during an audit window.
The failure scenario when this is neglected is the classic point-in-time gap. An auditor samples access on the last day of the quarter, everything looks clean, and the attestation is signed — while for six weeks earlier a deprovisioned contractor’s role retained DELETE on a production schema and no one could say when the excess grant appeared or when it was cleared. CCM closes that gap by turning the drift stream into a control-effectiveness time series: the control has a status at every moment, backed by an append-only record. This guide sits inside Compliance Evidence & Audit Reporting and consumes the scored output of Rule-Based Drift Scoring, converting scored deltas into control verdicts.
Prerequisites and scope
Continuous control monitoring is a thin, deterministic layer over an existing drift pipeline. It adds no new database privileges and issues no DDL against monitored targets — it consumes scored drift records and emits control evaluations. Before wiring it in, confirm:
- A scored drift stream. Each drift record must already carry a stable
signal_type, the affectedprincipaland object, a detection timestamp, and — critically — aremediated_atvalue populated when the finding is cleared. If your remediation pipeline does not emit a clearance event keyed on the record’s content hash, the effectiveness calculation cannot tell open findings from closed ones. - PostgreSQL 12+ or MySQL 8.0.19+ for the evidence store. The examples use PostgreSQL
intervalarithmetic andFILTERaggregates; the MySQL equivalents are noted where they differ. - Python 3.11+. The evaluation core uses only the standard library (
dataclasses,datetime,enum,hashlib), so it carries no third-party dependency into your compliance path. - A fixed control catalogue. Every
signal_typeyour engine can emit must map to exactly one named control before go-live. An unmapped signal is a silent hole in coverage, so the mapping fails closed rather than defaulting.
The scope is deliberately narrow: CCM decides which control a signal exercises and whether that control is currently effective. It does not classify the underlying access — that is Privilege Scope Mapping — and it does not decide retention or serve auditor queries, which belong to Audit Trail Retention and Query.
Core implementation walkthrough
Three steps take a scored drift stream to a continuously refreshed control-effectiveness record: bind signals to controls, evaluate each control as of a pinned instant, and roll effectiveness up over a window with SQL.
Step 1 — Bind every drift signal to exactly one named control
The heart of CCM is a total function from signal type to control. It is version-controlled beside the policy manifest, and it raises rather than defaults so a new, unmapped deviation class cannot slip through as “no control exercised.”
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
class Outcome(str, Enum):
PASS = "pass"
FAIL = "fail"
# Each drift-signal class is owned by exactly one named control.
SIGNAL_TO_CONTROL: 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 DriftSignal:
signal_type: str
principal: str
obj: str
detected_at: datetime
remediated_at: datetime | None # None while the finding is still open
def control_for(signal: DriftSignal) -> str:
try:
return SIGNAL_TO_CONTROL[signal.signal_type]
except KeyError as exc:
raise ValueError(f"unmapped drift signal: {signal.signal_type!r}") from exc
Verify the map is total against your engine’s signal vocabulary before you deploy:
ENGINE_SIGNALS = {"excess_grant", "wildcard_grant", "orphaned_role",
"missing_deprovision", "unauthorized_membership", "stale_revoke"}
assert ENGINE_SIGNALS <= SIGNAL_TO_CONTROL.keys(), "coverage gap in control map"
Step 2 — Evaluate each control as of a pinned instant
A control is effective at instant as_of when it has no open finding — no signal that was detected on or before as_of and not yet remediated by that instant. Pinning as_of (rather than calling now() inside the loop) is what makes an evaluation reproducible: the same signals and the same instant always yield the same verdict.
import hashlib
def evaluate_control(control_id: str, signals: list[DriftSignal],
*, as_of: datetime) -> dict:
"""Effective at `as_of` iff no finding for this control is open then."""
open_findings = [
s for s in signals
if control_for(s) == control_id
and s.detected_at <= as_of
and (s.remediated_at is None or s.remediated_at > as_of)
]
outcome = Outcome.FAIL if open_findings else Outcome.PASS
body = f"{control_id}:{as_of.isoformat()}:{outcome.value}:{len(open_findings)}"
return {
"control_id": control_id,
"observed_at": as_of.isoformat(),
"outcome": outcome.value,
"open_findings": len(open_findings),
"evidence_hash": hashlib.sha256(body.encode()).hexdigest(),
}
The evidence_hash is a content address over the identifying fields, not over wall-clock time, so an auditor can recompute it and confirm the verdict was not edited after the fact — the same tamper-evidence contract enforced in Evidence Integrity and Signing. Running the evaluator over a fixed window produces the always-current record:
as_of = datetime.now(timezone.utc)
report = [evaluate_control(c, signals, as_of=as_of)
for c in sorted(set(SIGNAL_TO_CONTROL.values()))]
Step 3 — Roll effectiveness up over a window in SQL
Point-in-time verdicts become a trend once appended to an evaluation table. This query computes control effectiveness — the share of evaluations that passed — over a rolling 90-day window, the standard SOC 2 continuous-monitoring cadence:
-- Control effectiveness over a rolling 90-day window, per named control.
SELECT
control_id,
count(*) AS evaluations,
count(*) FILTER (WHERE outcome = 'pass') AS passed,
round(100.0 * count(*) FILTER (WHERE outcome = 'pass')
/ count(*), 1) AS effectiveness_pct,
max(observed_at) AS last_evaluated
FROM control_evaluation
WHERE observed_at >= now() - interval '90 days'
GROUP BY control_id
ORDER BY effectiveness_pct;
To surface a control that is slipping, a window function exposes the run-over-run change in daily effectiveness so a downward trend is visible before it breaches the attestation threshold:
SELECT
control_id,
bucket,
effectiveness_pct,
effectiveness_pct - lag(effectiveness_pct) OVER (
PARTITION BY control_id ORDER BY bucket
) AS day_over_day_delta
FROM control_effectiveness_daily
ORDER BY control_id, bucket;
Idempotency and safety contract
CCM is safe to run on a tight schedule because it is a pure transform over inputs it never mutates:
- No writes to monitored databases. Evaluation reads a scored drift stream and appends to the evaluation store only. Running it every minute changes nothing in any target database.
- Deterministic verdicts. For a fixed signal set and a fixed
as_of,evaluate_controlreturns an identicaloutcome,open_findings, andevidence_hash. Re-evaluating an unchanged window is verifiably a no-op at the content level. - Pinned time, not wall-clock time. Because
as_ofis a parameter, a batch of controls evaluated in one run share a single instant, so their verdicts are mutually consistent — no control sees a finding as open while its neighbour, milliseconds later, sees it closed. - Convergence. When the drift stream carries no open finding for a control, its verdict is
passand stayspasson every subsequent run until a new signal arrives — the fixed point of the loop.
Treat the SIGNAL_TO_CONTROL map as a versioned artifact. When a control’s scope genuinely changes, bump the version and record it alongside the evaluation so historical verdicts remain interpretable against the mapping that produced them.
Compliance alignment
The point of CCM is that the evidence is a by-product of operation, not a document assembled at audit time. Each evaluation is a timestamped, hash-addressed assertion that a named control was tested and found effective (or not) at a specific instant, and the append-only series of those assertions is the continuous-monitoring evidence.
| Control | What CCM continuously demonstrates | Evidence artifact |
|---|---|---|
| SOC 2 CC6.1 | Logical access is restricted to authorized principals at every evaluated instant | pass/fail series for the excess-grant and wildcard-grant signals |
| SOC 2 CC6.2 | Deprovisioning is timely; no orphaned role retains live privilege | Open-finding count for orphaned-role and missing-deprovision signals |
| SOC 2 CC6.3 | Access modifications stay authorized and role-appropriate | Reconciliation verdicts for unauthorized-membership and stale-revoke signals |
| PCI DSS Req. 7 | Least-privilege effectiveness is measured continuously, not sampled | 90-day effectiveness percentage per CDE-scoped control |
The concrete signal-to-criterion table and the tagging code that stamps each record live in mapping RBAC drift to SOC 2 Trust Service Criteria. The evaluation series then feeds the packaged deliverables described in Audit Report Artifact Formats.
Troubleshooting matrix
Continuous monitoring fails quietly: the dashboard keeps showing green while the underlying signal is mis-bound or stale. Each row pairs the observable symptom with its root cause and fix.
| Failure scenario | Root-cause signature | Remediation |
|---|---|---|
| Control shows green while drift plainly exists | The signal reaches evaluation but is absent from SIGNAL_TO_CONTROL, so it is never counted against any control |
Keep the map total; assert ENGINE_SIGNALS <= SIGNAL_TO_CONTROL.keys() in CI and let control_for raise on the unmapped class |
| Effectiveness percentage flickers between adjacent runs | now() is called inside the loop, so each signal is tested against a slightly different instant |
Pin one as_of per batch and pass it into every evaluate_control call |
| Remediated findings still count as open | The remediation pipeline never wrote remediated_at back onto the record |
Join remediation events on the drift record’s content hash and backfill remediated_at before evaluating |
| Auditor reports the evidence looks stale | The evaluation job is not scheduled continuously; the last observed_at is days old |
Run on a fixed cadence, emit a heartbeat, and alert when now() - max(observed_at) exceeds the interval |
| Every control fails during a catalog outage | A short snapshot read every principal as having lost its grants, manufacturing universal drift | Gate evaluation on an expected row-count floor; hold the last-good verdict and re-run once the snapshot is whole |
Read together, these guardrails keep the control honest: it reports failure only when a real, mapped, unremediated finding exists, and it reports it for the exact interval the finding was open.
Related
- Rule-Based Drift Scoring — the scored deltas CCM turns into control verdicts.
- Mapping RBAC drift to SOC 2 Trust Service Criteria — the concrete signal-to-CC6.x table and the tagging code.
- Audit Trail Retention and Query — where the evaluation series is retained and made queryable.
- Evidence Integrity and Signing — hash-chaining and signing the evaluation records so verdicts are tamper-evident.
- Audit Report Artifact Formats — packaging the continuous evidence into auditor-ready deliverables.