Skip to main content

GDPR & Privacy

Heimdall implements the core data-subject rights required by the GDPR/DSGVO: the right of access and data portability (Article 15 / Article 20) via a self-service data export, the right to erasure (Article 17) via a grace-period account-deletion flow, and data-minimisation controls via privacy mode.

Overview

FeatureMechanismSurface
Data exportDownloadable ZIP (JSON + PDF + README)REST GET /v1/user/export
Export historyPaginated audit trail of past exportsREST GET /v1/user/export/logs
Account deletion7-day grace period, then anonymisationGraphQL requestAccountDeletion / cancelAccountDeletion
Deletion processingScheduler purges accounts past their grace periodheimdall-scheduler (see Scheduler)
Privacy modeBlurs sensitive fields (email, account ID) in the UIGraphQL updatePrivacyMode

The export and anonymisation logic lives in crates/heimdall-rest/src/export/ and crates/heimdall-graphql/src/mutations/. Both the main PostgreSQL database and the TimescaleDB audit store are covered (see Databases & Caching).

Data Export

Endpoints

MethodPathAuthDescription
GET/v1/user/exportBearer (user)Download all of the caller's data as a ZIP archive
GET/v1/user/export/logsBearer (user)Paginated history of the caller's previous exports

Both endpoints operate on the authenticated user only — they read the user ID from the auth context and reject requests that are not authenticated as a user.

Archive format

GET /v1/user/export returns application/zip with Content-Disposition: attachment; filename="data-export-<YYYY-MM-DD>.zip". The archive always contains three files:

FilePurpose
data.jsonMachine-readable JSON export (Article 20 portability)
report.pdfHuman-readable PDF report
README.txtExplanation of the included files

The PDF and README are localised using the user's preferred_locale, falling back to the configured default locale.

What is included

collect_user_data gathers the following, in parallel, from both databases:

  • User profileid, username, privacy_mode, created_at, last_login_at, preferred_locale, avatars_enabled
  • Connected platform accounts — platform, username, email, avatar, primary/OAuth flags, connection date
  • Connected OAuth apps — apps the user has authorised (consents), with granted scopes
  • Owned OAuth apps — OAuth clients the user created
  • API keys — non-system keys only (is_system = false), with secrets redacted (only the key prefix is included)
  • Roles — the user's role assignments
  • Two-factor status
  • Account deletion status
  • Export logs — the user's own export history
  • Sessions
  • Audit events — pulled from TimescaleDB
  • Discord memberships

Export logging & audit

Every export is recorded twice:

  1. A row in the DataExportLog table (export_format, file_size_bytes, locale, IP, user agent). Logging failures are non-fatal — they are logged as a warning and the download still succeeds.
  2. An audit event (data_exported) recorded with the export format and the categories ["profile", "sessions", "audit_logs", "connections"].

GET /v1/user/export/logs exposes the DataExportLog history. Pagination is controlled by limit (default 50, clamped to 1–100) and offset (default 0, floored at 0). The response is { logs, total, hasMore }. This log gives users a verifiable record of when and from where their data was exported. See the DataExportLog schema in Authentication & Authorization.

Account Deletion Lifecycle

Deletion is not immediate. It follows a request → grace period → purge → soft-delete flow so users can cancel before any data is destroyed.

requestAccountDeletion ──> ScheduledDeletion row (delete_at = now + 7 days)

(within grace period: cancelAccountDeletion removes the row)

scheduler (every 15 min) finds delete_at <= NOW() and deleted_at IS NULL

anonymize_user(...) ── purges PII, sets User.deleted_at

broadcasts AccountDeleted over WebSocket (force logout)

1. Request

GraphQL mutation requestAccountDeletion(userId) (userId optional — defaults to the authenticated user; a non-system / non-super-admin caller may only target their own account). It:

  • Rejects the request if the account is already deleted or already scheduled.
  • Inserts a ScheduledDeletion row with delete_at = now + 7 days (DELETION_GRACE_PERIOD_DAYS = 7), initiated_by = user, reason = "User request".
  • Logs a deletion_scheduled audit event.
  • Returns { isScheduledForDeletion: true, scheduledDeletionAt }.

2. Grace period & cancellation

During the 7-day window the account remains fully functional. The user can abort with GraphQL mutation cancelAccountDeletion(userId), which deletes the ScheduledDeletion row and logs a deletion_cancelled audit event. Cancellation only works while the account has not yet been anonymised.

Administrators have equivalent REST endpoints:

MethodPathDescription
GET/v1/admin/users/{id}/deletion-statusWhether the user is scheduled and the delete_at time
POST/v1/admin/users/{id}/cancel-deletionRemove a scheduled deletion
POST/v1/admin/users/{id}/force-deleteDelete immediately, bypassing the grace period

3. Scheduler purge

The heimdall-scheduler crate runs process_scheduled_deletions on a cron schedule (default every 15 minutes, plus an initial check on startup). It selects ScheduledDeletion joined to User where delete_at <= NOW() and User.deleted_at IS NULL, ordered by delete_at, and for each user calls anonymize_user. Failures are logged and do not block other users. See Scheduler for the job configuration.

4. Anonymisation & soft-delete

anonymize_user performs the actual erasure:

  1. Broadcasts an AccountDeleted WebSocket message first (before sessions are removed) to force-logout active sessions, then waits briefly for delivery.
  2. In a single transaction on the main database:
    • Anonymises the User row — username becomes deleted_<6 random chars>, primary_platform_account_id and last_login_platform_account_id are nulled, and deleted_at = NOW() is set (the soft-delete marker).
    • Hard-deletes PII-bearing rows: PlatformAccount, Session, ApiKey, OAuthAccessToken, OAuthRefreshToken, OAuthConsent, OAuthAuthorizationCode, user-owned OAuthClient, UserRole, TwoFactorBackupCode, UserTwoFactor, PendingTwoFactorSetup, and DataExportLog.
  3. In TimescaleDB (separate database): nulls AuditEvent.ip_address for the user and clears metadata for sensitive event types (user_updated, password_changed, password_reset_requested, account_linked, account_unlinked). The audit rows themselves are retained (with the anonymised user reference) for integrity.
  4. Invalidates the user's permission cache in Redis.

The User row is soft-deleted, not removed — it stays with an anonymised username and a non-null deleted_at, so foreign-key references and audit history remain intact. The ScheduledDeletion row is also kept for history; the scheduler's deleted_at IS NULL filter prevents reprocessing.

Privacy Mode

User.privacy_mode (BOOLEAN NOT NULL DEFAULT false) is a data-minimisation control. Per the schema comment: "When enabled, sensitive data like email and account ID are blurred in the UI."

Users toggle it with GraphQL mutation updatePrivacyMode(userId, privacyMode) (userId optional, defaults to the caller; ownership enforced for non-system / non-super-admin callers). The mutation updates User.privacy_mode, logs a user_updated audit event listing the privacy_mode field, and returns { success, privacyMode }. The current value is also included in the user's data export.

For browser-side consent and cookie preferences, see the Cookie Consent library.

Schema Reference

ScheduledDeletion

CREATE TABLE "ScheduledDeletion" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES "User"(id) ON DELETE CASCADE,
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
delete_at TIMESTAMP WITH TIME ZONE NOT NULL,
initiated_by UUID REFERENCES "User"(id) ON DELETE SET NULL,
reason TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

The UNIQUE constraint on user_id enforces at most one pending deletion per user.

DataExportLog

CREATE TABLE "DataExportLog" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
export_format VARCHAR(50) NOT NULL DEFAULT 'zip',
file_size_bytes BIGINT,
locale VARCHAR(10),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

User privacy & deletion columns

privacy_mode BOOLEAN NOT NULL DEFAULT false, -- blur sensitive fields in UI
deleted_at TIMESTAMPTZ -- NULL = active; set = soft-deleted