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