Skip to content

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.

  • pii: true on a field, encrypted under the subject key of its own row (user:<row.id>). At rest the column holds a kumiko-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, department is not personal data, so it remains plaintext, sortable and searchable.

  • lookupable: true on 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 rewrites email = $1 to (email = $1 OR email_bidx = hmac($1)). Needs runProdApp({ blindIndexKey }) (a dedicated 32-byte key, NOT the KEK). Sorting stays impossible by design, the boot validator rejects sortable on subject-annotated fields, because sorting reads the projection column and that stays ciphertext.

  • searchable: true on 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.ts drops those documents when the subject key is erased, so the derived index does not outlive the forget. sensitive: true + searchable still 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.

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.

  1. Create an employee → the executor creates a subject key on first insert and stores displayName/email as ciphertext.
  2. A comment about the employee is encrypted under the employee’s key via userOwned: { ownerField: "employeeId" }.
  3. kms.eraseKey({ kind: "user", userId }), idempotent, tombstone stays for the audit trail.
  4. Detail responses (list, too, for employee) now show [[erased]] for every field the key protected; events and rows keep their original (unreadable) bytes.
  5. 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 to NULL.

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