Runbook: reveal grants
Grants gate the key-gated reveal endpoint per PII type. They are shareable
(hand the grant_id to whoever may reveal) and revocable (revoke and the
reveal stops working immediately). Every issue/revoke is recorded in the
key-lifecycle audit stream.
Auth caveat. This is an operator/admin surface with no caller authentication yet — the API is tailnet-internal and the
grant_idis a bearer capability (anyone holding it can reveal what it allows). A real auth model is a pending decision; treatgrant_ids as secrets until then.
Issue
CLI:
wordsworth grant issue --recipient auditor \
--types PERSON,LOCATION \
[--document <document-uuid>] \
[--expires 2026-12-31T00:00:00+00:00] # ISO-8601, MUST be timezone-aware
HTTP: POST /grants {recipient, allowed_types, document_id?, expires_at?} → 201
with the grant metadata (including grant_id). --document scopes the grant to a
single document and is required by default — see the next section. A
naive/invalid expires_at → 400.
Who may issue a grant
A grant is the key to clear PII: whoever can mint one can reveal everything it allows. That is a smaller circle than "everyone with an API key", so it has its own setting:
WORDSWORTH_GRANT_ISSUER_LABELS |
with WORDSWORTH_API_KEYS set |
without API-key auth |
|---|---|---|
console,cli |
those labels may issue and revoke; any other caller gets 403 | unchanged |
| unset / empty | nobody may issue or revoke | unchanged |
Empty means nobody here — unlike the corpus-read scope, where empty means "off".
Forgetting to name the circle would otherwise leave exactly the hole this setting
closes: a caller who is denied /documents/{id}/anonymized could still mint a
grant and reveal through it.
Without API-key auth there is no caller to decide on, so behaviour is unchanged — the documented tailnet-internal mode.
WORDSWORTH_GRANT_ISSUER_LABELS=console,cli
Global (unscoped) grants are off by default
A grant without a document scope authorizes reveal on every document. That is a legitimate bulk-reveal capability, but too broad to hand out by forgetting a flag, so it is gated:
WORDSWORTH_ALLOW_GLOBAL_GRANTS |
Issue without --document |
An existing unscoped grant |
|---|---|---|
unset / false (default) |
400 document_id required (global grants are not allowed) |
authorizes nothing (reveal → 403) |
true |
201 | authorizes any document |
The gate is enforced in grants.authorize(), not only at issue, so a grant
created before the gate — or written straight into the database — is covered too.
Turning it on is a deployment decision, visible in the deployment's configmap:
WORDSWORTH_ALLOW_GLOBAL_GRANTS=true # bulk reveal across documents
Prefer a scoped grant per document; reach for the flag only for an actual cross-document task, and turn it off again afterwards.
Issue by Privacy Protection Level (PPL)
Instead of listing types, issue at a level; the server expands it through the
PII category registry (pii_categories.py) and stores plain allowed_types.
| PPL | Reveals | AVG basis | Typical holder (per the NORA target architecture) |
|---|---|---|---|
| 0 | nothing — placeholders only | — | data teams, external parties |
| 1 | ordinary personal data (PERSON, LOCATION, BSN, IBAN, EMAIL, …) | Art. 6 | functional administrators |
| 2 | PPL 1 + special categories (GEZONDHEID, RELIGIE, ETNICITEIT, BIOMETRIE, …) | Art. 6 + 9 | privacy officers |
| 3 | everything incl. criminal data (STRAFRECHTELIJK) | Art. 6 + 9 + 10 | FG, emergency procedures |
wordsworth grant issue --recipient privacy-officer --ppl 2 [--document <uuid>]
HTTP: POST /grants {recipient, ppl} — ppl and allowed_types are mutually
exclusive (422 on both or neither). The response reports ppl whenever the
stored type set equals a level exactly. Reveal responses add by_legal_basis
(the revealed/withheld types grouped under Art. 6/9/10) and the reveal audit
record carries the categories touched (c1/c2/c3), never values. A type the
registry does not know is treated as Art. 6 (c1) and is not granted
implicitly by any level.
Inspect
wordsworth grant show <grant_id> # GET /grants/{id}
Revoke
wordsworth grant revoke <grant_id> # POST /grants/{id}/revoke (idempotent)
After revocation, POST /documents/{id}/reveal with that grant returns 403.
Use a grant to reveal
curl -XPOST $API/documents/<doc>/reveal -H 'content-type: application/json' \
-d '{"grant_id":"<grant_id>","types":["PERSON"]}'
Only the granted types are revealed; every other type stays pseudonymised. The
access is audited (actor = grant recipient, plus the grant_id), never logging
clear values.
As a PDF, with the layout intact:
curl -XPOST $API/documents/<doc>/reveal.pdf -H 'content-type: application/json' \
-d '{"grant_id":"<grant_id>","types":["PERSON"]}' -o reveal.pdf
Same grant, same gates (see pdf-redaction for the full
contract) — the granted types are drawn in clear on the page, every other type
keeps its box and label. Never cached, never stored; audited exactly like
/reveal, as a deanonymize record with view: "pdf" and no clear value.
Three type lists, and why they differ
| veld | betekenis |
|---|---|
authorized_types |
wat de grant toestond voor dit verzoek |
resolved_types |
wat er werkelijk uit de mappingstore kwam |
withheld_types |
gevraagde types die de grant niet toestond |
resolved_types is kleiner dan authorized_types wanneer een toegestaan type
niet in dit document voorkomt, of wanneer een token gemunt is onder een sleutel
die deze installatie niet meer heeft. Het veld wordt overgenomen uit de
auditregel die de onthulling schreef (types), zodat antwoord en spoor niet
uiteen kunnen lopen; requested_types daar is authorized_types hier.
revealed_types is authorized_types onder zijn oude naam. Het blijft staan
omdat clients het lezen — wie nieuw leest neemt een van de twee precieze namen.
The caller must be the recipient
With caller authentication enabled (WORDSWORTH_API_KEYS set), the endpoint
refuses a caller who is not the grant's recipient — same 403, no text revealed.
This matters more than it looks. Until 2026-09-17 the recipient was recorded but
never checked, which made a grant_id a bearer token: one leak from a log line,
a ticket or a screenshot handed clear PII to whoever found it.
The comparison is exact. Console is not console, console-2 is not
console. A recipient is a label from the same vocabulary as the caller, and a
reveal is the wrong place to be generous. Only surrounding whitespace is ignored.
Without caller authentication there is no caller to decide on and nothing changes — the documented tailnet-internal mode.
Deploy-stap. Turning auth on (or upgrading with it already on) means an existing grant whose recipient is not the caller's label now gets 403. Check the active grants before this goes live:
curl $API/grants/<id>and comparerecipientwith the label behind the key that uses it.
A token only resolves for its own document
Since 2026-09-17 a reveal resolves only pseudonyms registered for the document being revealed. Anything else is left in place, silently.
The mapping store is global on purpose — one value, one token, across documents,
which is what makes pseudonymised text searchable. The cost was that a reveal
resolved any token it met, whoever minted it. neutralise_foreign_tokens closes
the entrance an attacker can walk; this closes the exit, and an exit is cheaper
to guard: there is one reveal path and an unbounded number of ways text enters a
system.
Refusals are silent by design. Reporting which tokens were refused would answer "does this value exist elsewhere in the corpus", and that answer is itself an oracle.
Deploy-stap, vóór dit live gaat. A document with no registry rows reveals nothing. Documents anonymised before this change have none, so run the backfill as part of the rollout:
sh wordsworth-backfill-pseudonyms --dry-run # telt eerst wordsworth-backfill-pseudonymsBackfilled rows are marked
backfilled, notminted: reading stored text cannot tell whether a token was minted there or arrived before the guard existed. That difference should be visible to whoever investigates an incident, not assumed away.
Notes
- Grant routes mount only when the deployment runs in reversible mode (a grant store is configured). In the irreversible default they are absent.
- An unscoped ("global") grant needs
WORDSWORTH_ALLOW_GLOBAL_GRANTS=true; see above. Default is denied, at issue and at authorize. - Every issue and revoke lands in the
key_lifecycle_eventstable, in the same transaction as the grant change: append-only by trigger, hash-chained (key_audit_pg.verify_chain). Until 2026-09-23 it was a JSONL file on an emptyDir and did not survive a restart;WORDSWORTH_KEY_LIFECYCLE_AUDIT_PATHno longer exists. WORDSWORTH_GRANT_ISSUER_LABELSalso names who may delete a document (see deleting documents) — the same "issuer label" named there, since minting a grant and deleting a document are both the kind of act this circle exists for. A grant naming rolebeheerderis also how a caller is recognised as holding that role, for deletion's other door.