Files
agentic-dev/docs/guides/audit-and-compliance.md

453 lines
16 KiB
Markdown

# Audit logging & DPA compliance
> **Prerequisite:** This guide assumes `@repo/core-audit` is scaffolded. If your
> project started from the slim template, run `pnpm turbo gen core-package audit`
> first, then follow the manual wiring steps in §4 below.
## What DPA requires
A Data Processing Agreement (DPA) typically mandates that any system handling
personal data must keep a tamper-evident record of every access to that data.
The six action types covered by this template are: **VIEW**, **CREATE**,
**UPDATE**, **DELETE**, **EXPORT**, and **PERMISSION_CHANGE**. Each entry must
capture four required fields:
| DPA field | Mapped to |
|---|---|
| **Who** performed the action | `actorId`, `actorType`, `actorRoles` |
| **What** was acted on | `action`, `resource.type`, `resource.id` |
| **When** it happened | `at` (server timestamp, ISO 8601) |
| **From where** the request came | `from.ipTruncated`, `from.userAgent` |
**What NOT to log** — the DPA "exclusion list" is enforced by the `AuditEntry`
type itself: there are no `payload`, `body`, `oldValue`, or `newValue` fields.
UPDATE actions capture only `changedFields` (the names of modified fields, not
their values). This is intentional — storing "what changed" rather than "what
it changed to" prevents the audit log from becoming a secondary store of
regulated data.
**Retention requirements** vary by jurisdiction, but a common baseline is 90 days
in hot storage (queryable via Payload admin or API) and 12 months in cold archive
(shipped to a log aggregator like Grafana Loki or Elasticsearch). The stdout JSON
sink + log shipper pattern satisfies this: Payload holds the hot copy; the
aggregator holds the archive.
**Immutability** is enforced by the Payload collection's `update: () => false`
access rule. No user — including admins — can modify a written entry through the
Payload API. The only write path is `IAuditLog.record()`. Erasure on GDPR request
uses a privileged `overrideAccess: true` path that pseudonymizes or deletes the
`actorId` field rather than altering the event itself.
## The two-pattern model
Two complementary ways to capture a read event:
### Pattern 1 — Use-case-level `record()` calls
In your feature's READ use cases, the developer explicitly calls
`ctx.auditLog?.record({ action: "VIEW", ... })`. This gives you full control
over the WHY (the `reason` field) and works in every context — HTTP requests,
background jobs, CLI scripts, and tests.
```ts
// packages/blog/src/application/use-cases/get-article.use-case.ts
export function getArticleUseCase(
deps: { articlesRepo: IArticlesRepository; auditLog?: AuditLogProtocol },
) {
return async (input: GetArticleInput): Promise<GetArticleOutput> => {
const article = await deps.articlesRepo.findById(input.id);
await deps.auditLog?.record({
actorId: input.userId,
actorType: "user",
actorRoles: input.userRoles,
action: "VIEW",
resource: { type: "articles", id: input.id },
at: new Date(),
scope: { feature: "blog", environment: process.env.NODE_ENV ?? "development", tenant: input.tenant ?? "default" },
from: { ipTruncated: input.ipTruncated, userAgent: input.userAgent },
containsPii: false,
outcome: "success",
reason: "article-page-render",
});
return getArticleOutputSchema.parse(article);
};
}
```
### Pattern 2 — Payload `afterRead` hook (automatic, opt-in)
Install `createAuditAfterReadHook(...)` on a collection's `afterRead` hook list.
This captures **every** read of the collection — including admin UI reads, direct
programmatic reads, and REST API reads — automatically, without per-use-case
instrumentation.
```ts
// packages/blog/src/integrations/cms/articles.collection.ts
import { createAuditAfterReadHook } from "@repo/core-audit/hooks";
export const articlesCollection: CollectionConfig = {
slug: "articles",
hooks: {
afterRead: [
createAuditAfterReadHook({
auditLog: ctx.auditLog,
feature: "blog",
tenant: "default",
}),
],
},
// ...
};
```
The hook fires asynchronously (fire-and-forget) so it never blocks the read
response. It uses the sentinel IP/UA `"system"` / `"payload-admin"` for admin UI
reads where no request context is available.
## When to use which pattern
| Read source | Recommended pattern |
|---|---|
| tRPC procedure (app-facing read) | Use-case-level `record()` call — you have full request context |
| Payload admin UI | Hook automatically captures (no request context needed) |
| Background job / cron | Use-case-level `record()` call with `actorId: "system"`, sentinel IP/UA |
| Direct programmatic / CMS REST | Hook automatically captures |
| CLI / seed script | Use-case-level `record()` call with `actorId: "service-{name}"` |
Use **both** for collections under DPA scope. The hook covers reads you might
forget at the use-case layer; the use-case calls add contextual `reason` and
accurate IP/UA.
## Wiring core-audit into your app (7 steps)
After running `pnpm turbo gen core-package audit`, the package exists in
`packages/core-audit/` but is not yet wired into your app. Complete these seven
steps:
### Step 1 — Set `AUDIT_PSEUDONYM_SALT`
Generate a cryptographically random salt and store it in your deployment secrets
manager. The salt is used for sha256 pseudonymization on GDPR erasure requests.
```bash
export AUDIT_PSEUDONYM_SALT="$(openssl rand -hex 32)"
```
Add to your `.env` (development) and to your production secrets vault. The
`bindAudit()` function throws at startup if `NODE_ENV=production` and this
variable is not set — intentional fail-fast behavior.
### Step 2 — Mount the Payload collection
In `packages/core-cms/src/payload.config.ts`, import and register the
append-only `auditLogsCollection`:
```ts
import { auditLogsCollection } from "@repo/core-audit/collection";
export default buildConfig({
collections: [
// ... existing collections ...
auditLogsCollection,
],
});
```
The collection enforces `update: () => false` and `delete: () => false` at the
access-control layer. The only write path is via the `IAuditLog.record()` API.
### Step 3 — Mount the admin tRPC router
In `packages/core-api/src/root.ts`, import `createAuditRouter` and wire it to
the app router. The router requires an `IAuditLog` instance — pass the one
returned by `bindAudit()`:
```ts
import { createAuditRouter } from "@repo/core-audit/api";
// In your router factory (called after bindAudit):
export function createAppRouter(auditLog: IAuditLog) {
return t.router({
// ... existing routers ...
audit: createAuditRouter(auditLog),
});
}
```
This exposes `audit.eraseSubject` as an admin-only tRPC mutation. Protect it
with your admin auth middleware (the `auditProcedure` base already requires a
caller-supplied auth guard — see `src/integrations/api/procedures.ts`).
### Step 4 — Bind audit in `bind-production.ts`
In `apps/web-next/src/server/bind-production.ts`, call `bindAudit()` before
any feature binder that uses `ctx.auditLog`:
```ts
import { bindAudit } from "@repo/core-audit/di";
// Inside your resolveProductionContext():
const { auditLog } = bindAudit(sharedContainer, {
payloadConfig: resolvedConfig,
sinks: ["payload", "stdout"],
});
const ctx: BindProductionContext = {
tracer,
logger,
config: resolvedConfig,
bus,
queue,
realtime,
realtimeRegistry,
auditLog, // <- new
};
```
The returned `auditLog` is already wrapped in `TraceIdEnrichingAuditLog`, so
every entry receives `correlationId` from the active OTel span automatically.
### Step 5 — Install user-collection hooks (DPA recommended)
To automatically pseudonymize or delete a user's audit history when their account
is deleted, install the erasure hook on your users Payload collection. In
`packages/auth/src/di/bind-production.ts`:
```ts
if (ctx.auditLog) {
const { createAuditErasureHook, createAuditAfterReadHook } =
await import("@repo/core-audit/hooks");
// Automatically erase audit entries when a user is deleted:
usersCollection.hooks ??= {};
usersCollection.hooks.afterDelete ??= [];
usersCollection.hooks.afterDelete.push(
createAuditErasureHook({ auditLog: ctx.auditLog, mode: "pseudonymize" }),
);
// Optionally capture VIEW events for user profile reads:
usersCollection.hooks.afterRead ??= [];
usersCollection.hooks.afterRead.push(
createAuditAfterReadHook({ auditLog: ctx.auditLog, feature: "auth", tenant: "default" }),
);
}
```
This step is gated on `ctx.auditLog` being present, so it's safely skipped in
slim-template projects where `@repo/core-audit` has not been scaffolded.
### Step 6 — Set up a log shipper
The `StdoutJsonAuditLog` sink writes one JSON line per audit entry to process
stdout. A log shipper (Vector or Fluent Bit) reads this stdout stream and
forwards entries to your centralized aggregator (Grafana Loki, Elasticsearch,
Splunk, etc.). See §5 for sample configs.
### Step 7 — Verify
```bash
pnpm install
pnpm lint && pnpm typecheck && pnpm test
pnpm turbo boundaries
```
Run your app in development mode and trigger a VIEW event. You should see an
`_type: "audit"` JSON line in stdout within 100 ms of the action.
## Sample log-shipper configs
### Vector (recommended)
Vector reads the container's stdout stream, filters for audit entries
(distinguished by `_type: "audit"`), and ships them to Grafana Loki in the EU
region.
```toml
# vector.toml
[sources.app_stdout]
type = "stdin"
[transforms.parse_audit]
type = "remap"
inputs = ["app_stdout"]
source = '''
. = parse_json!(.message)
if ._type != "audit" { abort }
'''
[transforms.enrich_labels]
type = "remap"
inputs = ["parse_audit"]
source = '''
.labels = { "env": .scope.environment, "feature": .scope.feature, "tenant": .scope.tenant }
'''
[sinks.loki_eu]
type = "loki"
inputs = ["enrich_labels"]
endpoint = "https://logs-prod-eu-west-0.grafana.net"
auth.strategy = "basic"
auth.user = "${LOKI_USER}"
auth.password = "${LOKI_API_KEY}"
labels.job = "audit"
labels.env = "{{ .labels.env }}"
encoding.codec = "json"
```
Set `LOKI_USER` and `LOKI_API_KEY` in your deployment environment. The filter
`if ._type != "audit" { abort }` ensures only audit entries are forwarded;
other stdout lines (application logs, framework output) pass through unshipped.
### Fluent Bit
```ini
# fluent-bit.conf
[INPUT]
Name tail
Path /var/log/app/stdout.log
Parser json
Tag app.stdout
[FILTER]
Name grep
Match app.stdout
Regex _type audit
[OUTPUT]
Name loki
Match app.stdout
Host logs-prod-eu-west-0.grafana.net
Port 443
TLS On
Labels job=audit,env=${AUDIT_ENV}
HTTP_User ${LOKI_USER}
HTTP_Passwd ${LOKI_API_KEY}
line_format json
```
For containerized deployments (Docker / Kubernetes), configure the log driver to
write stdout to a file or use Fluent Bit's `docker` input plugin instead of `tail`.
## GDPR erasure
GDPR Article 17 ("right to erasure") requires that a data subject can request
deletion of their personal data. `@repo/core-audit` satisfies this via
`IAuditLog.eraseSubject(actorId, mode)`.
### Trigger via admin tRPC
```bash
# Replace <TOKEN> with a valid admin session token and <ACTOR_ID> with the user ID
curl -X POST https://your-app.com/api/trpc/audit.eraseSubject \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"json":{"actorId":"user_123","mode":"pseudonymize"}}'
```
The `pseudonymize` mode replaces `actorId` in every matching Payload audit entry
with `erased-{sha256(salt+actorId)[0:16]}`. The entry itself is preserved (the
event happened; only the identity is pseudonymized). The `delete` mode hard-deletes
every entry for that actor — use only when the DPA or a court order requires it.
### Trigger via the `afterDelete` hook
If you installed the erasure hook in Step 5, deleting a user via Payload admin
automatically triggers pseudonymization. No manual API call is needed for the
standard deletion flow.
### What `eraseSubject` does NOT cover
`StdoutJsonAuditLog.eraseSubject()` writes a tombstone entry to stdout but cannot
retroactively alter past stdout lines that have already been shipped to your
aggregator. Handle this by issuing a deletion request to Loki/Elasticsearch for
that `actorId` label after the Payload pseudonymization completes:
```bash
# Grafana Loki: delete by label selector (requires delete permission)
curl -X POST "https://logs-prod-eu-west-0.grafana.net/loki/api/v1/delete?query={job=\"audit\"}&start=0&end=$(date +%s)000000000" \
-H "X-Scope-OrgID: ${LOKI_TENANT}" \
--data-urlencode 'query={actorId="user_123"}'
```
Consult your aggregator's deletion API for the exact syntax.
## Sample-week audit verification
"Can you tell who accessed any given article last Tuesday?"
1. Open Payload admin → Collections → Audit Logs
2. Filter: `resource.type = "articles"` and `at` between Monday 00:00 and Sunday 23:59
3. Each entry shows `actorId`, `actorType`, `actorRoles`, `action`, `at`, and `from.ipTruncated`
4. Cross-reference `actorId` with the Users collection to get the display name (keep this lookup out of the audit log itself — names are PII)
For bulk queries, use your aggregator's log search. In Grafana Loki:
```logql
{job="audit"} | json | resource_type="articles" | action="VIEW"
| line_format "{{.at}} {{.actorId}} viewed {{.resource_id}}"
```
The `correlationId` field (populated by `TraceIdEnrichingAuditLog`) links each
audit entry to its OTel trace, enabling pivot from the compliance timeline to the
full distributed trace in Grafana Tempo or Jaeger.
## Hostile-actor immutability test
The append-only guarantee is only as good as its enforcement. Verify it holds:
```bash
# 1. Record a test entry
curl -X POST http://localhost:3001/api/audit-logs \
-H "Content-Type: application/json" \
-d '{"actorId":"attacker","action":"VIEW","resource":{"type":"test"}}'
# Expected: 403 Forbidden — the collection's create access is API-only via IAuditLog
# 2. Try to update an existing entry via Payload REST
ENTRY_ID=$(curl -s "http://localhost:3001/api/audit-logs?limit=1" | jq -r '.docs[0].id')
curl -X PATCH "http://localhost:3001/api/audit-logs/${ENTRY_ID}" \
-H "Content-Type: application/json" \
-d '{"actorId":"tampered"}'
# Expected: 403 Forbidden — update: () => false
# 3. Try to delete via Payload REST
curl -X DELETE "http://localhost:3001/api/audit-logs/${ENTRY_ID}"
# Expected: 403 Forbidden — delete: () => false
```
Also verify that the stdout shipper is configured with an independent retention
policy that does NOT depend on Payload. If a hostile actor gains DB access and
truncates the `audit_logs` table, the shipped log lines in Loki/Elasticsearch
remain as the authoritative record.
## Common mistakes
**Forgetting `scope.tenant`** — the field is required, not optional. Single-tenant
projects must explicitly pass `tenant: "default"`. TypeScript will catch this at
compile time if you omit it.
**Setting `containsPii: false` on a collection that has PII** — for example, a
"users" profile view logs `resource.type: "users"` but `containsPii: false`.
Even though the audit entry itself doesn't store PII values, the *resource* being
accessed is PII-bearing. Set `containsPii: true` and list relevant categories in
`piiCategories: ["profile", "email"]` so downstream retention systems apply the
correct access policy to the audit entries themselves.
**Trying to add `oldValue`/`newValue` fields** — these fields do not exist on
`AuditEntry` by design. The type system prevents this. If you need to capture the
before/after state of a field for a specific compliance requirement, build a
separate audit-detail mechanism outside this package — do not extend `AuditEntry`.
**Forgetting `AUDIT_PSEUDONYM_SALT` in production**`bindAudit()` throws at
startup with a clear message. Do not try to catch this error; the intent is to
refuse to start rather than silently use a weak or predictable salt.
**Using `eraseSubject("delete")` as the default** — prefer `"pseudonymize"` for
the standard GDPR erasure path. Hard delete removes all evidence that the events
occurred, which can itself create compliance problems. Pseudonymization preserves
the event record while making the actor unidentifiable.
**Not verifying the log shipper in staging** — deploy to staging with the same
Vector/Fluent Bit configuration you'll use in production. Verify that audit
entries appear in your aggregator before go-live. The `_type: "audit"` filter is
your first line of defense against shipping non-audit data to the compliance log.