Files
agentic-dev-template/docs/guides/audit-and-compliance.md
Danijel Martinek dd339b11b1 feat(core-shared): extend audit action enum with consent and restriction types
Adds CONSENT_GRANT, CONSENT_WITHDRAW, RESTRICT, UNRESTRICT to the
AuditAction closed enum per GDPR Art. 7 and Art. 18 requirements.

core-consent and core-dsr optional cores (Epic B Stories 03/06) emit
these action types via core-audit's IAuditLog channel; the values must
exist in core-shared's enum before either optional core can be built.
No change to IAuditLog's interface surface — new values flow through
AuditEntry.action automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:16:30 +00:00

17 KiB

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 ten action types covered by this template are: VIEW, CREATE, UPDATE, DELETE, EXPORT, PERMISSION_CHANGE, CONSENT_GRANT, CONSENT_WITHDRAW, RESTRICT, and UNRESTRICT. 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.

// 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.

// 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.

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:

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():

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:

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.

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:

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

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 reads the container's stdout stream, filters for audit entries (distinguished by _type: "audit"), and ships them to Grafana Loki in the EU region.

# 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

# 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

# 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:

# 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:

{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:

# 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 productionbindAudit() 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.