SOC 2 access review evidence in JSON
This page specifies the exact JSON document a drift pipeline emits to evidence a SOC 2 CC6.1 and CC6.3 periodic access review — the pydantic v2 model that validates it, and the concrete output an auditor accepts as proof that access was reviewed and dispositioned.
A SOC 2 access review fails audit not when access is wrong, but when the review cannot be shown to have happened. Assessors want a per-principal record: what access existed during the period, who reviewed it, and whether it was retained or revoked. Hand-built spreadsheets satisfy this once and rot immediately. Generating the review document straight from canonical drift records — the audit report artifact formats this section standardizes — makes the review a reproducible export rather than an annual fire drill.
When to use this — and when not to
Use the JSON access-review document when:
- Your SOC 2 report covers CC6.1 (logical access is restricted to authorized users) and CC6.3 (access is added, modified, and removed under review), and you need machine-verifiable evidence per review period.
- You already emit scored deviations with a principal, an object, a governing control, and a disposition decision.
- Your reviewers work a queue and sign off dispositions that must be captured alongside the access facts.
Do not reach for this when:
- You need a flat, human-first spreadsheet for a committee that will not open JSON — project to CSV instead, as the PCI attestation report does.
- Your access facts are not yet scored or dispositioned. A review document over undispositioned rows evidences nothing; fix the scoring stage first.
- The objects in scope are ePHI, where §164.312 wants unique-user-identification framing — use the HIPAA evidence export.
Step-by-step implementation
Step 1 — Model the review document with pydantic v2
The document has two levels: a period-scoped envelope and a per-principal review line. Modeling both with pydantic v2 means an invalid disposition or a missing reviewer fails at construction, not at audit.
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, Field, field_validator
Disposition = Literal["retain", "revoke", "modify"]
class AccessReviewLine(BaseModel):
principal: str
reviewed_access: list[str] = Field(min_length=1) # ["SELECT ON cust.orders", ...]
trust_criteria: list[str] # ["CC6.1", "CC6.3"]
disposition: Disposition
evidence_hash: str
class AccessReviewDocument(BaseModel):
framework: Literal["SOC2"] = "SOC2"
review_period: str # "2026-Q2"
reviewer: str
generated_at: datetime
lines: list[AccessReviewLine]
@field_validator("generated_at")
@classmethod
def must_be_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None or v.utcoffset() != timezone.utc.utcoffset(None):
raise ValueError("generated_at must be timezone-aware UTC")
return v
Verify the model rejects a naive timestamp and an empty access list:
from pydantic import ValidationError
try:
AccessReviewDocument(review_period="2026-Q2", reviewer="j.okoro",
generated_at=datetime(2026, 7, 18), lines=[])
except ValidationError as e:
print(e.error_count(), "errors") # -> 1 errors (naive datetime)
Step 2 — Build review lines from scored records
Group the period’s scored deviations by principal, collapse each principal’s grants into a reviewed_access list, and set the disposition from the decision. A compliant decision retains; anything else flags for revoke or modify.
from collections import defaultdict
def build_review(records: list[dict], *, period: str, reviewer: str,
generated_at: datetime) -> AccessReviewDocument:
by_principal: dict[str, list[dict]] = defaultdict(list)
for r in records:
by_principal[r["principal"]].append(r)
lines = []
for principal, recs in sorted(by_principal.items()):
access = sorted(f'{r["privilege"]} ON {r["object"]}' for r in recs)
worst = "retain" if all(r["decision"] == "compliant" for r in recs) else "revoke"
lines.append(AccessReviewLine(
principal=principal,
reviewed_access=access,
trust_criteria=sorted({r["control"] for r in recs}),
disposition=worst, # type: ignore[arg-type]
evidence_hash=recs[0]["evidence_hash"],
))
return AccessReviewDocument(review_period=period, reviewer=reviewer,
generated_at=generated_at, lines=lines)
Step 3 — Serialize deterministically
Emit the document with sorted keys so two exports of the same period diff cleanly. Pydantic v2’s model_dump(mode="json") handles the datetime and enum serialization.
import json
def to_json(doc: AccessReviewDocument) -> str:
return json.dumps(doc.model_dump(mode="json"), indent=2, sort_keys=True)
Expected output for one principal:
{
"framework": "SOC2",
"generated_at": "2026-07-18T09:00:00+00:00",
"lines": [
{
"disposition": "revoke",
"evidence_hash": "c41d8f0e2b7a9d63a1f5e0c8b2d47e91f6a0c3b5d8e2f1a4c7b90e6d3f2a1b8c",
"principal": "analytics_ro",
"reviewed_access": [
"SELECT ON cust.orders",
"UPDATE ON cust.orders"
],
"trust_criteria": ["CC6.1", "CC6.3"]
}
],
"review_period": "2026-Q2",
"reviewer": "j.okoro"
}
Worked example: quarterly review on PostgreSQL 15
Scenario: PostgreSQL 15, a Q2 review of the analytics_ro role, which should be read-only. Drift scoring flagged an unexpected UPDATE on cust.orders. The review builder emits a document showing both the compliant SELECT and the violating UPDATE, with a revoke disposition because not all access was compliant.
from datetime import datetime, timezone
records = [
{"principal": "analytics_ro", "privilege": "SELECT", "object": "cust.orders",
"control": "CC6.1", "decision": "compliant", "evidence_hash": "c41d8f0e2b7a9d63a1f5e0c8b2d47e91f6a0c3b5d8e2f1a4c7b90e6d3f2a1b8c"},
{"principal": "analytics_ro", "privilege": "UPDATE", "object": "cust.orders",
"control": "CC6.3", "decision": "violation", "evidence_hash": "c41d8f0e2b7a9d63a1f5e0c8b2d47e91f6a0c3b5d8e2f1a4c7b90e6d3f2a1b8c"},
]
doc = build_review(records, period="2026-Q2", reviewer="j.okoro",
generated_at=datetime(2026, 7, 18, 9, 0, tzinfo=timezone.utc))
print(doc.lines[0].disposition) # -> revoke
print(doc.lines[0].trust_criteria) # -> ['CC6.1', 'CC6.3']
The single principal collapses two catalog rows into one review line, tagged with both trust criteria, dispositioned revoke because the UPDATE broke the read-only expectation. That one line is the atomic unit a SOC 2 assessor signs.
Gotchas and engine-specific notes
PostgreSQL vs MySQL grant granularity. PostgreSQL reports column-level grants in information_schema.role_column_grants separately from table grants; a review that reads only role_table_grants misses a UPDATE (status) column grant. MySQL 8 has no column-grant catalog view of the same shape, so the reviewed-access list is coarser. Note the engine in the envelope so an assessor knows the grant resolution behind each line.
Role membership is not direct access. A principal may hold UPDATE only through a granted role. Resolve effective privileges via pg_auth_members before building lines, or the review understates access. On MySQL, a role in mysql.role_edges contributes nothing until activated with SET ROLE or listed in mysql.default_roles.
Disposition is per principal, not per grant. SOC 2 reviewers sign off a principal, so a single non-compliant grant taints the whole line’s disposition to revoke. If your process disposition each grant independently, model reviewed_access as objects with their own disposition rather than strings.
Empty periods still need a document. A period with zero deviations must emit a document with an empty lines array and the reviewer sign-off, not no file at all — absence of evidence is not evidence of review.
Compliance note
This document satisfies SOC 2 CC6.1 (logical access is restricted to authorized users and systems) and CC6.3 (access is added, modified, and removed based on roles and reviewed) by producing a per-principal, per-period record with an explicit reviewer and disposition. Because each line carries the evidence_hash from its canonical record, the artifact ties back to the immutable evidence set and is ready for the tamper-evidence guarantees of evidence integrity and signing. The SOC 2 Trust Services Criteria define CC6.1 and CC6.3 in full.
Frequently asked questions
Why JSON rather than a spreadsheet for a SOC 2 review? JSON validates against a schema, serializes deterministically, and carries the evidence hash that proves it was not edited after generation. A spreadsheet does none of these, so a JSON document is far cheaper to reproduce every period and far harder to dispute.
How does the disposition get decided — automatically or by a human?
The builder proposes retain or revoke from the scored decision, but a human reviewer sets the final value and their identity is captured in reviewer. The automation removes the transcription toil; it does not remove the sign-off.
Do I need one document per principal or one per period? One per period, with one line per principal. The period envelope carries the reviewer and generation time; each line is the atomic unit an assessor signs, which keeps a quarter’s review as a single verifiable file.
What belongs in reviewed_access when a principal has dozens of grants?
List every effective grant as PRIVILEGE ON object, resolved through role membership, sorted for stable diffs. Truncating the list to a summary defeats the review — CC6.1 asks what access existed, not a sample of it.
Related
- PCI DSS Requirement 7 least-privilege attestation report — the CSV sibling for cardholder-data objects.
- HIPAA §164.312 access control evidence export — the ePHI framing of the same access facts.
- Rule-Based Drift Scoring — where the scored decisions on each record come from.