Skip to content

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/dsar persists the request and returns 202 Accepted; the work runs asynchronously.
  • The orchestrator calls every DsarSource bean — 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 to READY with expires_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 key dsar-bundle-<tenantId> (ES256, minted on demand), the private key never enters the service JVM, and the JWS kid carries 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 reaches READY with 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 (scope tenant: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-Erase header).
  • 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_deleted lifecycle 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. DataRetentionService purges by data-type category — AUTHORIZATION (the oauth2_authorization records) and TOKEN (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, and ComplianceRetentionJob applies 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.