Ga naar inhoud

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_id is a bearer capability (anyone holding it can reveal what it allows). A real auth model is a pending decision; treat grant_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 compare recipient with 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-pseudonyms

Backfilled rows are marked backfilled, not minted: 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_events table, 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_PATH no longer exists.
  • WORDSWORTH_GRANT_ISSUER_LABELS also 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 role beheerder is also how a caller is recognised as holding that role, for deletion's other door.