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

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