PCI DSS Requirement 7 least-privilege attestation report

This page specifies the CSV and JSON attestation report that proves least privilege on cardholder-data-environment objects for PCI DSS Requirement 7 — the exact columns a Qualified Security Assessor expects, generated from RBAC drift records rather than hand-collected screenshots.

PCI DSS Requirement 7 restricts access to cardholder data by business need-to-know. Where a SOC 2 review asks was access reviewed, a QSA asks something sharper: for every principal that can touch a CDE object, what is the documented business justification, and is the granted privilege the minimum that justification requires? An unstructured export cannot answer that per row. The attestation report below is a flat, one-row-per-grant artifact projected from the canonical audit report artifact formats record, scoped strictly to objects tagged as cardholder data.

When to use this — and when not to

Use the Requirement 7 attestation report when:

  • Your CDE includes database objects (cardholder tables, tokenization vaults, settlement schemas) and a QSA has asked for least-privilege evidence per principal.
  • Your objects are tagged with a cardholder sensitivity tier, so the report can be scoped to the CDE and nothing else.
  • You need a flat CSV a QSA can open, filter, and sign, plus an optional JSON twin for machine ingestion.

Do not reach for this when:

  • The objects are not cardholder data. A Requirement 7 attestation over non-CDE tables dilutes the evidence and invites scope-creep questions — use the SOC 2 review document for general access.
  • You have no business-need mapping. The defining column of this report is business_need; without it the attestation is just a grant dump.
  • You are evidencing ePHI, not cardholder data — that is the HIPAA export.
Building the PCI DSS Requirement 7 attestation CSV Canonical evidence records enter on the left and pass through a CDE filter that keeps only cardholder-tagged objects. The surviving rows are joined to a business-need register that supplies the documented justification per principal and object. A flattener then emits a fixed-column CSV attestation, one row per grant, each carrying a least-privilege verdict, alongside an optional JSON twin. canonical records all objects CDE filter sensitivity == cardholder business-need register justification per grant flatten fixed columns · verdict CSV + JSON one row per CDE grant
Figure 1 — The attestation is a CDE-scoped, business-need-joined flattening of the canonical record set into fixed columns.

Step-by-step implementation

Step 1 — Scope to the CDE and declare the column contract

A QSA rejects a report whose columns shift between quarters. Freeze the column order as a versioned constant, and filter the input to cardholder-tagged objects so nothing outside the CDE appears.

from dataclasses import dataclass

# Versioned column contract — a QSA reviews against exactly these, in order.
PCI_REQ7_COLUMNS = [
    "cde_object",              # schema.table inside the cardholder data environment
    "principal",               # role or user holding the grant
    "granted_privilege",       # SELECT, UPDATE, ALL PRIVILEGES, ...
    "access_path",             # direct | via_role:<role>
    "business_need",           # documented justification text
    "least_privilege_ok",      # true only if privilege == minimum the need requires
    "last_reviewed",           # ISO-8601 date of last attestation
    "attester",                # who signs this row
    "evidence_hash",           # ties row to the canonical record
]

@dataclass(frozen=True)
class BusinessNeed:
    principal: str
    cde_object: str
    justification: str
    minimum_privilege: str     # the least privilege the need actually requires

def is_cde(record: dict) -> bool:
    return record.get("sensitivity") == "cardholder"

Step 2 — Join the business-need register and compute the verdict

The least-privilege verdict is the crux of Requirement 7. A grant is least_privilege_ok only when the granted privilege is no broader than the minimum the documented need requires. UPDATE where the need is read-only fails; SELECT where the need is read-only passes.

# Ordering from least to most powerful; a grant passes only if it is at or below the need.
PRIV_RANK = {"SELECT": 1, "INSERT": 2, "UPDATE": 3, "DELETE": 4, "ALL PRIVILEGES": 9}

def attest_row(record: dict, need: BusinessNeed | None, *, attester: str,
               last_reviewed: str) -> dict:
    granted = record["privilege"].upper()
    if need is None:
        ok = False                      # no documented need => automatic fail
        justification = "UNDOCUMENTED"
    else:
        ok = PRIV_RANK.get(granted, 9) <= PRIV_RANK.get(need.minimum_privilege.upper(), 0)
        justification = need.justification
    return {
        "cde_object": record["object"],
        "principal": record["principal"],
        "granted_privilege": granted,
        "access_path": record.get("access_path", "direct"),
        "business_need": justification,
        "least_privilege_ok": ok,
        "last_reviewed": last_reviewed,
        "attester": attester,
        "evidence_hash": record["evidence_hash"],
    }

Verify the verdict logic on the two boundary cases:

need = BusinessNeed("settlement_svc", "cde.transactions", "nightly settlement read", "SELECT")
sel = attest_row({"object": "cde.transactions", "principal": "settlement_svc",
                  "privilege": "SELECT", "evidence_hash": "a1"}, need,
                 attester="qsa.liaison", last_reviewed="2026-07-01")
upd = attest_row({"object": "cde.transactions", "principal": "settlement_svc",
                  "privilege": "UPDATE", "evidence_hash": "a2"}, need,
                 attester="qsa.liaison", last_reviewed="2026-07-01")
print(sel["least_privilege_ok"], upd["least_privilege_ok"])   # -> True False

Step 3 — Emit the CSV a QSA opens, and its JSON twin

Write the CSV against the frozen column list; emit the same rows as JSON for pipelines. extrasaction="ignore" guarantees a stray field never widens the CSV silently.

import csv, io, json

def to_pci_csv(rows: list[dict]) -> str:
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=PCI_REQ7_COLUMNS, extrasaction="ignore")
    w.writeheader()
    w.writerows(sorted(rows, key=lambda r: (r["cde_object"], r["principal"])))
    return buf.getvalue()

def to_pci_json(rows: list[dict]) -> str:
    return json.dumps(sorted(rows, key=lambda r: (r["cde_object"], r["principal"])),
                      indent=2, sort_keys=True)

Expected CSV output:

cde_object,principal,granted_privilege,access_path,business_need,least_privilege_ok,last_reviewed,attester,evidence_hash
cde.transactions,settlement_svc,SELECT,direct,nightly settlement read,True,2026-07-01,qsa.liaison,a1
cde.transactions,support_ro,UPDATE,via_role:cde_writer,UNDOCUMENTED,False,2026-07-01,qsa.liaison,b7

Worked example: a support role over the cardholder schema on PostgreSQL 15

Scenario: PostgreSQL 15. The CDE schema cde holds transactions. A settlement_svc account legitimately reads it nightly; a support_ro role has picked up UPDATE through membership in cde_writer with no business-need entry. The attestation must pass the first and fail the second, showing the access path.

records = [
    {"object": "cde.transactions", "principal": "settlement_svc", "privilege": "SELECT",
     "sensitivity": "cardholder", "access_path": "direct", "evidence_hash": "a1"},
    {"object": "cde.transactions", "principal": "support_ro", "privilege": "UPDATE",
     "sensitivity": "cardholder", "access_path": "via_role:cde_writer", "evidence_hash": "b7"},
]
needs = {("settlement_svc", "cde.transactions"):
         BusinessNeed("settlement_svc", "cde.transactions", "nightly settlement read", "SELECT")}

rows = [attest_row(r, needs.get((r["principal"], r["object"])),
                   attester="qsa.liaison", last_reviewed="2026-07-01")
        for r in records if is_cde(r)]
print([r["least_privilege_ok"] for r in rows])   # -> [True, False]

The support_ro row surfaces exactly what a QSA hunts for: write access to cardholder data reached through a role, with no documented need. Its access_path of via_role:cde_writer tells the assessor where to revoke, and its False verdict makes the finding non-negotiable.

Gotchas and engine-specific notes

Role-mediated access is where CDE leaks hide. A principal rarely holds a dangerous CDE grant directly; it inherits it through a role. On PostgreSQL, walk pg_auth_members to populate access_path; on MySQL 8, read mysql.role_edges and remember the grant is inert until SET ROLE or a mysql.default_roles entry activates it. Reporting only direct grants understates CDE exposure and fails the assessment on the first spot-check.

PUBLIC grants are automatic Requirement 7 failures. A GRANT ... ON cde.transactions TO PUBLIC in PostgreSQL gives every role access with no business need by definition. Detect PUBLIC on any CDE object and emit a row with least_privilege_ok=False and business_need=UNDOCUMENTED — never filter it out as a non-principal.

Column-level grants change the verdict. PostgreSQL can grant UPDATE (status) on a single column, visible in information_schema.role_column_grants. A column-scoped UPDATE may satisfy a need that a table-wide UPDATE would violate. Resolve column grants before ranking, or the verdict is too harsh or too lax.

Tokenized columns are still in scope. A column holding a token or a truncated PAN is often still CDE per your QSA’s scoping. Tag sensitivity from the classification in privilege scope mapping, not from a guess about whether a column “looks like” a card number.

Compliance note

This report evidences PCI DSS Requirement 7.2 (establish an access-control system that restricts access based on need-to-know and least privilege) and 7.2.1 (define access needs per role). Each row proves, for one principal and one CDE object, that the granted privilege was measured against a documented business need and either passed or was flagged. The evidence_hash column binds every row to its canonical record so the attestation is verifiable and ready for evidence integrity and signing; scoring of the underlying deviations follows the rule-based drift scoring contract. See the PCI DSS v4.0 requirements for the full Requirement 7 text.

Frequently asked questions

Why CSV instead of JSON as the primary format? QSAs work in spreadsheets — they filter, sort, and sign rows interactively, which a flat CSV supports directly. The JSON twin exists for pipeline ingestion, but the CSV with its frozen column order is the artifact the assessor actually reviews.

What makes a grant fail the least-privilege check? A grant fails when its privilege outranks the minimum the documented business need requires, or when no business need is documented at all. An UPDATE where the need is read-only fails; an undocumented grant fails automatically with business_need marked UNDOCUMENTED.

How do I handle access granted through a role rather than directly? Resolve the effective privilege through role membership and record the chain in the access_path column as via_role:. This tells the QSA exactly where to revoke, and it prevents role-mediated CDE access from hiding behind a clean direct-grant list.

Should tokenized or truncated card columns appear in the report? Usually yes — most QSAs scope tokens and truncated PANs as cardholder data. Drive the cde_object filter from your object sensitivity classification rather than from column names, and confirm the token scope with your assessor.

Up: Audit Report Artifact Formats