Product documentation
Privacy & compliance
Where personal data lives, GDPR access (Art. 15) and erasure (Art. 17), the multi-source DSAR fan-out, per-tenant retention, and NIS2 incident logging.
Privacy & compliance
This page covers the data-subject and regulatory workflows a compliance reviewer will ask about: where personal data lives, GDPR right-of-access and right-to-erasure, subject-access request (DSAR) handling, retention, and NIS2 incident logging. All of it is per-tenant.
Where personal data lives
Because the hub is a pure identity broker, personal data — profiles, credentials, organisation records — lives in the identity member that owns it (Thoryn's Identity Service, or a customer's own IdP), not in the hub. The hub holds only protocol state: OAuth authorizations and tokens keyed by subject. This shapes the compliance model — right-of-access and right-to-erasure of the identity are implemented in the identity member, while the hub contributes only its token/authorization records and ages them out via retention.
GDPR right of access (Article 15)
There are two complementary access mechanisms.
Identity-member export
Thoryn's Identity Service serves a data subject's own record as JSON:
- Self-service:
GET /account/export - Admin:
GET /admin/users/{id}/export
The export covers the personal data the member holds about the user (profile, authentication factors, and related records).
Source: servers/federation-members/identity-service/.../export/ (AccountExportController,
AdminExportController, UserDataExportService).
Multi-source DSAR fan-out
The customer plane provides a subject-access request that aggregates across every data-holding
service. DsarOrchestrator (product-api) drives the GDPR Article 15 fan-out:
POST /api/v1/dsarpersists the request and returns202 Accepted; the work runs asynchronously.- The orchestrator calls every
DsarSourcebean — the hub, the identity service, and product-api each contribute a fragment — and aggregates them into a JSON-Lines bundle. - A Redis-backed lock keyed on the request id ensures a multi-pod deployment cannot process the same request twice; the lock TTL exceeds the pipeline's bounded ceiling, so a failing pod releases it automatically.
- The bundle's deterministic digest is signed with the tenant's own key (
DsarBundleSigner) and written to the configured store; the request moves toREADYwithexpires_at = ready_at + 30 days, and the finalisation is audit-logged. In the production mode (oauthy.dsar.signer.mode=vault,VaultDsarBundleSigner) each tenant signs with its own Vault Transit keydsar-bundle-<tenantId>(ES256, minted on demand), the private key never enters the service JVM, and the JWSkidcarries the key version (v<n>:<key>) so a downloaded bundle stays verifiable across key rotations — so a compromise of one tenant's key cannot forge another tenant's bundle. A non-Vault deployment may configure a single stable ES256 key (oauthy.dsar.signer.private-key-pkcs8) as a multi-replica-safe interim. If signing is left unconfigured the service refuses to sign rather than emit a bundle that cannot be verified across replicas or after a restart (no ephemeral-key fallback). - Failure is per-source and isolated: a source that throws is recorded (
dsar.failed) and the bundle still assembles and reachesREADYwith that source's slot carrying the failure reason. Operators see the partial success in the audit log — a single failing source never denies the subject the rest of their data.
The completed bundle is retrieved at GET /api/v1/dsar/{requestId} (status) and
GET /api/v1/dsar/{requestId}/bundle. The shared fan-out plumbing lives in core/lib/dsar.
Source: DsarOrchestrator.kt, DsarController.kt, DsarBundleSigner.kt (product-api);
core/lib/dsar/; hub HubDsarSource, identity IdentityServiceDsarSource.
Records of processing (Article 30)
The customer plane generates a tenant's record of processing activities on demand:
GET /api/v1/compliance/records-of-processing(scopetenant:compliance.read) returns the tenant's Art. 30 record, generated from platform config and derivable facts — it owns no new personal-data store. Processing purposes and personal-data categories derive from the platform's OAuth/OIDC scope catalogue; the retention window derives from the per-tenant retention service; the sub-processor list is deployment configuration. Purpose-based consent enrichment (ISO/IEC TS 27560) is a follow-up under SSO-2500.
The record is a grounded first draft the controller reviews and completes — the tenant is the controller and Thoryn provides the tooling. That legal-role split, and how each GDPR obligation divides between the managed and self-managed delivery models, is set out in the GDPR shared-responsibility matrix.
Source: servers/product-api/.../compliance/recordsofprocessing/ (RecordsOfProcessingController,
RecordsOfProcessingService, RecordsOfProcessingProperties); ADR
2026-08-11-gdpr-trust-center-and-consent-records.md.
GDPR right to erasure (Article 17)
Erasure of the identity is implemented in the Identity Service, which owns the user record. There are two entry points, and — crucially — both honour Article 17(3) obligations (an outstanding payment, an active legal claim …) rather than deleting on demand:
- Self-service:
DELETE /account/erase(the authenticated user, gated by the current password- an
X-Confirm-Eraseheader).
- an
- Customer-plane / admin:
POST /api/v1/erasure-requests(product-api,tenant:compliance.write).
Both open a two-phase conditional-erasure request driven by product-api's orchestrator: deactivate → hold-check → hard-erase. The subject is deactivated first (sessions/tokens revoked, processing stopped) without destroying data; product-api then consults the registered hold providers (e.g. a tenant-admin objection carrying a reason + legal basis). Only once no hold stands and the grace window has elapsed is the irreversible hard-erase performed. A hold still standing at the maximum window flips the request to DEFERRED and escalates — never a silent drop, never an auto-erase. With no hold provider configured the request still reaches the same end state after the grace window (backward-compatible). Self-service can never bypass this hold-check.
The one immediate, unconditional path is the operator break-glass DELETE /admin/users/{id}/erase
(SCOPE_admin), which skips the hold-check and records the bypass (breakGlass=true) on the audit
row for accountability.
A third entry point — the self-service scheduled deletion (a user schedules their own account for
deletion after a 7-day, cancellable grace window; the row is flipped to PENDING_DELETION with a
scheduled_purge_at) — is swept by the Identity Service's PendingDeletionPurgeJob. That job now
consults the same conditional-erasure hold state before it erases an overdue row: it reads
product-api's GET /internal/erasure-requests/hold-state for the subject and defers (leaves the
row pending, re-checks next sweep) when a hold stands. The check is fail-closed — an inconclusive
result (product-api unreachable or errored) defers too, so a transient outage never force-erases a
subject under an Article 17(3) hold. A subject with no standing hold is erased exactly as before.
The underlying irreversible primitive is unchanged: the service
- deletes the user's rows across the owning tables (profile, MFA backup codes, passkey credentials, …);
- severs active sessions first, before deleting the user row, so the session-revocation cascade can still resolve the user's tenant and propagate a refresh-token revocation to the hub — otherwise the hub would keep tokens until natural expiry;
- stores an erasure receipt as proof of completion. The receipt records a hash of the user id (not the id itself), the erasure timestamp, the tables cleared, and who requested it — so the fact of erasure is provable without retaining the identifier that was erased;
- emits a
user_deletedlifecycle audit row so the customer-plane audit stream sees the erasure alongside the rest of the account lifecycle.
Source: servers/federation-members/identity-service/.../gdpr/ (AccountErasureController,
AdminErasureController, UserErasureService, ProductApiErasureClient, ErasureReceiptEntity);
servers/federation-members/identity-service/.../selfservice/PendingDeletionPurgeJob;
product-api .../erasure/ (ErasureRequestController, ErasureRequestInternalController,
ErasureRequestService, ErasureRequestOrchestrator).
Retention
Personal data is aged out on configurable schedules, per tenant:
- Hub protocol data.
DataRetentionServicepurges by data-type category —AUTHORIZATION(theoauth2_authorizationrecords) andTOKEN(oauth2_authorization_token) — on a per-tenant retention policy (retention_days, minimum 1 day). Expired authorizations and tokens are purged so the hub does not accumulate protocol state indefinitely. - Customer-plane audit retention.
TenantAuditRetentionService(product-api) holds a per-tenant audit-retention policy, andComplianceRetentionJobapplies it. Retention here is a tenant-configurable window, because different tenants carry different regulatory obligations. - Sign-in attempt retention. The Identity Service ages out sign-in attempt records on its own
schedule (
SignInAttemptRetentionJob).
Retention windows are configurable rather than hard-coded, so a self-managed operator or a managed-service tenant can set the window their regulatory regime requires — including a multi-year horizon where the verifiable audit trail must remain verifiable against retired signing keys.
Surfaced in one place. A tenant admin does not have to reason about three services to
answer "how long is my data kept, and when is the next purge". GET /api/v1/compliance/retention
(the Trust Center, tenant:compliance.read) returns a domains array with one entry per
retention domain — protocol, audit, signin — each carrying the effective window, the purge
schedule, the derived next-purge cut-off (rows older than that instant are eligible for the
next sweep), who owns and where each is managed, and — for the customer-configurable audit domain
— the platform floor/ceiling/default. It is a read-only aggregation: the audit window is read
live from TenantAuditRetentionService (product-api owns it and the value is per-tenant), while
the protocol (hub) and sign-in (identity) windows are surfaced from product-api's
product.retention-overview.* config so the value is set once and stays honest rather than
duplicated authoritatively. Every response is scoped to the caller's tnt claim, so a tenant
only ever sees its own audit window.
Source: DataRetentionService.kt (hub); TenantAuditRetentionService.kt,
ComplianceRetentionJob.kt, TrustCenterService.kt (product-api);
SignInAttemptRetentionJob.kt (identity-service).
NIS2 incident logging
Alongside the event-level audit trail, the customer plane keeps a security-incident record
for the significant occurrences that may require reporting to a national authority under NIS2
(Directive (EU) 2022/2555). SecurityIncident (product-api) is a per-tenant record carrying the
severity, category, title, description, detection and resolution timestamps, and a
reportedToAuthority flag plus operator notes — the fields a reporting workflow needs to
track an incident from detection through authority notification to resolution.
The Identity Service additionally sends security-incident notification emails
(SecurityIncidentEmailSender) for account-security events.
Incidents are deliberately a higher level than the audit log: the audit trail records every event; incidents record the security-significant subset with a reporting lifecycle attached.
Source: servers/product-api/.../compliance/incident/ (SecurityIncident,
SecurityIncidentRepository); identity-service SecurityIncidentEmailSender.
Tamper-evident audit trail
Every configuration change and security-relevant action is recorded in the per-tenant, hash-chained, cryptographically-signed audit trail described under signing & cryptographic custody. For a compliance reviewer the key property is that the trail is tamper-evident: an altered, reordered, or (contiguously) removed row is detected on verification, and the verification can be performed years later against retired signing keys.