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.
Building the SOC 2 access-review JSON document Scored drift records for a review period enter a review builder on the left. The builder groups records by principal, attaches the reviewer identity and the CC6.1 and CC6.3 trust service criteria, and validates against a pydantic model. It emits a JSON access-review document on the right in which each principal carries its reviewed access and a disposition of retain or revoke. scored records for the review period review builder group by principal · validate model reviewer + CC6.1 / CC6.3 trust criteria · sign-off review JSON per principal: retain / revoke
Figure 1 — The review document is a grouping-and-validation pass over already-scored records, not a fresh database query.

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.

Up: Audit Report Artifact Formats