Product documentation
PII coverage matrix — DSAR + erasure completeness audit
Every personal-data-holding store across hub, identity-service, and product-api, mapped against the DSAR export fan-out and subject erasure — the completeness audit that gates any complete-GDPR claim (SSO-2505).
PII coverage matrix — DSAR + erasure completeness audit
This page is the cross-cutting completeness audit the GDPR Trust Center ADR
(adrs/2026-08-11-gdpr-trust-center-and-consent-records.md) makes a hard gate: "a cross-cutting
DSAR/erasure completeness audit (does the fan-out truly cover every PII-holding source?) gates any
'complete' claim." It exists so no "complete GDPR" claim
ships on apps/site ahead of the code, per the CLAUDE.md marketing-claim rule.
It enumerates every personal-data-holding store across the three services that persist
subject data — the authorization hub (servers/authorization-hub), the identity member
(servers/federation-members/identity-service), and the customer-plane API
(servers/product-api) — and maps each store against the two subject-rights mechanisms that
must reach it:
- DSAR export fan-out (GDPR Art. 15) —
DsarOrchestratorcollects everyDsarSourcebean and aggregates a signed bundle. Sources today:HubDsarSource(hub — issued authorizations plus, as of SSO-2515,consent_record/oauth2_authorization_consent/user_workspace_mapping),IdentityServiceDsarSource(identity, reached over HTTP viaHttpDsarSource— theusersprofile plus, as of SSO-2515, sign-in history / known devices / sessions / MFA + passkey enrolment / ToU acceptances / org memberships),UserProjectionDsarSource(product-apiapp_user), andProductApiDsarSource(product-api — as of SSO-2515audit_events/directory_user/federated_identity; was a placeholder that returned empty). Each auxiliary store is emitted as a record tagged with arecord_kinddiscriminator within its module's fragment; credential material (MFA hashes, passkey key bytes, session nonces) is deliberately excluded. - Subject erasure (GDPR Art. 17) — now a single unified "erase everywhere" fan-out (SSO-2514):
the SSO-2509 two-phase hard-erase (
ErasureRequestOrchestrator, CLEAR → ERASED) drives all three planes once the hold-check clears, each leg audited on the retainederasure_auditand per-leg isolated (a failing leg is recorded, not a lost erasure):- identity
UserErasureService(the authoritative profile/credential destroy): explicit deletes plus FK cascade offusers; - product-api — the
DsarEraseSourceerase fan-out over three beans now:UserProjectionEraseSource(anonymizeapp_user),DirectoryUserEraseSource(anonymizedirectory_user),FederatedIdentityEraseSource(deletefederated_identity), keyed by the platform subject the orchestrator resolves from the identity user id (via the pairwise sub →federated_identity); - hub —
HubSubjectErasureService(over thePOST /internal/subject-erasureS2S seam) delete-by-subject overconsent_record,oauth2_authorization_consent,user_workspace_mapping(distinct from SSO-2501's delete-by-CLIENT).
- identity
A third axis — the client-centric purge (ADR Decision 3, SSO-2501) — is now built. When an
Application (OAuth client) is deleted / offboarded, ClientManagementService.deleteClient
cascade-purges the PII-bearing protocol / consent state keyed to it (consent_record,
oauth2_authorization + oauth2_authorization_token, oauth2_authorization_consent), a scheduled
OrphanedClientStatePurgeJob reconciles rows left behind by any missed delete path, and product-api
writes a client-scoped client_erasure_receipt (a hash of the client id — provable without
retaining the identifier). The purge is a hard delete of protocol/consent state only; the receipt
and the CLIENT_PII_PURGED audit row that record it are retained (the ADR audit-trail guardrail).
This is the platform-owned state only — it does not reach PII the relying party holds downstream
(that is the RP's own controller boundary; the standards-first RP signal is SSO-2508).
How to read the matrix
- DSAR-covered? — is the store's PII emitted into the Art. 15 bundle, and by which
DsarSource? - Subject-erasure-covered? — is the store's PII removed (deleted or anonymized) when the subject
is erased?
cascade= removed transitively by theON DELETE CASCADEFK toidentity.userswhenUserErasureServicedeletes theusersrow. - retention-only — the store is not subject-triggered at all; it is aged out by a time-based
retention/TTL sweep (
DataRetentionService,TenantAuditRetentionService, tokenexpires_at), not by an Art. 15/17 request. - A store marked audit / receipt (retained) is deliberately kept for accountability per the ADR guardrail "purge is a hard delete of protocol/consent state — never of the audit trail".
Authorization hub — protocol state only (no profile)
The hub is a pure identity broker: it owns no
profile, credential, or organisation data — only protocol artefacts keyed by subject
(principal_name = sub). Subject erasure is not a hub operation by design; the SSO-64
session-revoke cascade invalidates the subject's live refresh tokens, and DataRetentionService
(time-based, per-tenant AUTHORIZATION / TOKEN / SESSION policies) ages the rest out. That
leaves a gap: erasing a subject in identity does not delete the hub rows that name them — they
persist until the retention window elapses.
| Store | Holds (PII) | DSAR-covered? | Subject-erasure-covered? | Notes / gap |
|---|---|---|---|---|
oauth2_authorization | principal_name = sub, scopes, grant type | Yes — HubDsarSource (issued_tokens) | No — retention-only (SSO-64 cascade revokes tokens) | The one hub store in the Art. 15 bundle (SSO-2515 added the consent/workspace stores below). Client-centric purge: built (SSO-2501 — revoked on client delete + orphan sweep). |
oauth2_authorization_token, oauth2_authorization_token_claim, oauth2_authorization_attribute | token bytes + serialized claim values (may embed email / name) | No | No — retention-only | Bundle surfaces the authorization row, not token bytes (intentional). Claim values can carry PII. |
oauth2_refresh_token, oauth2_refresh_token_attribute | refresh token tied to subject | No | No — retention + SSO-64 revoke | |
oauth2_authorization_consent | principal to client consent authorities | Yes — HubDsarSource (authorization_consent, SSO-2515) | Yes — HubSubjectErasureService delete-by-subject (SSO-2514) | Client-centric purge: built (SSO-2501). Subject erasure (SSO-2514): DELETE … WHERE principal_name = sub. Was gap G9. |
oauth2_authorization_request | pending authorize request (may carry login_hint) | No | No — retention-only | |
oauth2_device_authorization | device grant tied to subject | No | No — retention-only | |
consent_record | user_id, client_id, scope, grant/withdraw timestamps | Yes — HubDsarSource (consent_record, SSO-2515) | Yes — HubSubjectErasureService delete-by-subject (SSO-2514) | Client-centric purge: built (SSO-2501). Subject erasure (SSO-2514): DELETE … WHERE user_id = sub (distinct from ConsentService withdraw, which sets withdrawn_at). Was gap G9. |
federation_session | principal_name | No | No — retention sweep (short-lived, ≤120s) | HubDsarSource kdoc says these are gathered by the retention sweep — that is cleanup, not export. |
login_session | login_hint, client_id, redirect_uri | No | No — TTL expires_at | Short-lived pre-auth state. |
account_link_intent | link_subject = sub | No | No — TTL / single-use consumed_at | Short-lived capability record. |
user_workspace_mapping | sub to tenant/workspace | Yes — HubDsarSource (workspace_mapping, SSO-2515) | Yes — HubSubjectErasureService delete-by-subject (SSO-2514) | The erased subject's founder mappings are deleted (DELETE … WHERE sub = sub); the tenant itself survives (owned separately). Now exported too (SSO-2515). Was gap G9. |
data_retention_audit | purge counts (no raw subject) | n/a | audit (retained) | Accountability. |
security_incident | may reference a subject in incident detail | No | audit (retained) | NIS2 accountability. |
Identity service — the profile + credential owner
Identity-service owns the user profile and every credential/session artefact. UserErasureService
(1) revokes sessions first (so the SSO-64 hub cascade can resolve tenant_id), (2) explicitly
deletes mfa_backup_codes, passkey_credentials, organisation_memberships, then — as of
SSO-2513 — clears the non-cascade FK blockers per ownership (delete user-owned tou_acceptance and
the member's own access_review_decision rows; anonymise access_review_campaign.created_by and
other members' access_review_decision.reviewer_user_id; unassign oath_hardware_token), then (3)
deletes the users row — which cascade-sweeps every child table whose FK is REFERENCES users(id) ON DELETE CASCADE. As of SSO-2515 the Art. 15 export (IdentityServiceDsarSource) surfaces the
users profile row plus the subject's sign-in history, known devices, active sessions, MFA +
passkey enrolment (presence only — never the secret hash / key bytes), ToU acceptances, and org
memberships (gap G7). Erasure coverage is unchanged.
| Store | Holds (PII) | DSAR-covered? | Subject-erasure-covered? | Notes / gap |
|---|---|---|---|---|
users | email, given/family name, locale, status, external_id, password_hash | Yes — IdentityServiceDsarSource (user_profile) | Yes — deleted | Export omits password_hash (correct). SSO-2515 added the auxiliary stores below to the same fragment. |
mfa_backup_codes | MFA backup codes | Yes — IdentityServiceDsarSource (mfa_backup_code — enrolment PRESENCE only, never the hash, SSO-2515) | Yes — explicit delete | Was gap G7. |
passkey_credentials | WebAuthn credentials | Yes — IdentityServiceDsarSource (passkey_credential — nickname + timestamps, never key bytes, SSO-2515) | Yes — explicit delete | Was gap G7. |
organisation_memberships | user_id, org, role | Yes — IdentityServiceDsarSource (organisation_membership, SSO-2515) | Yes — explicit delete (non-cascade FK) | Was gap G7. |
user_sessions | session, device/IP context | Yes — IdentityServiceDsarSource (user_session — IP / UA / country, never the device-marker nonce, SSO-2515) | Yes — explicit revoke + cascade | Was gap G7. |
account_recovery_codes | recovery codes | No | Yes — cascade | Credential material — export deliberately omits (like MFA/passkey secrets). |
email_verification_tokens | email verification token | No | Yes — cascade | |
password_reset_tokens | reset token | No | Yes — cascade | |
account_unlock_token | unlock token | No | Yes — cascade | |
sign_in_history | IP, device, timestamps | Yes — IdentityServiceDsarSource (sign_in_history, SSO-2515) | Yes — cascade | Was gap G7. |
sign_in_attempt | email / IP attempt log | No | Yes — cascade | Pre-auth attempt log (nullable user_id); not part of the SSO-2515 by-user export. |
known_device | device fingerprint, IP | Yes — IdentityServiceDsarSource (known_device — label + coarse fingerprint, SSO-2515) | Yes — cascade | Was gap G7. |
step_up_challenge | challenge state | No | Yes — cascade | |
first_factor_token | first-factor token | No | Yes — cascade | |
user_notification_log | notification history | No | Yes — cascade | |
user_identity_link | external IdP link | No | Yes — cascade | |
scim_outbound_remote_user | remote SCIM id | No | Yes — cascade | |
wasnt_me_tokens | security-alert token | No | Yes — cascade | |
oath_hardware_token | assigned_user_id (nullable, non-cascade FK) | No | Yes — unassigned (SSO-2513) | Shared inventory: the token is returned to the pool (assigned_user_id→NULL, status→UNASSIGNED, assigned_at→NULL) before the users delete. Was gap G5. |
tou_acceptance | user_id, ip_address, user_agent (NOT NULL non-cascade FK) | Yes — IdentityServiceDsarSource (tou_acceptance — incl. captured IP / user-agent, SSO-2515) | Yes — explicit delete (SSO-2513) | User-owned PII: GDPR Art. 17 full erasure is the deliberate exception to V9's operational-lifecycle immutability, so the rows (incl. ip_address/user_agent) are deleted before the users delete. Was gap G3 (erase) + G7 (export). |
access_review_campaign | created_by (nullable non-cascade FK as of V89) | No | Yes — anonymised (SSO-2513) | Org-owned compliance record: created_by is nulled (V89 made it nullable) so the SOC2/ISO/NIS2 campaign survives without its erased creator. Was gap G4. |
access_review_decision | member_user_id (NOT NULL), reviewer_user_id (nullable non-cascade FK) | No | Yes — delete + anonymise (SSO-2513) | The erased member's own decision rows are deleted; reviewer_user_id on other members' decisions is nulled (decision about a still-present member survives). Was gap G4. |
magic_link_tokens | email, requested_ip (email-keyed, no FK) | No | No — TTL sweep only | Not subject-erasable (no user_id); email/IP linger until TTL. |
invitation_token | email, inviter / acceptee user_id (no cascade) | No | No | Email PII; not erased with the subject. |
password_reset_request_audit | requested_ip (no user linkage) | No | No — retention | IP retained; deliberately decoupled from users. |
erasure_receipt | user_id_hash (SHA-256, not raw) | n/a | receipt (retained) | Accountability; hash only, so not itself the identifier. |
Product-api — customer plane
Product-api runs the DSAR orchestrator. Its own sources are UserProjectionDsarSource (the
app_user PII projection — the only store wired to both export and erase "from day one" per the
projection ADR) and ProductApiDsarSource, which as of SSO-2515 exports audit_events (actor =
subject, gap G6), directory_user (SCIM-mirrored PII, gap G8) and federated_identity (the
subject's upstream-IdP link, gap G8) — it was previously a placeholder that returned an empty
fragment. The erase fan-out still has exactly one bean, UserProjectionEraseSource, which
anonymizes app_user PII columns while keeping the subject anchor (the SSO-2515 additions are
export-only — erasure of directory_user / federated_identity remains open).
| Store | Holds (PII) | DSAR-covered? | Subject-erasure-covered? | Notes / gap |
|---|---|---|---|---|
app_user | email, first/last/display name, profile picture, locale, metadata, subject, external_id | Yes — UserProjectionDsarSource (user_projection) | Yes — UserProjectionEraseSource (anonymize, keep anchor) | The reference "wired day-one" store. |
audit_events | actor / action (subject) | Yes — ProductApiDsarSource (audit_event, SSO-2515) | No — audit (retained) | Was gap G6. Tenant-scoped by tenant_id + actor_sub; the append-only audit trail is retained (not erased). |
siem_event | actor / subject in event payload | No | No — retained / SIEM sink | |
federated_identity | issuer, upstream_sub, platform_subject | Yes — ProductApiDsarSource (federated_identity, SSO-2515) | Yes — FederatedIdentityEraseSource (delete, SSO-2514) | Subject to IdP link (pseudonymous): exported by subject (SSO-2515), and the mapping is hard-deleted on erasure (severing the link forgets the subject; a later login mints a fresh subject). Was gap G8. |
directory_user | email, first/last name, username, external_id | Yes — ProductApiDsarSource (directory_user, SSO-2515) | Yes — DirectoryUserEraseSource (anonymize, keep anchor, SSO-2514) | SCIM-synced directory PII: exported tenant-scoped by tenant_id + subject (SSO-2515); on erasure NULLs email/username/first_name/last_name/raw_attributes, keeps the subject/external_id anchor (deprovision-first: the mirror row is never hard-deleted). Was gap G8. |
directory_scim_bearer | SCIM bearer credential | No | No | Credential material. |
organization_member | subject (opaque sub) | No | No — kept by design (membership-graph anchor) | Per erase ADR the subject anchor is intentionally retained (not PII we control). |
organization_invitation, member_invitation, guest_invitation | invitee email | No | No — TTL / manual revoke | Email PII in invitations. |
holder_email_index | email_hash (BYTEA) | No | No | Hashed; VC residue post vc-broker extraction — confirm dead. |
holder_notification_pref | holder email / preferences | No | No | VC residue — confirm dead. |
dsar_request | subject_identifier, subject_sub | n/a (the DSAR state itself) | expired by retention | Holds the very identifier the request is about. |
dsar_audit | subject references in detail | audit (retained) | Accountability. | |
compliance_report, audit_export_request | subject rows inside generated reports | No | retention |
Gaps to file
Each row below is a store (or wiring) that is not reached by DSAR export and/or subject erasure and that a human should file as a Jira follow-up under epic SSO-2497 (linked to SSO-2505). Ordered by severity.
| # | Gap | Missing coverage | Suggested owner | Severity |
|---|---|---|---|---|
| G1 | DsarOrchestrator.eraseSubject (the subject-wide erase fan-out) has no REST trigger and only one DsarEraseSource bean, so it reaches only the product-api app_user projection, not identity-service or the hub.DsarEraseSource beans) + the hub subject-purge; the trigger is the conditional-erasure REST/S2S surface. | erasure (wiring + reach) | product-api / DSAR | |
| G2 | UserErasureService and the product-api erase fan-out are disconnected — there is no single "erase this subject everywhere" call.ErasureRequestOrchestrator hard-erase is the single coherent "erase everywhere" path: identity + product-api fan-out + hub subject-purge, each leg audited + per-leg isolated. | erasure (orchestration) | identity + product-api | |
| G3 | identity.tou_acceptance has a NOT NULL non-cascade FK to users and is deliberately never deleted, so erasing any ToU-accepting user raises an FK violation and rolls back the whole erase; its ip_address / user_agent PII is also retained indefinitely with no anonymization path.UserErasureService now deletes the user's tou_acceptance rows before the users delete (Art. 17 exception to V9 immutability). | erasure (FK-blocking + retained PII) | identity | |
| G4 | identity.access_review_campaign.created_by and access_review_decision.member_user_id / reviewer_user_id are NOT NULL / non-cascade FKs not handled by UserErasureService — erasure of a campaign creator or reviewer is FK-blocked.created_by made nullable in V89, nulled; reviewer_user_id nulled), and the erased member's own decision rows deleted. | erasure (FK-blocking) | identity | |
| G5 | identity.oath_hardware_token.assigned_user_id is a non-cascade FK not handled by UserErasureService — an assigned token blocks / orphans the users delete.users delete. | erasure (FK-blocking) | identity | |
| G6 | ProductApiDsarSource is an empty placeholder — audit_events subject rows are never emitted into the Art. 15 bundle (kdoc references SSO-728).ProductApiDsarSource now exports audit_events subject rows. | DSAR export | product-api | |
| G7 | users profile — sign-in history, known devices, MFA/passkey inventory, consent/ToU, and org memberships are absent from the bundle even though the data exists. | DSAR export | identity | |
| G8 | directory_user (SCIM directory: email, names) and federated_identity (subject to IdP link) are reached by neither export nor erase.ProductApiDsarSource); erasure SSO-2514 (DirectoryUserEraseSource anonymize + FederatedIdentityEraseSource delete). | DSAR export + erasure | product-api | |
| G9 | consent_record, oauth2_authorization_consent, and user_workspace_mapping are subject-linked but absent from the Art. 15 bundle and not purged on subject erasure.HubDsarSource); subject-erasure SSO-2514 (HubSubjectErasureService delete-by-subject over the /internal/subject-erasure S2S seam); client-centric axis SSO-2501. | DSAR export + subject erasure | hub | |
| G10 | Email-keyed short-lived tables (identity.magic_link_tokens, identity.invitation_token, product-api *_invitation) carry email/IP and are TTL-swept only — not subject-erasable or exportable. | DSAR export + erasure | identity + product-api | Low |
| G11 | product-api holder_email_index / holder_notification_pref appear to be VC residue after the thoryn-vc-broker extraction — confirm dead and drop, or bring under coverage. | cleanup | product-api | Low |
Intended CI guard (deferred — not built)
Deliverable 3 of SSO-2505 proposed a scripts/check-pii-source-coverage.sh guard that would fail
when a new migration adds a PII-bearing table with no matching DsarSource. It is deliberately
deferred.
The DsarSource beans query repositories / entities, not table names, and the fan-out is
subject-keyed, not table-enumerated — a source runs one query per subject and emits a fragment;
it never names the individual tables it (or its retention siblings) cover. So a text heuristic that
looks for a table name inside a DsarSource would flag almost every PII table, including the
covered ones (e.g. sign_in_history is erasure-covered by cascade but appears in no source file).
A guard with that false-positive rate is worse than none — it trains reviewers to suppress it. The
honest coverage signal is this human-maintained matrix plus the change-trigger rule: this page's
sources: lists the three migration directories, so any new migration triggers a review of this
page. If a future refactor makes coverage table-explicit (e.g. a registry enumerating covered
tables), a guard becomes tractable — file it as a follow-up under SSO-2497 at that point.