FedRAMP Moderate audit evidence architecture for enterprise LLM deployment — Reference Architecture
The control that resolves this is an Attestable Inference Plane, evaluated before the request reaches a model provider rather than audited afterwards. In regulated enterprise the binding constraint is FedRAMP Moderate, and the question it actually asks is which NIST 800-53 control does each component satisfy. The shape below holds p95 ≤ 600ms over REST over HTTPS, and ships with a standard-library implementation that runs as written.
Implementation parameter matrix
Every value below is a parameter you can hold a vendor or an internal team to. Adjectives are not acceptance criteria.
| Deployment vector | Customer-managed VPC, provider model endpoint pinned to region |
|---|---|
| Security standard | FedRAMP Moderate |
| Applicable regimes | SOC 2 Type II, ISO/IEC 27001:2022, GDPR |
| Latency SLA | p95 ≤ 600ms — interactive surfaces need sub-second; batch jobs do not |
| Integration protocol | REST over HTTPS — the widest compatibility and the easiest audit story |
| Idempotency control | an Idempotency-Key header, honoured for 24h |
| Throughput envelope | ~2k rps per gateway node before connection churn dominates |
| Middleware requirement | policy decision point in front of the model gateway |
| Data classes in scope | PII, commercial-in-confidence data |
| Retention obligation | per the data class retention schedule, enforced automatically |
| Data residency | contracted processing region, asserted on every response |
| Contractual instrument | DPA plus a disclosed subprocessor list |
| Systems of record | SAP, Salesforce, Workday |
| Primary failure mode | a retried POST creating a duplicate record |
Risk register with acceptance criteria
Each row carries an acceptance parameter rather than an adjective, because "low latency" cannot be signed off. Retention here is bound by per the data class retention schedule, enforced automatically.
| Category | Risk | Control | Acceptance parameter |
|---|---|---|---|
| FedRAMP Moderate | A component sits outside the authorisation boundary undocumented | Maintain the boundary as code; every egress is an explicit, reviewed exception mapped to a control | 0 undocumented egress paths; boundary diagram regenerated from configuration, not drawn by hand |
| Data privacy | Prompt and completion bodies persisted in the audit store | Redact before sealing; store sha256 digests of sensitive fields, never values | 0 raw PII fields in the audit store; digest-only verified by schema test in CI |
| Data privacy | Subprocessor exposure not disclosable to the customer | Pin provider + region per tenant; publish the subprocessor list as a versioned artifact | Subprocessor list version referenced in every DPA; drift alert on change |
| Latency SLA | Sealing and redaction added to the synchronous request path | Redact inline (cheap, deterministic); seal and persist asynchronously via an outbox | Added p95 under 12ms; outbox lag alert over 30s |
| Latency SLA | Verifier scan over a long chain blocks the audit window | Checkpoint every 10k records; verify the delta against the last signed checkpoint | Full-chain verify under 60s per tenant per million records |
| Cost control | Audit volume grows faster than inference spend | Tier the store: hot 30 days, cold object storage to the retention horizon | Audit storage held under 4% of inference spend |
| Cost control | Retention over-collection creates both cost and liability | Per-class retention clock with documented, automated deletion | 0 records past their class retention horizon; deletion log reconciles monthly |
| Operational | Key rotation breaks historical seal verification | Record key id per record; verifier resolves the key id against the KMS key history | Rotation rehearsed quarterly; historical verification passes post-rotation |
Where the rollout actually stalls
The model works and the pilot passed. The rollout is stopped by a control gap: the team can show that inference happened, but cannot produce per-tenant, tamper-evident evidence of WHAT was processed, by WHOM, under WHICH retention rule — which is the evidence an auditor asks for and a shared application log cannot give.
What FedRAMP Moderate specifically requires
FedRAMP is control-by-control. Every component must map to a specific NIST 800-53 Rev 5 control identifier with an implementation statement, and anything crossing the authorisation boundary needs its own justification in the SSP.
The artifact that satisfies it is a control implementation summary mapped to control identifiers. The way teams most often fail it: a component outside the authorisation boundary that nobody documented, found during assessment.
Modular implementation framework
- Policy decision point — Evaluates tenant, data class and purpose before the call. Fails closed.
- Redaction stage — Strips sensitive values and substitutes digests, BEFORE anything is sealed.
- Per-tenant hash chain — Each record commits to the previous digest, keyed per tenant.
- HMAC seal — A KMS-held key makes records unforgeable by anyone with store write access.
- Retention scheduler — Enforces the per-class retention clock and records the deletion.
- Verifier endpoint — The function you run in front of the auditor to prove the chain is intact.
System architecture
Attestable Inference Plane — the request path, with the decision points that must exist before the first model call:
flowchart LR
C[Client / Agent] --> G[Gateway]
G --> PDP{Policy Decision Point<br/>tenant - class - purpose}
PDP -->|deny| D[403 + denial record]
PDP -->|allow| R[Redaction stage<br/>values to digests]
R --> M[Model provider]
M --> S[Seal: sha256 chain + HMAC]
S --> L[(Per-tenant audit store)]
L --> V[Verifier endpoint]
V --> A[Auditor evidence pack]
L --> RT[Retention scheduler]
RT -->|expiry| X[Documented deletion]Reference implementation — Per-tenant tamper-evident audit chain
Standard-library Python, no external dependencies, runs as written. Executed by the publish gate: if it raises, this page does not ship. Wire it behind REST over HTTPS; the failure mode it guards against is a retried POST creating a duplicate record. The output you hand the assessor is the SSP control implementation summary.
"""Per-tenant, tamper-evident audit log for an LLM gateway.
Why this shape: a SOC 2 auditor does not ask "do you log?" — they ask whether a
log entry can be altered after the fact without detection, and whether tenant A
can ever read tenant B's entries. A plain append-only table answers neither.
This answers both with a per-tenant hash chain: each record commits to the
previous record's digest, so any edit or deletion breaks the chain at a
detectable point, and the chain is keyed per tenant so isolation is structural
rather than a WHERE clause someone can forget.
"""
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass, field
GENESIS = "0" * 64
def _canonical(obj) -> bytes:
# Digests must be stable across processes and Python versions, so the JSON
# has to be canonical: sorted keys, no insignificant whitespace.
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
@dataclass
class AuditChain:
"""Append-only, per-tenant hash chain with an HMAC seal.
secret: a per-deployment key from your KMS. It makes the seal
unforgeable by anyone who only has write access to the store.
"""
secret: bytes
heads: dict = field(default_factory=dict) # tenant_id -> last digest
def append(self, tenant_id: str, event: dict) -> dict:
if not tenant_id:
raise ValueError("tenant_id is required: an unattributed audit "
"record cannot satisfy per-tenant evidence")
prev = self.heads.get(tenant_id, GENESIS)
record = {
"tenant_id": tenant_id,
"ts": event.get("ts") or time.time(),
"actor": event.get("actor"),
"action": event["action"],
"resource": event.get("resource"),
"prev": prev,
# Redaction happens BEFORE sealing. Sealing a payload you later
# need to redact forces you to break your own chain.
"payload": redact(event.get("payload") or {}),
}
digest = hashlib.sha256(_canonical(record)).hexdigest()
seal = hmac.new(self.secret, digest.encode(), hashlib.sha256).hexdigest()
record["digest"] = digest
record["seal"] = seal
self.heads[tenant_id] = digest
return record
def verify(self, tenant_id: str, records: list) -> tuple:
"""-> (ok, first_broken_index). This is the function you demo to the
auditor; without it the chain is a claim rather than evidence."""
prev = GENESIS
for i, r in enumerate(records):
if r.get("tenant_id") != tenant_id:
return False, i # cross-tenant record in the stream
body = {k: r[k] for k in
("tenant_id", "ts", "actor", "action", "resource",
"prev", "payload")}
if r.get("prev") != prev:
return False, i
if hashlib.sha256(_canonical(body)).hexdigest() != r.get("digest"):
return False, i
expect = hmac.new(self.secret, r["digest"].encode(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect, r.get("seal", "")):
return False, i
prev = r["digest"]
return True, -1
SENSITIVE_KEYS = {"prompt", "completion", "email", "ssn", "dob", "mrn",
"card", "iban", "phone", "address", "name"}
def redact(payload: dict, keep_hash: bool = True) -> dict:
"""Drop sensitive values, keep a digest so you can still prove WHICH
payload was processed without storing the payload itself.
This is the control that lets you keep audit evidence under GDPR data
minimisation and an HIPAA BAA at the same time."""
out = {}
for k, v in payload.items():
if k.lower() in SENSITIVE_KEYS:
out[k] = ("sha256:" + hashlib.sha256(
_canonical(v)).hexdigest()[:16]) if keep_hash else "[redacted]"
elif isinstance(v, dict):
out[k] = redact(v, keep_hash)
else:
out[k] = v
return out
if __name__ == "__main__":
chain = AuditChain(secret=os.environb.get(b"AUDIT_HMAC_KEY", b"dev-only-key"))
stream = [
chain.append("tenant-a", {"actor": "svc:rag", "action": "inference",
"resource": "gpt-4o",
"payload": {"prompt": "patient MRN 9912",
"tokens": 318}}),
chain.append("tenant-a", {"actor": "u:42", "action": "export",
"resource": "conversation/77"}),
]
print("redacted payload:", stream[0]["payload"])
print("chain verifies:", chain.verify("tenant-a", stream))
stream[0]["payload"]["tokens"] = 1 # tamper
print("after tamper: ", chain.verify("tenant-a", stream))
The FedRAMP Moderate control, as code
NIST 800-53 control map with a boundary-crossing check. This is the artifact an assessor asks for under FedRAMP Moderate specifically — it is not the same control as the one above, and a system that implements one does not thereby implement the other. It executes under the same publish gate.
"""NIST 800-53 control satisfaction map with an authorisation-boundary check.
FedRAMP reviewers do not ask whether the system is secure. They ask which
control, by identifier, each component satisfies, and what crosses the
authorisation boundary. An external model endpoint is a boundary crossing, and
an undeclared crossing is the finding that stalls an ATO.
"""
MODERATE_BASELINE = {
"AC-4": "Information flow enforcement",
"AU-2": "Event logging",
"AU-9": "Protection of audit information",
"CA-3": "Information exchange / external connections",
"SC-7": "Boundary protection",
"SC-8": "Transmission confidentiality and integrity",
"SC-28": "Protection of information at rest",
"SI-4": "System monitoring",
}
class AuthorisationBoundary:
def __init__(self, name: str):
self.name = name
self.components: dict[str, dict] = {}
self.crossings: list[dict] = []
def declare(self, component: str, inside: bool, controls: list[str]) -> None:
unknown = [c for c in controls if c not in MODERATE_BASELINE]
if unknown:
raise KeyError(f"not in the Moderate baseline: {unknown}")
self.components[component] = {"inside": inside, "controls": set(controls)}
def connect(self, src: str, dst: str, declared: bool) -> None:
for end in (src, dst):
if end not in self.components:
raise KeyError(f"undeclared component: {end}")
crossing = self.components[src]["inside"] != self.components[dst]["inside"]
self.crossings.append({"src": src, "dst": dst,
"boundary_crossing": crossing, "declared": declared})
def undeclared_crossings(self) -> list[dict]:
return [c for c in self.crossings if c["boundary_crossing"] and not c["declared"]]
def coverage(self) -> dict:
covered = set()
for meta in self.components.values():
covered |= meta["controls"]
gaps = sorted(set(MODERATE_BASELINE) - covered)
return {"covered": sorted(covered), "gaps": gaps,
"ato_ready": not gaps and not self.undeclared_crossings()}
if __name__ == "__main__":
b = AuthorisationBoundary("agency-llm-enclave")
b.declare("gateway", True, ["AC-4", "SC-7", "SC-8"])
b.declare("audit_store", True, ["AU-2", "AU-9", "SC-28"])
b.declare("monitor", True, ["SI-4"])
b.declare("external_model_api", False, ["CA-3"])
b.connect("gateway", "audit_store", declared=True)
b.connect("gateway", "external_model_api", declared=True)
cov = b.coverage()
assert cov["ato_ready"], cov
assert cov["gaps"] == [], cov
# The finding this exists to catch: a crossing nobody declared.
b2 = AuthorisationBoundary("agency-llm-enclave")
b2.declare("gateway", True, list(MODERATE_BASELINE))
b2.declare("telemetry_sidecar", False, ["CA-3"])
b2.connect("gateway", "telemetry_sidecar", declared=False)
assert not b2.coverage()["ato_ready"], b2.coverage()
assert len(b2.undeclared_crossings()) == 1, b2.undeclared_crossings()
try:
b2.declare("rogue", True, ["XX-1"])
raise AssertionError("accepted a control outside the baseline")
except KeyError:
pass
print("FedRAMP Moderate boundary + control map: OK")
print(f" controls covered {len(cov['covered'])}/{len(MODERATE_BASELINE)}, "
f"undeclared crossings {len(b2.undeclared_crossings())}")
What the auditor will ask
- Which NIST 800-53 control does each component satisfy, and which components sit outside the authorisation boundary?
Each component carries its control identifiers in configuration, and the boundary diagram is generated from that configuration so it cannot drift from reality. - Can you produce, for one named tenant, every inference record in a date range with proof none was altered?
Yes — the verifier endpoint replays that tenant's hash chain for the date range and reports the first index at which the chain breaks. Each record commits to the previous record's digest, so an edit or deletion is detectable rather than merely unlikely. - Where exactly in the request path are sensitive values removed, and what is stored in their place?
In the redaction stage, before the record is sealed. Sensitive values are replaced with a sha256 digest of the value, so you can still prove which payload was processed without retaining the payload. - Which key seals the records, who can use it, and what happens to verification after rotation?
An HMAC key held in your KMS, usable only by the sealing service. Each record stores the key id it was sealed with, so the verifier resolves historical records against the KMS key history and rotation does not invalidate past evidence. - What is the retention clock per data class, and what evidence proves deletion occurred?
A retention clock per data class, enforced by the retention scheduler, which writes a deletion record when a record passes its horizon. The deletion log is what you show the auditor; reconcile it monthly against records in the store. - Can tenant A's records ever appear in tenant B's evidence pack, and what makes that structurally impossible?
No. The chain is keyed per tenant and the verifier rejects any record whose tenant_id does not match the stream being verified, so isolation is structural rather than a WHERE clause that can be forgotten.
Adjacent problems
- Part of enterprise ai compliance architecture.
- fedRAMP Moderate prompt injection defence architecture for enterprise LLM covers the adjacent case.
- The same control appears in fedRAMP Moderate agent permission and delegated access architecture.
- The same control appears in fedRAMP Moderate model release gate and golden set evaluation.
- fedRAMP Moderate vendor evaluation criteria for enterprise AI covers the adjacent case.
- If this is the blocker in front of you: enterprise AI infrastructure review.
Enterprise infrastructure review
If you are carrying this blocker, the useful next step is not a demo. It is a review of your actual request path against the control points above:
- Which of the 6 components exist in your estate today, and which are assumed
- Your current numbers against the 7 acceptance parameters, starting with p95 ≤ 600ms
- Which FedRAMP Moderate control questions you cannot yet answer, and what closes each gap