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/v1/users/:id/gdpr-erase is admin-only and refuses self-erasure. It runs one transaction that:
- Hard-deletes everything registered as the user's: the framework's own tables (uploads, sessions, password-reset tokens, role grants, 2FA configs, trusted devices, pending TOTP tokens, dashboard layouts and notifications), and every resource generated with
--owned-by, so the records a person owns go with them. Each table is counted as it goes. Append-only resources are the exception: their rows cannot be deleted by design, and which of retention or erasure wins is a decision for the project. - 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.
Restoring a backup does not undo an erasure
A backup taken before someone was erased still holds them. Restore reads the deletion journal before it clears anything, and after replaying the archive it puts back the journal entries the archive is missing, exactly as they were, and erases each of those people again. The chain verifies afterwards as it did before, and the restore says how many erasures it re-applied.
The limit is where the journal lives: in the same database. Restoring into an empty one, on new infrastructure after a total loss, has no journal to consult. If that is a scenario you plan for, export the journal to storage outside the database as part of your backup routine.
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.
Who read a record
Health-records rules, and most privacy regimes, ask a question the write log cannot answer: who looked at this person's record? Generate the resource with --audit-reads and every read goes into the same tamper-evident activity log as the writes: who asked, when, from where, and which rows they were shown.
grit generate resource LabResult --fields "test:string,result:text" --owned-by user --audit-reads
- A list or search records the ids of the rows on the page it returned, up to 1,000 per entry.
- A read by id, and its PDF, record that one id.
- An export records how many rows it held rather than every id, which would make one entry the size of the table.
- The query string is kept as a digest, not verbatim: a name typed into a search box is personal data too.
- Only successful reads count. A 404 served nothing, so it is not recorded.
Writes record the id of the record they changed as well, so the admin audit page can answer both halves: type a record's id into its Record id filter and it lists everyone who read or changed it. The same filter is GET /api/admin/activity?record=<id>, and ?resource=lab_results narrows to one resource. Line items generated with --items inherit the flag from their parent.
Reads are off by default because they are most of all traffic, and a log of every page load buries the writes. --audit-reads cannot be combined with --public, whose reads are anonymous, or with --tree, whose endpoints return the whole hierarchy at once. A project from before v3.216.0 needs grit upgrade first; the generator says so rather than writing handlers whose reads nothing would record.
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.
