diff --git a/coverage/summary.json b/coverage/summary.json index bab4d10..ab937f4 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-19T10:10:24.367Z", - "commit": "5abf7fe", + "generatedAt": "2026-05-19T10:16:15.209Z", + "commit": "8cf9f4b", "repo": { "statements": 96.48, "branches": 91.72, diff --git a/docs/decisions/adr-018-audit-and-compliance.md b/docs/decisions/adr-018-audit-and-compliance.md index d5b23b2..ee425af 100644 --- a/docs/decisions/adr-018-audit-and-compliance.md +++ b/docs/decisions/adr-018-audit-and-compliance.md @@ -18,8 +18,9 @@ audit data is lossless and long-retention with privileged erasure. 1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface. Features call `ctx.auditLog?.record(entry)` without importing the optional package. 2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/ - EXPORT/PERMISSION_CHANGE; new actions require explicit type bump. No - payload/body/oldValue/newValue fields — type enforces "what NOT to log". + EXPORT/PERMISSION_CHANGE/CONSENT_GRANT/CONSENT_WITHDRAW/RESTRICT/UNRESTRICT; + new actions require explicit type bump. No payload/body/oldValue/newValue + fields — type enforces "what NOT to log". 3. **`@repo/core-audit` as 5th optional package** — joins realtime, events, trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`. 4. **Four impls + Recording test double**: NoopAuditLog, PayloadAuditLog @@ -80,3 +81,22 @@ audit data is lossless and long-retention with privileged erasure. signal flowing through OTel. The correlationId field is the bridge. - ADR-015 (events/jobs): no overlap; audit is observational, events are reactive. - ADR-017 (OTel migration): provides currentTraceId() helper. + +## Amendments + +### 2026-05-19 — Consent and restriction action types (Epic B, ADR-025) + +Added four new `AuditAction` values to `core-shared/audit/audit-entry.ts`: + +| Action | Article | Description | +| ------------------ | ------------ | ------------------------------------------------- | +| `CONSENT_GRANT` | GDPR Art. 7 | Subject granted consent for a processing purpose | +| `CONSENT_WITHDRAW` | GDPR Art. 7 | Subject withdrew consent for a processing purpose | +| `RESTRICT` | GDPR Art. 18 | Subject requested restriction of processing | +| `UNRESTRICT` | GDPR Art. 18 | Restriction lifted (controller or subject action) | + +**Reason:** `core-consent` and `core-dsr` optional packages (Story 03 and 06 +of Epic B) emit these action types via `core-audit`'s existing `IAuditLog` +channel. The values must exist in `core-shared`'s closed enum before either +optional core can be implemented. No change to `IAuditLog`'s interface surface — +the new values flow through `AuditEntry.action` automatically. diff --git a/docs/guides/audit-and-compliance.md b/docs/guides/audit-and-compliance.md index b4f5e3b..5891e04 100644 --- a/docs/guides/audit-and-compliance.md +++ b/docs/guides/audit-and-compliance.md @@ -8,16 +8,17 @@ 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 +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` | +| 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. @@ -51,9 +52,10 @@ 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 }, -) { +export function getArticleUseCase(deps: { + articlesRepo: IArticlesRepository; + auditLog?: AuditLogProtocol; +}) { return async (input: GetArticleInput): Promise => { const article = await deps.articlesRepo.findById(input.id); await deps.auditLog?.record({ @@ -63,7 +65,11 @@ export function getArticleUseCase( action: "VIEW", resource: { type: "articles", id: input.id }, at: new Date(), - scope: { feature: "blog", environment: process.env.NODE_ENV ?? "development", tenant: input.tenant ?? "default" }, + scope: { + feature: "blog", + environment: process.env.NODE_ENV ?? "development", + tenant: input.tenant ?? "default", + }, from: { ipTruncated: input.ipTruncated, userAgent: input.userAgent }, containsPii: false, outcome: "success", @@ -106,13 +112,13 @@ 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}"` | +| 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 @@ -200,7 +206,7 @@ const ctx: BindProductionContext = { queue, realtime, realtimeRegistry, - auditLog, // <- new + auditLog, // <- new }; ``` @@ -228,7 +234,11 @@ if (ctx.auditLog) { // Optionally capture VIEW events for user profile reads: usersCollection.hooks.afterRead ??= []; usersCollection.hooks.afterRead.push( - createAuditAfterReadHook({ auditLog: ctx.auditLog, feature: "auth", tenant: "default" }), + createAuditAfterReadHook({ + auditLog: ctx.auditLog, + feature: "auth", + tenant: "default", + }), ); } ``` @@ -427,7 +437,7 @@ 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 +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. diff --git a/packages/core-shared/src/audit/audit-entry.test.ts b/packages/core-shared/src/audit/audit-entry.test.ts index 50cfe25..ecf7cd3 100644 --- a/packages/core-shared/src/audit/audit-entry.test.ts +++ b/packages/core-shared/src/audit/audit-entry.test.ts @@ -2,16 +2,28 @@ import { describe, it, expectTypeOf } from "vitest"; import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry"; describe("AuditAction", () => { - it("is a closed enum of 6 values", () => { + it("is a closed enum of 10 values", () => { expectTypeOf().toEqualTypeOf< - "VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE" + | "VIEW" + | "CREATE" + | "UPDATE" + | "DELETE" + | "EXPORT" + | "PERMISSION_CHANGE" + | "CONSENT_GRANT" + | "CONSENT_WITHDRAW" + | "RESTRICT" + | "UNRESTRICT" >(); }); }); describe("AuditFrom", () => { it("requires ipTruncated and userAgent", () => { - expectTypeOf().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>(); + expectTypeOf().toEqualTypeOf<{ + ipTruncated: string; + userAgent: string; + }>(); }); }); @@ -34,7 +46,15 @@ describe("AuditEntry", () => { it("makes optional fields actually optional", () => { type Entry = AuditEntry; - type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode"; - expectTypeOf>().toEqualTypeOf>>(); + type OptionalKeys = + | "changedFields" + | "reason" + | "correlationId" + | "requestId" + | "piiCategories" + | "errorCode"; + expectTypeOf>().toEqualTypeOf< + Partial> + >(); }); }); diff --git a/packages/core-shared/src/audit/audit-entry.ts b/packages/core-shared/src/audit/audit-entry.ts index 581d498..35c8ce3 100644 --- a/packages/core-shared/src/audit/audit-entry.ts +++ b/packages/core-shared/src/audit/audit-entry.ts @@ -8,7 +8,11 @@ export type AuditAction = | "UPDATE" | "DELETE" | "EXPORT" - | "PERMISSION_CHANGE"; + | "PERMISSION_CHANGE" + | "CONSENT_GRANT" + | "CONSENT_WITHDRAW" + | "RESTRICT" + | "UNRESTRICT"; /** * `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6)