Privacy & Compliance
Every Grit app ships with two admin-only compliance surfaces you'd otherwise bolt on by hand: a GDPR data toolkit (export & erasure with a tamper-evident journal) and Access Reviews (point-in-time role recertification). Both live under Security & Access in the System Hub. The question this page answers: how do they get populated? For both, the answer is the same — on demand, by an admin action. Nothing is scheduled, and nothing is seeded; the tables start empty.
The GDPR data toolkit
Two rights sit at the centre of GDPR (and CCPA, and most privacy regimes): the right to access your data and the right to be forgotten. Grit implements both at /system/gdpr, keyed by a user's UUID.
Export — reads, never writes
GET /api/users/:id/gdpr-export gathers everything the app holds about one user — profile, uploads, sessions, activity, dashboard layout, and whether 2FA is enabled — into a single JSON bundle and streams it as a download (user-<id>-export.json). A user can export their own data; an admin can export anyone's. Export creates no rows — it is a pure read, so it never touches the journal.
Erase — the only thing that writes to the journal
POST /api/users/:id/gdpr-erase is admin-only and refuses self-erasure. It runs one transaction that:
- Hard-deletes the user's child PII — uploads, sessions, password-reset tokens, role grants, 2FA configs, trusted devices, pending TOTP tokens, dashboard layouts, and notifications — counting each table as it goes.
- Anonymizes the user row in place rather than deleting it (so foreign keys in immutable records still resolve): name becomes
Erased User, email becomeserased-<id>@deleted.invalid, password / avatar / bio / IP / device identifiers are blanked, the account is deactivated and demoted to the base role. - Writes exactly one journal entry recording the erasure.
So the deletion journal is populated one row per erasure — never by export, never automatically, never by a seeder. The user's activity-log rows are deliberately left untouched (they carry only a UUID, and editing them would break that log's own hash chain); the erasure itself is additionally recorded there as a user.gdpr_erase event so it shows up in the dashboard and any SIEM export.
Why the journal is “tamper-evident”
A DeletionJournal row stores only non-PII facts about an erasure — the erased user's UUID, who ran it (actor id + email), a reason, the per-table counts, and a timestamp — plus two hashes. Each row is chained to the one before it exactly like a miniature blockchain:
hash = sha256( prev_hash + canonical(entry) )canonical(entry) = deleted_user_id | actor_id | actor_email |reason | records_affected | counts | created_at# The genesis row's prev_hash is "".# Each new row's prev_hash = the previous row's hash.
Rows are append-only — never updated or deleted. To audit the chain, GET /api/gdpr/journal replays every row in order, recomputes each hash, and checks that each prev_hash links to the prior row's hash. If a single row were altered or removed after the fact, the recomputed hash wouldn't match and the replay reports the break. The admin page surfaces this as a “Chain verified” / “Chain broken” pill above the journal table.
What the admin sees
The /system/gdpr page has two cards. Export or erase a user has a searchable user picker — requests arrive as “delete john@acme.com”, never as a UUID — then an Export data button (downloads the JSON bundle) and an Erase… button that opens an inline confirmation with a reason field, which is stored in the journal. Deletion journal lists every erasure (deleted user, erased-by, records affected, reason, when) under the verified/broken pill. Empty until the first erasure: “No erasures recorded yet.”
Deleting a user is not an erasure. The Users page's Delete is an ordinary GORM soft delete: it sets deleted_at and the row — with its email, names and device identifiers — physically remains. That is reversible, which is exactly what you want most of the time, and it is deliberately not written to the journal. For a real Art. 17 request use the Erase (GDPR) action on the Users table (it deep-links here with the user pre-selected) or pick them here directly.
Access Reviews
Auditors and SOC 2 / ISO 27001 controls ask a recurring question: does everyone who has access still need it? An access review (a.k.a. access recertification) answers it by freezing today's permissions and having an owner sign off on each one. Grit ships this at /system/access-reviews.
Opening a review is the snapshot
This is how the feature gets populated. When an admin clicks New review and names it (e.g. “Q3 2026 quarterly”), POST /api/access-reviews opens a campaign and, in the same transaction, snapshots every current role assignment — it reads the user_roles table joined to users and roles, and creates one review item per grant:
AccessReview (the campaign) status: "open"└─ AccessReviewItem (one per current user→role grant)user_email: "ada@acme.com" ← snapshotted at open timerole_name: "ADMIN" ← snapshotted at open timedecision: "pending"
The email and role name are copied into the item, not referenced — so the record survives someone later renaming the role or deleting the user. There is no schedule and no seeder: a snapshot exists only because an admin opened a review, and it captures the full set of assignments as they stood that moment, all pending.
Working the items — and what a decision actually does
For each pending item the admin makes one of two calls via POST /api/access-reviews/:id/items/:itemId/decision:
- Keep (
approved) — certifies the grant. It stays. This is the attestation; there's no separate “attest” step. - Revoke (
revoked) — deletes the realuser_rolesgrant in the same transaction, then records the decision. A revoke is terminal — it can't be undone from the review — and is logged to the activity trail asaccess_review.revoke.
So a review isn't just paperwork: revoking an item changes live access. Once every item has a decision, POST /api/access-reviews/:id/complete signs the campaign off (it refuses while anything is still pending). A completed review is immutable evidence and is never reopened — the next period is a new campaign.
What the admin sees
A two-column page: on the left, the list of campaigns with a status pill and {pending} · {approved} · {revoked} counts; on the right, the selected review's items table (User, Role, Decision) with Keep / Revoke buttons per pending row and a Complete review button that stays disabled until nothing is pending. New review opens a form for the campaign's name and an optional note.
Populated on demand, both of them. The GDPR journal grows by one row each time an admin erases a user; an access review's items appear the moment an admin opens the campaign. Neither is scheduled, automatic, or seeded — which is exactly what makes them defensible audit evidence.
