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
| Feature | Mechanism | Surface |
|---|---|---|
| Data export | Downloadable ZIP (JSON + PDF + README) | REST GET /v1/user/export |
| Export history | Paginated audit trail of past exports | REST GET /v1/user/export/logs |
| Account deletion | 7-day grace period, then anonymisation | GraphQL requestAccountDeletion / cancelAccountDeletion |
| Deletion processing | Scheduler purges accounts past their grace period | heimdall-scheduler (see Scheduler) |
| Privacy mode | Blurs sensitive fields (email, account ID) in the UI | GraphQL 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
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/user/export | Bearer (user) | Download all of the caller's data as a ZIP archive |
GET | /v1/user/export/logs | Bearer (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:
| File | Purpose |
|---|---|
data.json | Machine-readable JSON export (Article 20 portability) |
report.pdf | Human-readable PDF report |
README.txt | Explanation 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 profile —
id,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:
- A row in the
DataExportLogtable (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. - 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
ScheduledDeletionrow withdelete_at = now + 7 days(DELETION_GRACE_PERIOD_DAYS = 7),initiated_by = user,reason = "User request". - Logs a
deletion_scheduledaudit 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:
| Method | Path | Description |
|---|---|---|
GET | /v1/admin/users/{id}/deletion-status | Whether the user is scheduled and the delete_at time |
POST | /v1/admin/users/{id}/cancel-deletion | Remove a scheduled deletion |
POST | /v1/admin/users/{id}/force-delete | Delete 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:
- Broadcasts an
AccountDeletedWebSocket message first (before sessions are removed) to force-logout active sessions, then waits briefly for delivery. - In a single transaction on the main database:
- Anonymises the
Userrow — username becomesdeleted_<6 random chars>,primary_platform_account_idandlast_login_platform_account_idare nulled, anddeleted_at = NOW()is set (the soft-delete marker). - Hard-deletes PII-bearing rows:
PlatformAccount,Session,ApiKey,OAuthAccessToken,OAuthRefreshToken,OAuthConsent,OAuthAuthorizationCode, user-ownedOAuthClient,UserRole,TwoFactorBackupCode,UserTwoFactor,PendingTwoFactorSetup, andDataExportLog.
- Anonymises the
- In TimescaleDB (separate database): nulls
AuditEvent.ip_addressfor the user and clearsmetadatafor 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. - 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
Related Pages
- Scheduler — the background job that processes scheduled deletions
- Databases & Caching — dual PostgreSQL/TimescaleDB architecture and Redis cache
- Authentication & Authorization — sessions, roles, and the
DataExportLogschema - Cookie Consent library — browser-side consent management