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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-19T10:10:24.367Z",
|
"generatedAt": "2026-05-19T10:16:15.209Z",
|
||||||
"commit": "5abf7fe",
|
"commit": "8cf9f4b",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 96.48,
|
"statements": 96.48,
|
||||||
"branches": 91.72,
|
"branches": 91.72,
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ audit data is lossless and long-retention with privileged erasure.
|
|||||||
1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface.
|
1. **`AuditLogProtocol` in `core-shared`** — must-have universal surface.
|
||||||
Features call `ctx.auditLog?.record(entry)` without importing the optional package.
|
Features call `ctx.auditLog?.record(entry)` without importing the optional package.
|
||||||
2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/
|
2. **`AuditEntry` type with closed action enum** — VIEW/CREATE/UPDATE/DELETE/
|
||||||
EXPORT/PERMISSION_CHANGE; new actions require explicit type bump. No
|
EXPORT/PERMISSION_CHANGE/CONSENT_GRANT/CONSENT_WITHDRAW/RESTRICT/UNRESTRICT;
|
||||||
payload/body/oldValue/newValue fields — type enforces "what NOT to log".
|
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,
|
3. **`@repo/core-audit` as 5th optional package** — joins realtime, events,
|
||||||
trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`.
|
trpc, ui. Scaffolded via `pnpm turbo gen core-package audit`.
|
||||||
4. **Four impls + Recording test double**: NoopAuditLog, PayloadAuditLog
|
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.
|
signal flowing through OTel. The correlationId field is the bridge.
|
||||||
- ADR-015 (events/jobs): no overlap; audit is observational, events are reactive.
|
- ADR-015 (events/jobs): no overlap; audit is observational, events are reactive.
|
||||||
- ADR-017 (OTel migration): provides currentTraceId() helper.
|
- 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,12 +8,13 @@
|
|||||||
|
|
||||||
A Data Processing Agreement (DPA) typically mandates that any system handling
|
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.
|
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**,
|
The ten action types covered by this template are: **VIEW**, **CREATE**,
|
||||||
**UPDATE**, **DELETE**, **EXPORT**, and **PERMISSION_CHANGE**. Each entry must
|
**UPDATE**, **DELETE**, **EXPORT**, **PERMISSION_CHANGE**, **CONSENT_GRANT**,
|
||||||
|
**CONSENT_WITHDRAW**, **RESTRICT**, and **UNRESTRICT**. Each entry must
|
||||||
capture four required fields:
|
capture four required fields:
|
||||||
|
|
||||||
| DPA field | Mapped to |
|
| DPA field | Mapped to |
|
||||||
|---|---|
|
| ------------------------------- | ---------------------------------------- |
|
||||||
| **Who** performed the action | `actorId`, `actorType`, `actorRoles` |
|
| **Who** performed the action | `actorId`, `actorType`, `actorRoles` |
|
||||||
| **What** was acted on | `action`, `resource.type`, `resource.id` |
|
| **What** was acted on | `action`, `resource.type`, `resource.id` |
|
||||||
| **When** it happened | `at` (server timestamp, ISO 8601) |
|
| **When** it happened | `at` (server timestamp, ISO 8601) |
|
||||||
@@ -51,9 +52,10 @@ background jobs, CLI scripts, and tests.
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// packages/blog/src/application/use-cases/get-article.use-case.ts
|
// packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||||
export function getArticleUseCase(
|
export function getArticleUseCase(deps: {
|
||||||
deps: { articlesRepo: IArticlesRepository; auditLog?: AuditLogProtocol },
|
articlesRepo: IArticlesRepository;
|
||||||
) {
|
auditLog?: AuditLogProtocol;
|
||||||
|
}) {
|
||||||
return async (input: GetArticleInput): Promise<GetArticleOutput> => {
|
return async (input: GetArticleInput): Promise<GetArticleOutput> => {
|
||||||
const article = await deps.articlesRepo.findById(input.id);
|
const article = await deps.articlesRepo.findById(input.id);
|
||||||
await deps.auditLog?.record({
|
await deps.auditLog?.record({
|
||||||
@@ -63,7 +65,11 @@ export function getArticleUseCase(
|
|||||||
action: "VIEW",
|
action: "VIEW",
|
||||||
resource: { type: "articles", id: input.id },
|
resource: { type: "articles", id: input.id },
|
||||||
at: new Date(),
|
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 },
|
from: { ipTruncated: input.ipTruncated, userAgent: input.userAgent },
|
||||||
containsPii: false,
|
containsPii: false,
|
||||||
outcome: "success",
|
outcome: "success",
|
||||||
@@ -107,7 +113,7 @@ reads where no request context is available.
|
|||||||
## When to use which pattern
|
## When to use which pattern
|
||||||
|
|
||||||
| Read source | Recommended pattern |
|
| Read source | Recommended pattern |
|
||||||
|---|---|
|
| -------------------------------- | ----------------------------------------------------------------------- |
|
||||||
| tRPC procedure (app-facing read) | Use-case-level `record()` call — you have full request context |
|
| 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) |
|
| Payload admin UI | Hook automatically captures (no request context needed) |
|
||||||
| Background job / cron | Use-case-level `record()` call with `actorId: "system"`, sentinel IP/UA |
|
| Background job / cron | Use-case-level `record()` call with `actorId: "system"`, sentinel IP/UA |
|
||||||
@@ -228,7 +234,11 @@ if (ctx.auditLog) {
|
|||||||
// Optionally capture VIEW events for user profile reads:
|
// Optionally capture VIEW events for user profile reads:
|
||||||
usersCollection.hooks.afterRead ??= [];
|
usersCollection.hooks.afterRead ??= [];
|
||||||
usersCollection.hooks.afterRead.push(
|
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
|
**Setting `containsPii: false` on a collection that has PII** — for example, a
|
||||||
"users" profile view logs `resource.type: "users"` but `containsPii: false`.
|
"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
|
accessed is PII-bearing. Set `containsPii: true` and list relevant categories in
|
||||||
`piiCategories: ["profile", "email"]` so downstream retention systems apply the
|
`piiCategories: ["profile", "email"]` so downstream retention systems apply the
|
||||||
correct access policy to the audit entries themselves.
|
correct access policy to the audit entries themselves.
|
||||||
|
|||||||
@@ -2,16 +2,28 @@ import { describe, it, expectTypeOf } from "vitest";
|
|||||||
import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
|
import type { AuditEntry, AuditAction, AuditFrom } from "./audit-entry";
|
||||||
|
|
||||||
describe("AuditAction", () => {
|
describe("AuditAction", () => {
|
||||||
it("is a closed enum of 6 values", () => {
|
it("is a closed enum of 10 values", () => {
|
||||||
expectTypeOf<AuditAction>().toEqualTypeOf<
|
expectTypeOf<AuditAction>().toEqualTypeOf<
|
||||||
"VIEW" | "CREATE" | "UPDATE" | "DELETE" | "EXPORT" | "PERMISSION_CHANGE"
|
| "VIEW"
|
||||||
|
| "CREATE"
|
||||||
|
| "UPDATE"
|
||||||
|
| "DELETE"
|
||||||
|
| "EXPORT"
|
||||||
|
| "PERMISSION_CHANGE"
|
||||||
|
| "CONSENT_GRANT"
|
||||||
|
| "CONSENT_WITHDRAW"
|
||||||
|
| "RESTRICT"
|
||||||
|
| "UNRESTRICT"
|
||||||
>();
|
>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AuditFrom", () => {
|
describe("AuditFrom", () => {
|
||||||
it("requires ipTruncated and userAgent", () => {
|
it("requires ipTruncated and userAgent", () => {
|
||||||
expectTypeOf<AuditFrom>().toEqualTypeOf<{ ipTruncated: string; userAgent: string }>();
|
expectTypeOf<AuditFrom>().toEqualTypeOf<{
|
||||||
|
ipTruncated: string;
|
||||||
|
userAgent: string;
|
||||||
|
}>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +46,15 @@ describe("AuditEntry", () => {
|
|||||||
|
|
||||||
it("makes optional fields actually optional", () => {
|
it("makes optional fields actually optional", () => {
|
||||||
type Entry = AuditEntry;
|
type Entry = AuditEntry;
|
||||||
type OptionalKeys = "changedFields" | "reason" | "correlationId" | "requestId" | "piiCategories" | "errorCode";
|
type OptionalKeys =
|
||||||
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<Partial<Pick<Entry, OptionalKeys>>>();
|
| "changedFields"
|
||||||
|
| "reason"
|
||||||
|
| "correlationId"
|
||||||
|
| "requestId"
|
||||||
|
| "piiCategories"
|
||||||
|
| "errorCode";
|
||||||
|
expectTypeOf<Pick<Entry, OptionalKeys>>().toEqualTypeOf<
|
||||||
|
Partial<Pick<Entry, OptionalKeys>>
|
||||||
|
>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export type AuditAction =
|
|||||||
| "UPDATE"
|
| "UPDATE"
|
||||||
| "DELETE"
|
| "DELETE"
|
||||||
| "EXPORT"
|
| "EXPORT"
|
||||||
| "PERMISSION_CHANGE";
|
| "PERMISSION_CHANGE"
|
||||||
|
| "CONSENT_GRANT"
|
||||||
|
| "CONSENT_WITHDRAW"
|
||||||
|
| "RESTRICT"
|
||||||
|
| "UNRESTRICT";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6)
|
* `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6)
|
||||||
|
|||||||
Reference in New Issue
Block a user