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 queryabledepartment 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