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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user