Skip to content

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) — DsarOrchestrator collects every DsarSource bean 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 via HttpDsarSource — the users profile plus, as of SSO-2515, sign-in history / known devices / sessions / MFA + passkey enrolment / ToU acceptances / org memberships), UserProjectionDsarSource (product-api app_user), and ProductApiDsarSource (product-api — as of SSO-2515 audit_events / directory_user / federated_identity; was a placeholder that returned empty). Each auxiliary store is emitted as a record tagged with a record_kind discriminator 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 retained erasure_audit and per-leg isolated (a failing leg is recorded, not a lost erasure):
    • identity UserErasureService (the authoritative profile/credential destroy): explicit deletes plus FK cascade off users;
    • product-api — the DsarEraseSource erase fan-out over three beans now: UserProjectionEraseSource (anonymize app_user), DirectoryUserEraseSource (anonymize directory_user), FederatedIdentityEraseSource (delete federated_identity), keyed by the platform subject the orchestrator resolves from the identity user id (via the pairwise sub → federated_identity);
    • hubHubSubjectErasureService (over the POST /internal/subject-erasure S2S seam) delete-by-subject over consent_record, oauth2_authorization_consent, user_workspace_mapping (distinct from SSO-2501's delete-by-CLIENT).

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 the ON DELETE CASCADE FK to identity.users when UserErasureService deletes the users row.
  • retention-only — the store is not subject-triggered at all; it is aged out by a time-based retention/TTL sweep (DataRetentionService, TenantAuditRetentionService, token expires_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.

StoreHolds (PII)DSAR-covered?Subject-erasure-covered?Notes / gap
oauth2_authorizationprincipal_name = sub, scopes, grant typeYesHubDsarSource (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_attributetoken bytes + serialized claim values (may embed email / name)NoNo — retention-onlyBundle surfaces the authorization row, not token bytes (intentional). Claim values can carry PII.
oauth2_refresh_token, oauth2_refresh_token_attributerefresh token tied to subjectNoNo — retention + SSO-64 revoke
oauth2_authorization_consentprincipal to client consent authoritiesYesHubDsarSource (authorization_consent, SSO-2515)YesHubSubjectErasureService 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_requestpending authorize request (may carry login_hint)NoNo — retention-only
oauth2_device_authorizationdevice grant tied to subjectNoNo — retention-only
consent_recorduser_id, client_id, scope, grant/withdraw timestampsYesHubDsarSource (consent_record, SSO-2515)YesHubSubjectErasureService 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_sessionprincipal_nameNoNo — retention sweep (short-lived, ≤120s)HubDsarSource kdoc says these are gathered by the retention sweep — that is cleanup, not export.
login_sessionlogin_hint, client_id, redirect_uriNoNo — TTL expires_atShort-lived pre-auth state.
account_link_intentlink_subject = subNoNo — TTL / single-use consumed_atShort-lived capability record.
user_workspace_mappingsub to tenant/workspaceYesHubDsarSource (workspace_mapping, SSO-2515)YesHubSubjectErasureService 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_auditpurge counts (no raw subject)n/aaudit (retained)Accountability.
security_incidentmay reference a subject in incident detailNoaudit (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.

StoreHolds (PII)DSAR-covered?Subject-erasure-covered?Notes / gap
usersemail, given/family name, locale, status, external_id, password_hashYesIdentityServiceDsarSource (user_profile)Yes — deletedExport omits password_hash (correct). SSO-2515 added the auxiliary stores below to the same fragment.
mfa_backup_codesMFA backup codesYesIdentityServiceDsarSource (mfa_backup_code — enrolment PRESENCE only, never the hash, SSO-2515)Yes — explicit deleteWas gap G7.
passkey_credentialsWebAuthn credentialsYesIdentityServiceDsarSource (passkey_credential — nickname + timestamps, never key bytes, SSO-2515)Yes — explicit deleteWas gap G7.
organisation_membershipsuser_id, org, roleYesIdentityServiceDsarSource (organisation_membership, SSO-2515)Yes — explicit delete (non-cascade FK)Was gap G7.
user_sessionssession, device/IP contextYesIdentityServiceDsarSource (user_session — IP / UA / country, never the device-marker nonce, SSO-2515)Yes — explicit revoke + cascadeWas gap G7.
account_recovery_codesrecovery codesNoYes — cascadeCredential material — export deliberately omits (like MFA/passkey secrets).
email_verification_tokensemail verification tokenNoYes — cascade
password_reset_tokensreset tokenNoYes — cascade
account_unlock_tokenunlock tokenNoYes — cascade
sign_in_historyIP, device, timestampsYesIdentityServiceDsarSource (sign_in_history, SSO-2515)Yes — cascadeWas gap G7.
sign_in_attemptemail / IP attempt logNoYes — cascadePre-auth attempt log (nullable user_id); not part of the SSO-2515 by-user export.
known_devicedevice fingerprint, IPYesIdentityServiceDsarSource (known_device — label + coarse fingerprint, SSO-2515)Yes — cascadeWas gap G7.
step_up_challengechallenge stateNoYes — cascade
first_factor_tokenfirst-factor tokenNoYes — cascade
user_notification_lognotification historyNoYes — cascade
user_identity_linkexternal IdP linkNoYes — cascade
scim_outbound_remote_userremote SCIM idNoYes — cascade
wasnt_me_tokenssecurity-alert tokenNoYes — cascade
oath_hardware_tokenassigned_user_id (nullable, non-cascade FK)NoYes — unassigned (SSO-2513)Shared inventory: the token is returned to the pool (assigned_user_id→NULL, statusUNASSIGNED, assigned_at→NULL) before the users delete. Was gap G5.
tou_acceptanceuser_id, ip_address, user_agent (NOT NULL non-cascade FK)YesIdentityServiceDsarSource (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_campaigncreated_by (nullable non-cascade FK as of V89)NoYes — 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_decisionmember_user_id (NOT NULL), reviewer_user_id (nullable non-cascade FK)NoYes — 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_tokensemail, requested_ip (email-keyed, no FK)NoNo — TTL sweep onlyNot subject-erasable (no user_id); email/IP linger until TTL.
invitation_tokenemail, inviter / acceptee user_id (no cascade)NoNoEmail PII; not erased with the subject.
password_reset_request_auditrequested_ip (no user linkage)NoNo — retentionIP retained; deliberately decoupled from users.
erasure_receiptuser_id_hash (SHA-256, not raw)n/areceipt (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).

StoreHolds (PII)DSAR-covered?Subject-erasure-covered?Notes / gap
app_useremail, first/last/display name, profile picture, locale, metadata, subject, external_idYesUserProjectionDsarSource (user_projection)YesUserProjectionEraseSource (anonymize, keep anchor)The reference "wired day-one" store.
audit_eventsactor / action (subject)YesProductApiDsarSource (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_eventactor / subject in event payloadNoNo — retained / SIEM sink
federated_identityissuer, upstream_sub, platform_subjectYesProductApiDsarSource (federated_identity, SSO-2515)YesFederatedIdentityEraseSource (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_useremail, first/last name, username, external_idYesProductApiDsarSource (directory_user, SSO-2515)YesDirectoryUserEraseSource (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_bearerSCIM bearer credentialNoNoCredential material.
organization_membersubject (opaque sub)NoNo — kept by design (membership-graph anchor)Per erase ADR the subject anchor is intentionally retained (not PII we control).
organization_invitation, member_invitation, guest_invitationinvitee emailNoNo — TTL / manual revokeEmail PII in invitations.
holder_email_indexemail_hash (BYTEA)NoNoHashed; VC residue post vc-broker extraction — confirm dead.
holder_notification_prefholder email / preferencesNoNoVC residue — confirm dead.
dsar_requestsubject_identifier, subject_subn/a (the DSAR state itself)expired by retentionHolds the very identifier the request is about.
dsar_auditsubject references in detailaudit (retained)Accountability.
compliance_report, audit_export_requestsubject rows inside generated reportsNoretention

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.

#GapMissing coverageSuggested ownerSeverity
G1DsarOrchestrator.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. Resolved (SSO-2514) — the SSO-2509 two-phase hard-erase now drives the erase fan-out (three DsarEraseSource beans) + the hub subject-purge; the trigger is the conditional-erasure REST/S2S surface.erasure (wiring + reach)product-api / DSARHigh Done
G2Identity UserErasureService and the product-api erase fan-out are disconnected — there is no single "erase this subject everywhere" call. Resolved (SSO-2514) — the 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-apiHigh Done
G3identity.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. Resolved (SSO-2513)UserErasureService now deletes the user's tou_acceptance rows before the users delete (Art. 17 exception to V9 immutability).erasure (FK-blocking + retained PII)identityHigh Done
G4identity.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. Resolved (SSO-2513) — creator/reviewer references anonymised (created_by made nullable in V89, nulled; reviewer_user_id nulled), and the erased member's own decision rows deleted.erasure (FK-blocking)identityMedium Done
G5identity.oath_hardware_token.assigned_user_id is a non-cascade FK not handled by UserErasureService — an assigned token blocks / orphans the users delete. Resolved (SSO-2513) — the token is unassigned (returned to inventory) before the users delete.erasure (FK-blocking)identityMedium Done
G6product-api ProductApiDsarSource is an empty placeholderaudit_events subject rows are never emitted into the Art. 15 bundle (kdoc references SSO-728). Resolved (SSO-2515)ProductApiDsarSource now exports audit_events subject rows.DSAR exportproduct-apiMedium Done
G7Identity Art. 15 export surfaces only the 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. Resolved (SSO-2515) — the identity auxiliary stores are now emitted into the bundle (credential material excluded).DSAR exportidentityMedium Done
G8product-api directory_user (SCIM directory: email, names) and federated_identity (subject to IdP link) are reached by neither export nor erase. Resolved — export SSO-2515 (ProductApiDsarSource); erasure SSO-2514 (DirectoryUserEraseSource anonymize + FederatedIdentityEraseSource delete).DSAR export + erasureproduct-apiMedium Done
G9Hub 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. Resolved — export SSO-2515 (HubDsarSource); subject-erasure SSO-2514 (HubSubjectErasureService delete-by-subject over the /internal/subject-erasure S2S seam); client-centric axis SSO-2501.DSAR export + subject erasurehubMedium Done
G10Email-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 + erasureidentity + product-apiLow
G11product-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.cleanupproduct-apiLow

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.