Crypto-shredding (mini HR)
Make GDPR forget a key-erase instead of a data-hunt. Fields annotated as PII
are encrypted under a per-subject key (DEK); forgetting the subject erases
that key in the KMS — the ciphertext stays in rows and events but is
unreadable forever, and reads render the [[erased]] sentinel.
The recipe ships a mini HR feature: an employee entity whose name and email
are the employee’s own PII, and an hr-comment entity whose body is
encrypted under the key of the employee the comment is about — so a
manager’s comment dies with the employee’s key, not the manager’s.
What it shows
Section titled “What it shows”-
pii: trueon a field — encrypted under the subject key of its own row (user:<row.id>). At rest the column holds akumiko-pii:v2:<subjectKey>:<ciphertext>envelope; the API returns plaintext as long as the key exists. -
userOwned: { ownerField }— encrypted under the key of the user another field points at. Erasing that user’s key makes every row about them unreadable, with no per-row cleanup hunt. -
Plain fields stay queryable —
departmentis not personal data, so it remains plaintext, sortable and searchable. -
lookupable: trueon an encrypted field — the framework maintains an HMAC blind-index column (email_bidx), so equality lookups (login, dedup checks) keep working on ciphertext: the query compiler rewritesemail = $1to(email = $1 OR email_bidx = hmac($1)). NeedsrunProdApp({ blindIndexKey })(a dedicated 32-byte key, NOT the KEK). Sorting stays impossible by design — the boot validator rejectssortableon subject-annotated fields, because sorting reads the projection column and that stays ciphertext. -
searchable: trueon an encrypted field (#1610) — substring search works: the search consumer decrypts into a derived Meilisearch index, while events and projection keep the ciphertext.search/purge-subject.tsdrops those documents when the subject key is erased, so the derived index does not outlive the forget.sensitive: true+searchablestill throws — those values may never be read back at all.Encrypting a field is not a reason to make it unfindable. If a list needs ordering over an encrypted name, the way out is a search-driven list rather than an alphabetically paginated one — not a plaintext sort column beside the encrypted one, which would survive the key erase and quietly undo the shredding.
-
Forget =
kms.eraseKey(subject)— afterwards detail and list render[[erased]]for every protected field while the stored ciphertext bytes stay untouched.
Feature composition
Section titled “Feature composition”hr → employee (displayName/email pii) + hr-comment (body userOwned)Requires a KMS adapter: runProdApp({ kms: createPgKmsAdapter(...) }) in
production, configurePiiSubjectKms(new InMemoryKmsAdapter()) in tests.
Without one, fields are stored in plaintext and a boot warning is logged.
The crypto-shredding bundled feature ships the operator forget-subject
command; user-data-rights erases user keys automatically after the
deletion grace period.
- Create an employee → the executor creates a subject key on first insert
and stores
displayName/emailas ciphertext. - A comment about the employee is encrypted under the employee’s key via
userOwned: { ownerField: "employeeId" }. kms.eraseKey({ kind: "user", userId })— idempotent, tombstone stays for the audit trail.- Detail responses (list, too, for
employee) now show[[erased]]for every field the key protected; events and rows keep their original (unreadable) bytes. - Lookups by email stop matching after the forget: the pipeline nulls the
blind index immediately (
nullBlindIndexesForSubject), and every projection rebuild recomputes it from the (now undecryptable) ciphertext toNULL.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
export const employeeEntity = createEntity({ table: "read_hr_employees", fields: { displayName: createTextField({ required: true, personal: "self", find: "none" }), email: createTextField({ required: true, format: "email", personal: "self", find: "exact" }), department: createTextField({ sortable: true }), }, softDelete: true,});
export const hrCommentEntity = createEntity({ table: "read_hr_comments", fields: { employeeId: createTextField({ required: true }), body: createTextField({ required: true, personal: { of: "employeeId" }, find: "none" }), authorName: createTextField(), }, softDelete: true,});
const hrWrite = { access: { roles: ["Admin"] } } as const;const hrRead = { access: { roles: ["Admin"] } } as const;
export const hrFeature = defineFeature("hr", (r) => { r.crud("employee", employeeEntity, { write: hrWrite, read: hrRead, verbs: { update: false, delete: false, restore: false }, });
r.crud("hr-comment", hrCommentEntity, { write: hrWrite, read: hrRead, verbs: { update: false, delete: false, restore: false, list: false }, });});📄 On GitHub: samples/recipes/crypto-shredding-hr/src/feature.ts