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>
This commit is contained in:
2026-05-19 10:16:30 +00:00
parent 8cf9f4be98
commit dd339b11b1
5 changed files with 86 additions and 32 deletions

View File

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

View File

@@ -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<GetArticleOutput> => {
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.