From a3505f2e6990cf6d33db118f40b62e5f6c2e3a82 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 22:07:50 +0000 Subject: [PATCH] docs(compliance): add DSR guide, consent guide, subject-linkage example, glossary terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/guides/dsr.md: GDPR Art. 15/16/17/18/20 interface mapping, tRPC router wiring, multi-subject handling, soft vs cascade-hard semantics, DeletionCertificate format and storage requirements - docs/guides/consent.md: requiresConsent manifest field, withConsent DI wiring, runtime isGranted pattern, IConsent audit trail, anonymous→ authenticated migration, cookie _v versioning, SSR-safe banner loading, CNIL/EDPB equal-prominence requirement - docs/compliance/subject-linkage.example.md: SubjectLink kind discriminator with worked support-ticket example (owner submitter + reference assignee) - docs/glossary.md: SubjectLink, DeletionCertificate, UserConsentState, ConsentChecked entries; Manifest definition updated with requiresConsent - CLAUDE.md: lint comment 8→12 conformance rules; conformance section notes requiresConsent; brand composition order updated to full 5-wrapper chain - docs/guides/conformance-quickref.md: requiresConsent field added to manifest table; component-must-have-story, component-must-have-test, atomic-tier-import-direction added to ESLint rules table Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 +- docs/compliance/subject-linkage.example.md | 170 ++++++++++++++ docs/glossary.md | 17 +- docs/guides/conformance-quickref.md | 24 +- docs/guides/consent.md | 255 +++++++++++++++++++++ docs/guides/dsr.md | 189 +++++++++++++++ 6 files changed, 647 insertions(+), 14 deletions(-) create mode 100644 docs/compliance/subject-linkage.example.md create mode 100644 docs/guides/consent.md create mode 100644 docs/guides/dsr.md diff --git a/CLAUDE.md b/CLAUDE.md index fe33e51..aa467c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ pnpm dev # Start all dev servers pnpm build # Build all packages pnpm test # Run all tests pnpm typecheck # TypeScript across all packages -pnpm lint # ESLint (incl. 8 conformance/* rules) +pnpm lint # ESLint (incl. 12 conformance/* rules) pnpm conformance # Cross-feature event closure pnpm fallow # Whole-codebase: dead exports, dupes, complexity pnpm fallow:audit # AI-change audit (run before commits) @@ -62,7 +62,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, ## Conformance system -Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, and required cores. Drift is caught at five latencies: +Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, required cores, and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies: | Layer | Latency | Catches | | -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- | @@ -110,7 +110,7 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational - **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders - **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent - **Instrumentation lives in `core-shared/instrumentation/`** — Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017) -- **Spans + capture composed at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes +- **Spans + capture composed at DI bind time** — Use cases + controllers are wrapped at DI bind time in this order (outermost → innermost): `withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`. Apply `withAudit` when the manifest declares `audits`, `withAnalytics` when it declares `analyticsEvents`, `withConsent` when it declares `requiresConsent`. `withSpan` is always outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes - **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures. Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`) - **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (CI grep gate); replay default-masks all text/inputs/media (allowlist starts empty); `setUser({ id })` only — no email/username; server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (ADR-017 §7) - **Three apps, three Sentry projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes diff --git a/docs/compliance/subject-linkage.example.md b/docs/compliance/subject-linkage.example.md new file mode 100644 index 0000000..0dd81f3 --- /dev/null +++ b/docs/compliance/subject-linkage.example.md @@ -0,0 +1,170 @@ +# Subject linkage — annotated example + +This document describes the `custom.subject` declaration pattern for Payload collections that store data belonging to **more than one data subject**. It serves as the anchor for downstream consumers adding PII-holding collections to the data map. + +For background on the DSR cascade that consumes these declarations, see `docs/guides/dsr.md`. + +--- + +## What is a `SubjectLink`? + +A `SubjectLink` is a field-level declaration that maps a Payload relationship field to a data subject. The DSR cascade reads these at runtime to determine: + +1. Which rows to include in an Art. 15 export (`dsr.export`). +2. Which rows to delete or redact in an Art. 17 erasure (`dsr.delete`). + +```ts +type SubjectLink = { + field: string; // Payload field name (must be a relationship field) + kind: "self" | "owner" | "reference"; + target?: string; // Slug of the auth collection this field relates to + role?: string; // Semantic label (informational, appears in the data map) +}; +``` + +### `kind` discriminator + +| `kind` | Meaning | +| ------------- | --------------------------------------------------------------------------------------------------------------- | +| `"self"` | The field **is** the subject row — used on the auth collection itself (e.g., Users) | +| `"owner"` | The subject **created or owns** this row (e.g., the author of a post) | +| `"reference"` | The subject is **referenced** in this row but does not own it (e.g., an assignee, a reviewer, a mentioned user) | + +The difference between `"self"` and `"owner"` matters for deletion: rows marked `"self"` are the subject's account rows; rows marked `"owner"` are authored/owned content. Both are treated as owned data for Art. 15/17 purposes. `"reference"` rows are never deleted — only the linking field is NULLed. + +--- + +## Worked example: support ticket collection + +A support ticket has two subject relationships — the user who submitted it and the support agent assigned to it. + +```ts +// packages//src/integrations/cms/support-tickets.collection.ts +import type { CollectionConfig } from "payload"; + +export const SupportTicketsCollection: CollectionConfig = { + slug: "support-tickets", + custom: { + subject: [ + { + field: "submittedBy", + kind: "owner", // The submitter owns this ticket + target: "users", + role: "submitter", + }, + { + field: "assignedTo", + kind: "reference", // The assignee is merely linked, not the owner + target: "users", + role: "assignee", + }, + ], + retention: { + purgeSchedule: "daily", + postDeletion: { + action: "pseudonymize", + duration: "P30D", + trigger: "after-deletion", + }, + }, + }, + fields: [ + { + name: "title", + type: "text", + }, + { + name: "body", + type: "textarea", + custom: { + pii: { + category: "user-generated-content", + purpose: ["service-delivery"], + exportable: true, + restrictable: true, + }, + }, + }, + { + name: "submittedBy", + type: "relationship", + relationTo: "users", + required: true, + }, + { + name: "assignedTo", + type: "relationship", + relationTo: "users", + }, + ], +}; +``` + +### What the DSR cascade does with this collection + +**Art. 15 export** for user `alice`: + +- `submittedBy === alice` → ticket rows appear in `data["support-tickets"].asSelf` (filtered to exportable PII fields: `body`). +- `assignedTo === alice` → ticket row IDs + link coordinates appear in `data["support-tickets"].asReference`. + +**Art. 17 soft delete** for user `alice`: + +- Rows where `submittedBy === alice` → `body` field NULLed (pseudonymized, per `postDeletion.action = "pseudonymize"`). +- Rows where `assignedTo === alice` → `assignedTo` field NULLed; row is otherwise untouched. + +**Art. 17 cascade-hard delete** for user `alice` (admin only): + +- Rows where `submittedBy === alice` and `postDeletion.action` resolves to `"hard-delete"` → rows deleted entirely. +- Rows where `assignedTo === alice` → `assignedTo` field NULLed (reference rows are never hard-deleted — they belong to the submitter). + +--- + +## Collections with a single subject + +For collections owned by a single subject (e.g., user profile rows, blog posts), a single `SubjectLink` suffices: + +```ts +custom: { + subject: [ + { field: "author", kind: "owner", target: "users" }, + ], +} +``` + +--- + +## The Users collection (`kind: "self"`) + +The auth collection itself uses `kind: "self"` to mark the primary subject identifier field: + +```ts +// This is scaffolded automatically when `auth: true` is set on the collection. +custom: { + subject: [ + { field: "id", kind: "self", target: "users" }, + ], +} +``` + +The DSR cascade treats `kind: "self"` rows as the subject's account record — deletion here is always gated behind the strictest policy. + +--- + +## Regenerating the data map + +After adding or changing `custom.subject` declarations, regenerate the compliance data map: + +```bash +pnpm compliance:data-map +``` + +The output at `compliance/data-map.yml` shows every collection + its subject links + its PII fields. The pre-commit hook and CI gate run `compliance:data-map --check` to detect uncommitted drift. + +--- + +## Cross-references + +- DSR procedures and deletion semantics: `docs/guides/dsr.md` +- PII field tagging (`custom.pii`): `docs/compliance/README.md` +- Retention policy (`custom.retention`): `docs/compliance/retention-policy.example.yml` +- Glossary: `SubjectLink`, `DeletionCertificate` in `docs/glossary.md` diff --git a/docs/glossary.md b/docs/glossary.md index 1bfee98..3218e9f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -78,7 +78,7 @@ A port for collection-style entity access. Lives in `infrastructure/.reposito A port for non-collection capabilities (auth, mailer, etc.). Same `.ts` / `.mock.ts` / `.interface.ts` triad as repositories. **Manifest** (`feature.manifest.ts`): -The per-feature contract declaring `useCases`, `audits`, `publishes`, `consumes`, and `requiredCores`. Source of truth for the conformance system. Always edited **before** code (manifest-first ordering). +The per-feature contract declaring `useCases`, `audits`, `publishes`, `consumes`, `requiredCores`, and (when applicable) `requiresConsent: ConsentCategory[]`. Source of truth for the conformance system. Always edited **before** code (manifest-first ordering). **Symbol**: A DI token in `packages//src/di/symbols.ts`. Used by Inversify to identify a binding. @@ -258,6 +258,14 @@ _Avoid:_ confusing analytics events with cross-feature events (bus) or audit eve **`withSpan`** / **`withCapture`** / **`withAudit`** / **`withAnalytics`** / **`withConsent`** / **`withRateLimit`**: The six wrapper composers applied at DI bind time. Composition is nested with `withSpan` outermost; each layer attaches a brand: `withSpan` → `Instrumented`, `withCapture` → `Captured`, `withAudit` → `Audited` (when manifest declares `audits`), `withAnalytics` → `Analyzed` (when manifest declares `analyticsEvents`), `withConsent` → `ConsentChecked` (when manifest declares `requiresConsent`), `withRateLimit` → `RateLimited` (when manifest declares `rateLimit`). See ADR-025 for the compliance-baseline channels. +**SubjectLink**: +A field-level declaration in a Payload collection's `custom.subject` array that maps a relationship field to a data subject. Carries `field` (Payload field name), `kind` (`"self"` | `"owner"` | `"reference"`), optional `target` (auth-collection slug), and optional `role` (semantic label). The DSR cascade walks these at runtime to determine which rows to include in Art. 15 exports and which rows to erase or redact on Art. 17 requests. See `docs/compliance/subject-linkage.example.md`. +_Avoid:_ confusing `kind: "self"` (the subject's own account row) with `kind: "owner"` (rows the subject authored/owns) — both are treated as owned data for DSR purposes, but `"self"` targets the auth-collection record itself. + +**DeletionCertificate**: +The immutable proof-of-erasure record returned by `IDataDelete.deleteSubjectData(subjectId, mode)`. Carries `subjectId` (or `"erased-{hash}"` after the ID itself is purged), `mode` (`"soft"` | `"cascade-hard"`), `timestamp` (ISO 8601), `reason`, `affected` (per-collection summary of `collection`, `rowsAffected`, `action`, and `fields` NULLed), and `auditEntryId` linking to the immutable audit log entry. GDPR Art. 17 compliance artifact — store permanently, never mutate. See `docs/guides/dsr.md`. +_Avoid:_ generating a `DeletionCertificate` manually — it is emitted only by the production `IDataDelete` implementation to ensure correctness. + **DSR** (`@repo/core-dsr`): The Data Subject Rights handler — optional core package providing four interfaces that operate on user data in response to GDPR Arts. 15–18 requests. `IDataExport` (Art. 15 access + Art. 20 portability), `IDataDelete` (Art. 17 erasure cascade), `IDataRectify` (Art. 16), `IProcessingRestriction` (Art. 18). Sibling to `core-audit` — audit _records_ access, DSR _acts_ on the underlying data. Walks Payload collections at runtime using field-level `custom.pii` tags. See ADR-025. _Avoid:_ confusing DSR with audit; audit is the immutable journal, DSR is the mutator. @@ -265,6 +273,13 @@ _Avoid:_ confusing DSR with audit; audit is the immutable journal, DSR is the mu **Consent** (`@repo/core-consent`): The runtime gate for granular consent categories (essential/functional/analytics/marketing). Optional core package providing `IConsent` interface (`isGranted`/`grant`/`withdraw`/`getCategories`) + `ConsentChecked` brand + `requiresConsent: [...]` manifest field + `no-undeclared-consent-check` ESLint rule. Cookie banner UI ships as atomic component in `core-ui`. See ADR-025. +**UserConsentState**: +A per-category consent record returned by `IConsent.getCategories()`. Carries `category` (`ConsentCategory`), `state` (`"granted"` | `"denied"` | `"pending"`), optional `grantedAt` and `withdrawnAt` ISO 8601 timestamps, `bannerVersion`, `policyVersion`, and `method` (how the category was consented — e.g., `"banner-accept"`, `"signup-migration"`). The element type of the `getCategories()` return value; used for compliance audits and cookie-versioning migration-on-read logic. + +**ConsentChecked**: +The phantom-type brand `ConsentChecked` attached by `withConsent(consent, factory(deps))` at DI bind time. Signals that the wrapped use case's `requiresConsent` categories will be checked before execution. TypeScript rejects binding an unwrapped factory to a `ConsentChecked`-typed DI symbol when the manifest declares `requiresConsent`. The boot assertion (`assertFeatureConformance`) verifies the brand is present at runtime. See `withConsent` in `@repo/core-consent`. +_Avoid:_ applying `withConsent` in test files — tests inject mocks directly into the factory and do not go through the DI wrappers. + **Rate-limit** (`core-shared/rate-limit`): Fourth conformance channel after audit/analytics/consent. `IRateLimit` interface (`consume`/`reset`) + `withRateLimit` wrapper + `RateLimited` brand + `rateLimit: { window, budget }` manifest field. Default budgets in manifest, runtime overrides via `ctx.rateLimit` config. Consumer wires Redis/Upstash impl. See ADR-025. diff --git a/docs/guides/conformance-quickref.md b/docs/guides/conformance-quickref.md index b291423..024c8f0 100644 --- a/docs/guides/conformance-quickref.md +++ b/docs/guides/conformance-quickref.md @@ -32,16 +32,17 @@ export type FooManifest = typeof fooManifest; Field reference: -| Field | Type | Meaning | -| --------------------------- | -------------- | --------------------------------------------------------------------------- | -| `name` | string literal | Feature name (kebab-case, matches package name) | -| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) | -| `useCases..mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required | -| `useCases..audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` | -| `useCases..publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` | -| `useCases..consumes` | string[] | Cross-feature events this use case consumes (via an event handler) | -| `realtimeChannels` | string[] | Realtime channels this feature owns | -| `jobs` | string[] | Job slugs this feature enqueues | +| Field | Type | Meaning | +| --------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- | +| `name` | string literal | Feature name (kebab-case, matches package name) | +| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) | +| `useCases..mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required | +| `useCases..audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` | +| `useCases..publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` | +| `useCases..consumes` | string[] | Cross-feature events this use case consumes (via an event handler) | +| `realtimeChannels` | string[] | Realtime channels this feature owns | +| `jobs` | string[] | Job slugs this feature enqueues | +| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` | Re-export from `src/index.ts`: @@ -96,6 +97,9 @@ The symbol map declares which container symbol each manifest use-case key resolv | `conformance/usecase-must-be-wired` | error | Every manifest use case must be bound via `wireUseCase({ name: "" })` in `bind-production.ts` / `bind-dev-seed.ts` | | `conformance/no-undeclared-analytics-event` | warn | `analytics.track("X")` literal must match the manifest's `analyticsEvents` for the use case | | `conformance/pii-declaration-must-be-complete` | warn | `custom.pii` blocks in Payload config files must declare all required fields: `category`, `purpose`, `exportable`, `restrictable` | +| `conformance/component-must-have-story` | warn | Every component file under `src/` must have a sibling `.stories.tsx` file | +| `conformance/component-must-have-test` | warn | Every component file under `src/` must have a sibling `.test.tsx` file | +| `conformance/atomic-tier-import-direction` | warn | Atomic-design import direction must flow downward (atoms ← molecules ← organisms ← templates ← pages); no upward imports | | `conformance/no-undeclared-consent-check` | warn | `consent.isGranted("X")` literal in a use-case file must match a category declared in `manifest.requiresConsent`; warns if declared categories are never checked | ## Workflow ordering for new use cases diff --git a/docs/guides/consent.md b/docs/guides/consent.md new file mode 100644 index 0000000..b565c2b --- /dev/null +++ b/docs/guides/consent.md @@ -0,0 +1,255 @@ +# Consent guide + +Consumer-facing reference for the `@repo/core-consent` optional core package. Covers the `requiresConsent` manifest field, `withConsent` DI wiring, runtime consent-check pattern, `IConsent` interface, anonymous→authenticated migration, cookie versioning policy, SSR-safe banner loading, and CNIL/EDPB equal-prominence requirements. + +**Prerequisite:** scaffold the package if it isn't already present: + +```bash +pnpm turbo gen core-package consent +``` + +--- + +## Declaring `requiresConsent` in the feature manifest + +Any feature that gates behaviour behind user consent declares the required categories in `feature.manifest.ts`: + +```ts +// packages//src/feature.manifest.ts +export const fooManifest = defineFeature({ + name: "foo", + requiredCores: ["consent"], + requiresConsent: ["analytics", "marketing"], + useCases: { + trackEvent: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + // ... +} as const); +``` + +`requiresConsent` accepts an array of `ConsentCategory` values: + +| Category | Typical use | +| -------------- | ----------------------------------------------- | +| `"necessary"` | Session management, CSRF protection (always on) | +| `"functional"` | Preferences, language, personalised content | +| `"analytics"` | Product analytics, funnels, cohort analysis | +| `"marketing"` | Ad targeting, remarketing pixels | +| `string & {}` | Custom categories (autocomplete still works) | + +The ESLint rule `conformance/no-undeclared-consent-check` warns if: + +- A use-case file calls `consent.isGranted("X")` for a category not in `manifest.requiresConsent`. +- `manifest.requiresConsent` lists a category that is never checked in any use case. + +--- + +## Wiring `withConsent` at DI bind time + +Apply `withConsent` **inside** `withCapture`, **outermost** among the optional wrappers: + +```ts +// packages//src/di/bind-production.ts +import { withSpan, withCapture } from "@repo/core-shared/instrumentation"; +import { withConsent } from "@repo/core-consent"; + +export function bindProductionFoo(ctx: BindProductionContext): void { + const { tracer, logger, consent } = ctx; + + // Full composition order (outermost → innermost): + // withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps) + fooContainer + .bind(FOO_SYMBOLS.ITrackEventUseCase) + .toDynamicValue(() => + withSpan( + tracer, + { name: "foo.trackEvent" }, + withCapture( + logger, + { useCase: "foo.trackEvent" }, + withConsent(consent, trackEventUseCase({ analytics: ctx.analytics })), + ), + ), + ); + + assertFeatureConformance( + fooContainer, + fooManifest, + { trackEvent: FOO_SYMBOLS.ITrackEventUseCase }, + ctx, + ); +} +``` + +`withConsent` attaches the `ConsentChecked` brand at bind time. The boot assertion (`assertFeatureConformance`) verifies that every use case listed under `requiresConsent` in the manifest is wrapped. TypeScript rejects binding an unwrapped factory to a `ConsentChecked`-typed symbol. + +--- + +## Runtime consent-check pattern + +Inside a use case, call `consent.isGranted(category)` before performing the gated work: + +```ts +// packages//src/application/use-cases/track-event.use-case.ts +import type { IConsent } from "@repo/core-consent"; +import { ConsentDeniedError } from "@repo/core-consent/errors"; + +export function trackEventUseCase(deps: { + analytics: IAnalytics; + consent: IConsent; +}) { + return async (input: TrackEventInput): Promise => { + if (!deps.consent.isGranted("analytics")) { + throw new ConsentDeniedError("analytics"); + } + await deps.analytics.track(input.event, input.properties); + }; +} +``` + +`isGranted` is **synchronous** — it reads the in-memory consent state populated at request initialisation. Do not `await` it. + +--- + +## `IConsent` interface + +```ts +interface IConsent { + /** Synchronous: reads in-memory state. */ + isGranted(category: ConsentCategory): boolean; + /** Records a consent grant; production impl writes to the audit log. */ + grant(category: ConsentCategory, meta?: ConsentGrantMeta): Promise; + /** Records a consent withdrawal; production impl writes to the audit log. */ + withdraw(category: ConsentCategory): Promise; + /** Returns per-category state for all categories the subject has interacted with. */ + getCategories(): Promise; +} +``` + +### Audit trail + +The production `IConsent` implementation calls `auditLog.record(...)` on every `grant` and `withdraw` call, creating an immutable audit entry with: + +- `type`: `"consent.granted"` or `"consent.withdrawn"` +- `category`: the `ConsentCategory` string +- `method`: from `ConsentGrantMeta.method` (e.g., `"banner-accept"`, `"signup-migration"`) +- `bannerVersion` / `policyVersion`: from `ConsentGrantMeta` when provided + +This creates a traceable consent history for regulatory inspection. + +--- + +## Anonymous → authenticated migration + +When an anonymous visitor accepts the cookie banner, their choices are stored in the `cc_consent` cookie as a comma-separated list of granted categories (e.g., `necessary,analytics`). + +On sign-up or sign-in, migrate the anonymous consent to the authenticated user record: + +```ts +// packages/auth/src/application/use-cases/sign-up.use-case.ts +import { + extractAnonymousConsent, + migrateAnonymousConsent, +} from "@repo/core-consent/migration"; + +export function signUpUseCase(deps: { + users: IUsersRepository; + consent: IConsent; +}) { + return async (input: SignUpInput): Promise => { + const user = await deps.users.create({ email: input.email /* ... */ }); + + // Migrate consent from the anonymous banner cookie, if present. + const cookieState = extractAnonymousConsent(input.cookieHeader ?? ""); + await migrateAnonymousConsent({ + consent: deps.consent, + cookieState, + bannerVersion: input.bannerVersion, + policyVersion: input.policyVersion, + }); + + return signUpOutputSchema.parse({ userId: user.id }); + }; +} +``` + +`migrateAnonymousConsent` calls `consent.grant(category, { method: "signup-migration" })` for each category in the cookie. It is a no-op when `cookieState` is `null`. + +After migration, delete or expire the `cc_consent` cookie on the client to avoid double-migration on subsequent sign-ins. + +--- + +## Cookie versioning policy + +The client-side consent state is stored in the `__consent_state` cookie as JSON: + +```ts +type ConsentCookieState = { + _v: number; // schema version (bump when categories change) + categories: Record; // category slug → granted +}; +``` + +**When to increment `_v`:** + +- You add a new non-necessary consent category. +- You update the privacy policy in a way that requires renewed consent (e.g., new processing purpose). +- You remove a category that users previously consented to and need to inform them of the change. + +**Migration-on-read:** when `readConsentCookie()` reads a cookie with `_v < current`, treat the consent as `pending` and show the banner again. The user's previous granular choices are discarded because the categories or policy changed. Implement this in your `ConsentProvider` initialisation. + +The `cc_consent` anonymous cookie (written before authentication) follows the same versioning scheme — pass `bannerVersion` through `extractAnonymousConsent` / `migrateAnonymousConsent` so the version is preserved in the audit log. + +--- + +## SSR-safe banner loading + +The cookie consent banner is a client-only component (reads `document.cookie`, attaches focus traps). Use `CookieConsentBannerLoader` instead of `CookieConsentBanner` directly to prevent SSR hydration mismatches: + +```tsx +// apps/web-next/src/app/layout.tsx +import { CookieConsentBannerLoader } from "@repo/core-ui/cookie-consent-banner"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + {" "} + {/* renders null on server, mounts on client */} + + + ); +} +``` + +`CookieConsentBannerLoader` returns `null` during SSR and mounts the full `CookieConsentBanner` only after hydration, avoiding the `document is not defined` error and preventing layout shift from banner flicker on first load. + +--- + +## CNIL / EDPB equal-prominence requirement + +The French CNIL and the EDPB both require that the banner offers **equivalent visual weight** to accepting and rejecting consent — a prominent "Accept all" button paired with an equally-prominent "Reject all" button, with no pre-checked non-necessary categories. + +The default `CookieConsentBanner` implements this: + +- **Accept all** and **Reject all** buttons are rendered at the same visual prominence (identical `variant` prop). +- Non-necessary category checkboxes default to **unchecked**. +- ESC key triggers "Reject all" (keyboard-accessible rejection path). +- The `required: true` flag on a `CookieCategory` marks it as always-on and renders a disabled, checked checkbox — do not use `required: true` for non-necessary categories. + +If you customise the banner via render props (`renderActions`, `renderCategoryRow`), you must preserve these guarantees manually. Failing to offer an equivalent "Reject all" path is a CNIL/EDPB violation. + +--- + +## Cross-references + +- Cookie banner component: `@repo/core-ui` → `CookieConsentBanner`, `CookieConsentBannerLoader` +- Anonymous migration: `@repo/core-consent/migration` → `extractAnonymousConsent`, `migrateAnonymousConsent` +- Conformance rule: `no-undeclared-consent-check` in `docs/guides/conformance-quickref.md` +- Glossary: `ConsentChecked`, `UserConsentState` in `docs/glossary.md` +- ADR: ADR-025 (compliance baseline channels) diff --git a/docs/guides/dsr.md b/docs/guides/dsr.md new file mode 100644 index 0000000..93868bb --- /dev/null +++ b/docs/guides/dsr.md @@ -0,0 +1,189 @@ +# Data Subject Rights (DSR) guide + +Consumer-facing reference for the `@repo/core-dsr` optional core package. Covers the four GDPR interfaces, tRPC procedure wiring, multi-subject collection handling, deletion semantics, `DeletionCertificate` format, and per-article compliance notes. + +**Prerequisite:** scaffold the package if it isn't already present: + +```bash +pnpm turbo gen core-package dsr +``` + +--- + +## Interfaces and GDPR article mapping + +`@repo/core-dsr` exposes four vendor-neutral interfaces: + +| Interface | tRPC procedure | GDPR article(s) | Mutation? | +| ------------------------ | -------------- | ----------------------------------------- | ---------- | +| `IDataExport` | `dsr.export` | Art. 15 (access) + Art. 20 (portability) | No (query) | +| `IDataDelete` | `dsr.delete` | Art. 17 (erasure / right to be forgotten) | Yes | +| `IDataRectify` | `dsr.rectify` | Art. 16 (rectification) | Yes | +| `IProcessingRestriction` | `dsr.restrict` | Art. 18 (restriction of processing) | Yes | + +--- + +## Wiring the DSR router + +`core-dsr` ships a pre-built tRPC router. Mount it once in your app router: + +```ts +// apps/web-next/src/server/router.ts +import { createDsrRouter } from "@repo/core-dsr"; +import { bindProductionDsr } from "@repo/core-dsr/di/bind-production"; + +const dsrBinding = bindProductionDsr({ config: payloadConfig, auditLog }); + +export const appRouter = t.router({ + // ... other feature routers + dsr: createDsrRouter(dsrBinding), +}); +``` + +All four procedures require an authenticated user in `ctx.user`. `cascade-hard` deletion additionally requires `ctx.user.roles` to include `"admin"`. + +### Input schemas + +| Procedure | Input fields | +| -------------- | ---------------------------------------------------------------------------- | +| `dsr.export` | `subjectId: string`, `format: "json" \| "json-ld"` | +| `dsr.delete` | `subjectId: string`, `mode: "soft" \| "cascade-hard"` | +| `dsr.rectify` | `subjectId: string`, `collection: string`, `field: string`, `value: unknown` | +| `dsr.restrict` | `subjectId: string`, `granted: boolean` | + +--- + +## Multi-subject handling + +A Payload collection can reference **multiple data subjects** — for example, a support ticket has both a submitter and an assignee. Declare subject relationships via `custom.subject` on the collection config: + +```ts +// packages//src/integrations/cms/support-tickets.collection.ts +{ + slug: "support-tickets", + custom: { + subject: [ + { field: "submittedBy", kind: "self", target: "users" }, + { field: "assignedTo", kind: "reference", target: "users", role: "assignee" }, + ], + }, + fields: [ + { name: "submittedBy", type: "relationship", relationTo: "users" }, + { name: "assignedTo", type: "relationship", relationTo: "users" }, + ], +} +``` + +The DSR cascade walks each `SubjectLink` at runtime to determine scope: + +| `kind` | Meaning | Export behaviour | Delete behaviour | +| ------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | ---------------------------------- | +| `"self"` | The subject **is** this row (e.g., the Users row itself) | Full row in `asSelf` | Row deleted / pseudonymized | +| `"owner"` | The subject **created or owns** this row (e.g., posts authored by the user) | Full row in `asSelf` | Row deleted / pseudonymized | +| `"reference"` | The subject is **referenced** in this row but does not own it (e.g., assignee on a ticket) | Row ID + link coords in `asReference` | Linked field NULLed; row preserved | + +See `docs/compliance/subject-linkage.example.md` for a full annotated example. + +--- + +## Deletion modes + +### `soft` (default, no admin role required) + +Redacts or pseudonymizes PII fields in rows the subject owns (`kind: "self" | "owner"`) while preserving foreign-key integrity. Reference rows (`kind: "reference"`) have the linking field NULLed. The row structure remains intact — useful for preserving order history, audit trails, and referential integrity. + +### `cascade-hard` (admin role required) + +Hard-deletes all rows the subject owns (where `postDeletion.action === "hard-delete"` in the collection's retention config), then NULLs reference fields. Use only when the subject's right to erasure overrides your referential-integrity requirements or when retention policy mandates hard deletion. + +Retention-policy overrides apply: a collection with `postDeletion.action = "pseudonymize"` will pseudonymize rather than hard-delete even in `cascade-hard` mode. + +--- + +## `DeletionCertificate` format + +`IDataDelete.deleteSubjectData(subjectId, mode)` resolves to a `DeletionCertificate`: + +```ts +type DeletionCertificate = { + subjectId: string; // or "erased-{hash}" if the ID itself was purged + mode: "soft" | "cascade-hard"; + timestamp: string; // ISO 8601 + reason: "art-17-request" | "admin-expunge" | "retention-policy"; + affected: Array<{ + collection: string; + rowsAffected: number; + action: "deleted" | "redacted" | "pseudonymized"; + fields?: string[]; // PII field names NULLed when action === "redacted" + }>; + auditEntryId: string; // links to the immutable audit log entry +}; +``` + +**Storage requirements:** persist the `DeletionCertificate` permanently — it is your Art. 17 compliance evidence for regulatory inspection. The `auditEntryId` forms a tamper-evident chain back to the `core-audit` log. Never mutate a certificate after creation. + +--- + +## `UserDataBundle` format (export) + +`IDataExport.exportSubjectData(subjectId, format)` resolves to a `UserDataBundle`: + +```ts +type UserDataBundle = { + subjectId: string; + exportedAt: string; // ISO 8601 + format: "json" | "json-ld"; + data: Record< + string, + { + // keyed by collection slug + asSelf?: Array>; // owned rows (PII-filtered) + asReference?: Array<{ + rowId: string; + linkedField: string; + linkedThrough: string; + }>; + } + >; + auditLog?: AuditEntry[]; // subject-scoped audit history + "@context"?: string | Record; // JSON-LD only +}; +``` + +Only fields marked `exportable: true` in their `custom.pii` block are included in `asSelf` rows. + +--- + +## Compliance notes + +### Art. 15 — Right of access + +`dsr.export` with `format: "json"` satisfies the right of access. Return the `UserDataBundle` directly in the API response or as a downloadable JSON file. Response time: ≤ 30 days under GDPR. + +### Art. 16 — Right to rectification + +`dsr.rectify` targets a single field. For bulk updates, call it once per field. The implementation calls `IDataRectify.rectifySubjectData(subjectId, collection, field, value)` and records an audit entry. + +### Art. 17 — Right to erasure + +`dsr.delete` with `mode: "soft"` is the default erasure path. Use `mode: "cascade-hard"` only when data minimisation obligations outweigh referential integrity needs. Both paths produce a `DeletionCertificate`. + +**Exemptions:** Art. 17(3) allows retention for legal obligations (e.g., invoices, tax records). Implement exemptions via a `postDeletion.action = "pseudonymize"` retention override on the affected collection — the DSR cascade respects this automatically. + +### Art. 18 — Restriction of processing + +`dsr.restrict` with `granted: true` flags the subject's data for restricted processing. Your application code must check `IProcessingRestriction.isRestricted(subjectId)` before performing processing operations on restricted subjects. + +### Art. 20 — Right to data portability + +`dsr.export` with `format: "json-ld"` produces a machine-readable JSON-LD export suitable for portability. The `@context` field is populated with the schema URI for downstream consumption. + +--- + +## Cross-references + +- Subject linkage patterns: `docs/compliance/subject-linkage.example.md` +- PII field tagging: `docs/compliance/README.md` → "Annotating PII fields" +- Retention policy: `docs/compliance/retention-policy.example.yml` +- Audit log: `docs/guides/audit-and-compliance.md` +- Glossary: `SubjectLink`, `DeletionCertificate` in `docs/glossary.md`