Privacy & GDPR
The privacy area bundles all data-subject rights and technical data minimization in one place: data export (Art. 15/20), anonymization (Art. 17), time-based retention and public transparency (privacy policy, imprint). Endpoints under /api/privacy; public parts under /api/public.
Endpoints
| Method | Endpoint | Permission | Response | Description |
|---|---|---|---|---|
GET | /api/privacy/export/me | any sign-in (own data only) | 200, document | Self-service export, at most 1 per 24 h |
GET | /api/privacy/export/:userId | users.dataExport | 200, document | Export for any person, no quota |
GET | /api/privacy/users/:id/erasure-check | users.erase | 200, check result | Pre-check: blockers, hold and archive status |
POST | /api/privacy/users/:id/erase | users.erase | 204, no content | Anonymization (irreversible) |
POST | /api/privacy/users/:id/erasure-hold | users.erase | 204, no content | Set/clear erasure hold |
GET | /api/privacy/erasure-backlog | users.erase ∨ audit.enterpriseView | { data, pagination } | Erasure backlog as a list: overdue archived accounts and stale email contacts, with search, filters and pages |
GET | /api/privacy/erasure-backlog/stats | users.erase ∨ audit.enterpriseView | { stats, retention } | Key figures of the same backlog (unfiltered) and the periods in force |
GET | /api/public/privacy-config | none | { retention } | Live periods for the privacy policy |
GET | /api/public/imprint | none | 200, imprint data | Imprint data (works before login) |
Every endpoint under /api/privacy requires a signed-in person; an API key is rejected (403). The reason: data-subject rights are decisions that must stay attributable to a human — the audit entry names them. The check and backlog responses come without a wrapper, anonymization and erasure hold answer 204 with no content; an unknown person is 404.
Data Export (Art. 15/20)
The export gathers all of a person's personal data into a machine-readable JSON (Art. 20-ready) — master data, cases (tickets as customer/participant, approvals, absences, handovers, assets/licenses), communication (emails, web notifications, Teams reference) and metadata (activity/audit entries as actor, where already-redacted fields stay redacted). The response consists of a cover sheet (cover) and the data (data).
The cover sheet names the export time (exportedAt), the kind of export (exportType: SELF_SERVICE or ADMIN), the data subject (subject), the legal basis (legalBasis), notes (notes) and per category a count and a cap flag (categories with count and capped). Every source has an upper bound — 5,000 entries, 1,000 for web notifications and 500 for error telemetry. If it is reached, the cover sheet marks the category as capped: a silent truncation would be a false "complete" disclosure. If a source fails, the whole export fails with an error — for the same reason. Third-party free text about the person (an agent's description, say) is deliberately not included and is named on the cover sheet.
| Path | Who | Limit |
|---|---|---|
GET /export/me | any logged-in user, own ID ONLY | 1 export / 24 h; a failed run does not consume the quota |
GET /export/:userId | users.dataExport (ADMIN/DPO) | any person, no 24h limit |
The self-service quota counts per person: a second call within 24 hours is refused with 429 and the error code DATA_EXPORT_QUOTA_EXCEEDED; the Retry-After header names the remaining seconds, the body carries no waiting time. The reason for the limit: the export is expensive and would otherwise be a convenient way to siphon off personal data. There is deliberately no reset call — whoever needs disclosure sooner gets it via the administration export (users.dataExport), which has no quota.
Language and file name
The legal basis and notes on the cover sheet are in the language of the DATA SUBJECT, not of the caller: first their profile language, then the system language from the general settings, then English. For the administration export this is deliberately a different language than the caller's interface — the document is a disclosure FOR the data subject. If no system language is set in the general settings, people without a profile language receive the cover sheet in English. The file name stays language-neutral: data-export-<date>.json for one's own export, data-export-<userId>-<date>.json for the administration export.
Both paths are audited (DATA_ACCESS) — with categories, record count and the data subject.
Anonymization (Art. 17)
Art. 17 is implemented as anonymization. The reason: deleting the user outright would leave every reference to them (tickets, comments, history) dangling and destroy the factual ticket history. The record therefore remains as a skeleton (identifier, timestamps, status), and all personal data is irreversibly overwritten in one transaction.
Step 1: Pre-check — what blocks?
Active involvements block erasure until an admin resolves them. A workflow step whose assignee vanishes would otherwise hang; an approval quorum could become unreachable. Art. 17 requires "without undue delay" (practically ≤ 1 month) — leaving room to reassign active cases first. GET /erasure-check returns the blockers with counts and entity links; POST /erase refuses the anonymization while blockers exist.
GET /api/privacy/users/:id/erasure-check
{
"erasable": false,
"alreadyAnonymized": false,
"isArchived": true,
"erasureHold": false,
"erasureHoldReason": null,
"blockers": [
{
"type": "openTicketsAssigned",
"count": 3,
"refs": [{ "id": "tkt_1", "label": "T-1042", "url": "/tickets/tkt_1" }]
},
{ "type": "pendingApprovals", "count": 1 }
]
}
erasable is true only when no blocker exists, no erasure hold is set and the person has not yet been anonymized. Per blocker the check returns up to five references with a link target — so the list can be worked through, not merely counted; types without their own detail view carry a label only.
The blocker types (must be resolved manually): open tickets as customer or assignee; open incidents/problems/changes/change tasks as assignee; running workflows as initiator or step assignee; PENDING approvals as approver; PENDING handovers; assigned assets, held consumables and active licenses; sole member of an otherwise non-functional approval group; mailbox catch-all target; designated assignee in a workflow template; target of an escalation policy that would otherwise have no recipient. Some things the transaction resolves itself (substitutions, group memberships, mailbox access, manager relations, user-ID lists such as scheduled report recipients).
Step 2: Anonymize
Preconditions (otherwise the call is refused with a specific error code, see error codes): not a protected account (system user, first administrator account), not the caller themselves, no erasure hold, the user is already ARCHIVED (two-step protection; archived users cannot sign in anyway), not yet anonymized and free of blockers. Then ONE transaction overwrites the personal data everywhere:
- Master data: email → erased+<id>@anonym.invalid, name → "Deleted user", avatar/preferences/2FA secrets → null, anonymizedAt set
- Name snapshots in all *Activity/timeline tables and on ticket-comment authors (actorName → "Deleted user") — otherwise the name would remain there in plain text
- Attachment uploader/IP/user agent, license assignment emails, participant emails, Teams reference, email sender fields
- Absences: reason/rejectedReason → null (sick notes + free text = health-related data, Art. 9)
- The user's access ends entirely: sessions/tokens are fully revoked (refresh tokens + sid sessions), active WebSockets disconnected, device push registrations removed — and it receives no further notifications (see notification system)
- Audit events of the actor: targeted content scrub (actor_name/IP/user agent) so no plain text survives there for up to 7 years — the actorId stays as a pseudonym, the trail stays correlatable
Deliberate boundary: Ticket and comment TEXTS are retained (Art. 17(3) — business record); the personal reference falls away via anonymization of foreign keys and snapshots. Personal data WITHIN free text is a manual redaction case. The Microsoft Entra ID sync skips anonymized users (no re-import). Anonymized skeletons are also excluded server-side from all user lists and selection pickers (assignee, customer, sharing, checkout recipient, approver) — only user administration can reveal them via a toggle.
Erasure hold (legal hold)
POST /erasure-hold sets an absolute lock that takes effect BEFORE all other checks — including the automatic 180-day anonymization and the re-application after a backup restore. While the hold is active, the erase menu item is disabled and every erasure attempt fails in a controlled way.
The call is strict per direction: setting it ({"enabled": true}) requires a reason of 1 to 500 characters (e.g. ongoing proceedings, retention obligation) — a decision that must be documented should carry its reason. Releasing it ({"enabled": false}) accepts no reason. Any other shape of the call is a 400. Protected accounts have no erasure lifecycle and therefore no erasure hold either; an already anonymized account can no longer be put on hold. Both directions write an audit entry (ERASURE_HOLD_SET or ERASURE_HOLD_RELEASED), the setting one including the reason.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
ERASURE_BLOCKED | 400 | Active involvements exist; details.blockers names type and count per blocker |
ERASURE_HOLD_ACTIVE | 400 | Erasure hold set — release it first, then anonymize |
ERASURE_REQUIRES_ARCHIVED | 400 | The account must be archived before anonymization |
USER_ALREADY_ANONYMIZED | 400 | Already anonymized — the operation is irreversible and not repeatable |
VALIDATION_ERROR | 400 | Hold without a reason, release with a reason, unknown value for kind or hold |
ERASURE_SELF_FORBIDDEN | 403 | Nobody anonymizes their own account |
PROTECTED_ACCOUNT | 403 | Protected account (system user, first administrator account): neither anonymization nor erasure hold |
DATA_EXPORT_QUOTA_EXCEEDED | 429 | Second self-service export within 24 h; Retry-After names the remaining time in seconds |
NOT_FOUND | 404 | The requested person does not exist |
Deadline model
| Case | Period | Mechanic |
|---|---|---|
| Erasure request (Art. 17) | immediately once blocker-free | manually by admin/DPO |
| Departed (archived) users without a request | 180 days | Target archivedUsers in retention_purge: auto-anonymization if blocker-free and without a hold; otherwise skipped and reported in the job result. The period starts at the LAST CONTACT — counted from whichever is later: the archival or the last email on that account, in either direction (the agent reply counts like the customer mail). An archived account that keeps corresponding is not anonymized; without further contact the period elapses normally. The run's result reports the skipped ones separately (hold, ongoing contact, blocker) |
| Auto-created email contacts without open cases | 90 / 180 days | Target emailContacts in retention_purge: without any business anchor (no ticket as customer, no handover, no ticket participation) → hard delete 90 days after the last contact; with an anchor and only closed cases → auto-archive after 180 days of inactivity, after that the regular anonymization of archived users applies (target archivedUsers). Open tickets, assigned assets, held consumables, active licenses and open approvals stop both stages. The auto-archive also ends the account's access (sessions, live connections, push) and its notifications |
| Anonymized skeletons | unlimited | deliberate: a record without personal reference contains no personal data |
External handover recipients (technicians/partners with a loaned device, created as an email contact) also run this way: while assets are assigned the blocker list applies; after return archive and anonymize via the 180-day automation. Handover protocols remain available as on-demand PDFs and render the pseudonym after anonymization (note "recipient anonymized").
Erasure backlog
The backlog answers one question: who should long since have been anonymized or cleaned up under the deadline model but has not been — and why? It holds two kinds (field kind): overdue archived accounts (ARCHIVED_OVERDUE) and inactive, automatically created email contacts (EMAIL_CONTACT_STALE). Due dates are computed with the same semantics as the nightly run and from the same live-configured periods, so list, pages, sorting and key figures all see the same rows. In the interface the view sits under "Reporting & Audit → Erasure Backlog".
| Kind | Start of the period | Period |
|---|---|---|
ARCHIVED_OVERDUE | the later of archiving and the last email on the account | period for archived accounts (default 180 days) |
EMAIL_CONTACT_STALE | the later of the last change and the last email | the period of its stage: with a business anchor (a ticket as customer, a handover or a ticket participation) the archive period (default 180 days), without an anchor the deletion period (default 90 days) |
The list comes as { data, pagination } and is sorted by overdueness (the longest-overdue row first). Search with q over name and email, page with page and per (default 25, maximum 100). Two filters narrow it down: kind (ARCHIVED_OVERDUE or EMAIL_CONTACT_STALE) and hold (true or false); any other value is a 400 rather than a silent assumption. Each row carries userId, name, email, kind, the reference date (referenceDate), the days past the period (daysOverdue), the hold status with its reason (erasureHold, erasureHoldReason) and isArchived. The blockers of a row are fetched by the interface via the pre-check of that same person — there is only one source for them.
The key-figure route /erasure-backlog/stats returns { stats, retention }: stats counts archivedOverdue, emailContactsStale, holds and total over the UNFILTERED backlog — so the tiles stay stable while filtering, whereas the list carries its filtered sum in pagination.total; with exactly one tile filter both numbers agree. holds counts only holds inside the backlog, not every hold in the system. retention names the three periods the view computes with (archivedUserRetentionDays, emailContactHardDeleteDays, emailContactArchiveDays). Accounts WITH an erasure hold deliberately appear in the list although the nightly run skips them — making the hold visible is half the point of the view.
Retention & Derivatives
ONE nightly job retention_purge (03:00 UTC) enforces all time-based database retention periods in a single run — isolated per target (a broken target does not stop the others) and with a self-audit event per run. The default periods:
| Target | Rule (default) |
|---|---|
| AuditEvent | two-stage — content purge per policy (90d/365d/7y), row delete at the 7-year maximum (see audit page) |
| ActivityLog | 180 days |
| RoleAuditLog | 365 days |
| EmailMessage | ticketless orphans (REJECTED/bounces) older than 90 days; rawHeaders + retry copy older than 30 days nulled |
| WebNotification / ClientError | 30 / 90 days |
| ReportExecution | previews 24h, result data nulled after 30 days; export files (CUSTOM_REPORT attachments) older than 30 days soft-deleted |
| JobExecution / PushSubscription | old records / inactive subscriptions |
| notificationDigestItems | orphaned digest buffer items (digest disabled after buffering), default 30 days — regularly items are deleted immediately after delivery |
| BulkUpdateJob | 90 days |
| kbArticles | soft-deleted KB articles (trash) older than 90 days → final purge incl. revisions, tag orphans and attachments (cascade) |
| emailContacts | auto email contacts: anchorless → hard delete 90 days; only closed cases → auto-archive 180 days (see periods table) |
| WorkflowInstance / UserAbsence | optional, default OFF — for operators with a stricter form-data/Art. 9 policy |
Boundary rule: time-based retention of database rows runs via retention_purge; anything touching files or virus scans (physical deletion, quarantine) stays with the attachment_cleanup job.
Derivatives after anonymization
Every place that renders or stores personal data is assigned to a class — making it traceable what an artifact shows after anonymization:
| Class | Behavior |
|---|---|
| A — live render from DB | self-healing: renders anonymously by itself (detail/list views, on-demand handover PDF, global search, report execution) |
| B — snapshots with retention | a name may appear until the period lapses — accepted for windows ≤ 90 days (web notifications, nulled report results) |
| C — immutable stores | targeted content scrub on anonymization (AuditEvent) |
| D — files | decided per file (avatars deleted, user uploads stay with an anonymous uploader, report exports 30-day retention) |
| E — external/irreversible | not retrievable — sent emails, Teams/Webex messages, delivered push (Art. 17 boundary, disclosed in the privacy policy) |
| F — Backups | a restore can revive erased people → re-apply erasures after every restore (see below) |
Backup restore: A restore from a dump predating an erasure makes the person visible again. Therefore: before the restore save the list of anonymized accounts from the current database, after the restore re-apply every erasure made since — this is repeatable, already anonymized accounts are left untouched. An erasure hold and existing blockers apply here just as they do on the manual path. The procedure is part of the operations runbook for backups.
Public Transparency
Privacy policy and imprint are reachable at /privacy and /imprint WITHOUT login — required for the three groups who never see the login page: recipients of the Art. 14 info mail to auto-created contacts, external handover recipients on the public confirmation page, and email footer links. The periods in the privacy policy are LIVE: /api/public/privacy-config returns the real, currently configured retention_purge parameters, so an admin change appears without a deployment (static values only as an offline fallback).
The privacy policy discloses the third-party services in use (Cloudflare Turnstile, Microsoft Entra ID/Graph, Teams, Webex, Web-Push, SMTP), telemetry with truncated IP, and the Art. 9 masking implemented as a safeguard for absences (sickness free text is stripped without a special right). An optional Art. 14 info mail to auto-created email contacts can be enabled globally via a notification-type toggle (default OFF).
Permissions
| Permission | Description |
|---|---|
users.erase | Pre-check, anonymization and erasure hold (default: ADMIN; critical action, the permission is checked directly against the database on every call, so a revocation takes effect immediately) |
users.dataExport | Third-party export for any person (ADMIN/DPO). The self-service export needs no permission, only a valid session. |
users.erase ∨ audit.enterpriseView | Reading the erasure backlog and its key figures — both the people acting and pure reviewers (data protection, say) can see it; the actions inside it stay bound to users.erase. |
- Enterprise Audit System – PII redaction & two-stage purge
- CronJobs API – retention_purge
- User Management · Permissions & RBAC
- Security – encryption at rest (TOM)