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:v1:<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). Substring search and sorting stay impossible by design — the boot validator rejectssearchable/sortableon encrypted fields.- 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 and list responses 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, registerEntityCrud,} from "@cosmicdrift/kumiko-framework/engine";
export const employeeEntity = createEntity({ table: "read_hr_employees", fields: { displayName: createTextField({ required: true, pii: true }), email: createTextField({ required: true, format: "email", pii: true, lookupable: true }), department: createTextField({ sortable: true }), }, softDelete: true,});
export const hrCommentEntity = createEntity({ table: "read_hr_comments", fields: { employeeId: createTextField({ required: true }), body: createTextField({ required: true, userOwned: { ownerField: "employeeId" } }), authorName: createTextField(), }, softDelete: true,});
const hrWrite = { access: { roles: ["Admin"] } } as const;const hrRead = { access: { roles: ["Admin"] } } as const;
export const hrFeature = defineFeature("hr", (r) => { registerEntityCrud(r, "employee", employeeEntity, { write: hrWrite, read: hrRead, verbs: { update: false, delete: false, restore: false }, });
registerEntityCrud(r, "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