datewise/app/lib/pii.server.ts
metatroncubeswdev 1d2d943d54 feat(security): encrypt customer personal data at rest
- Prisma extension seals booking name/email/phone/address and waitlist contacts with
  AES-256-GCM on write and opens them on read (incl. nested reads)
- keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search,
  waitlist de-duplication); migration 0002
- tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:56:05 -04:00

102 lines
3.9 KiB
TypeScript

/**
* Encryption at rest for customer personal data, applied to every Prisma client (db.server.ts).
*
* Booking: customerName, customerEmail, customerPhone, address (+ customerEmailHash, customerPhoneHash)
* WaitlistEntry: name, email, phone (+ emailHash, phoneHash)
*
* Writes are sealed with AES-256-GCM in a query extension; reads are opened in a result extension,
* which also covers nested reads (e.g. reminderJob → booking). Code that queries by email or phone
* must use the *Hash columns with blindIndex(), because ciphertext can't be matched.
*/
import { Prisma } from "@prisma/client";
import { blindIndex, seal, unseal } from "../services/crypto.server";
type Data = Record<string, unknown>;
function sealAddress(v: unknown) {
if (v == null || v === Prisma.DbNull || v === Prisma.JsonNull) return v;
if (typeof v === "object" && v && "enc1" in v) return v;
return { enc1: seal(JSON.stringify(v)) };
}
function openAddress(v: unknown) {
if (v && typeof v === "object" && "enc1" in v && typeof (v as { enc1: unknown }).enc1 === "string") {
return JSON.parse(unseal((v as { enc1: string }).enc1) ?? "null");
}
return v ?? null;
}
const str = (v: unknown) => (typeof v === "string" ? v : null);
function sealBooking(d: Data | undefined) {
if (!d) return d;
const out: Data = { ...d };
if ("customerName" in d) out.customerName = seal(str(d.customerName));
if ("customerEmail" in d) {
out.customerEmail = seal(str(d.customerEmail));
out.customerEmailHash = blindIndex(str(d.customerEmail), "email");
}
if ("customerPhone" in d) {
out.customerPhone = seal(str(d.customerPhone));
out.customerPhoneHash = blindIndex(str(d.customerPhone), "phone");
}
if ("address" in d) out.address = sealAddress(d.address);
return out;
}
function sealWaitlist(d: Data | undefined) {
if (!d) return d;
const out: Data = { ...d };
if ("name" in d) out.name = seal(str(d.name));
if ("email" in d) {
out.email = seal(str(d.email));
out.emailHash = blindIndex(str(d.email), "email");
}
if ("phone" in d) {
out.phone = seal(str(d.phone));
out.phoneHash = blindIndex(str(d.phone), "phone");
}
return out;
}
/** Applies a sealer to the data of create / update / upsert / createMany / updateMany. */
function sealArgs(args: Data, sealer: (d: Data | undefined) => Data | undefined) {
const a: Data = { ...args };
if (Array.isArray(a.data)) a.data = (a.data as Data[]).map((x) => sealer(x));
else if (a.data) a.data = sealer(a.data as Data);
if (a.create) a.create = sealer(a.create as Data);
if (a.update) a.update = sealer(a.update as Data);
return a;
}
const WRITES = new Set(["create", "createMany", "createManyAndReturn", "update", "updateMany", "updateManyAndReturn", "upsert"]);
export const piiExtension = Prisma.defineExtension({
name: "datewise-pii",
query: {
booking: {
$allOperations({ operation, args, query }) {
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealBooking) as typeof args) : args);
},
},
waitlistEntry: {
$allOperations({ operation, args, query }) {
return query(WRITES.has(operation) ? (sealArgs(args as Data, sealWaitlist) as typeof args) : args);
},
},
},
result: {
booking: {
customerName: { needs: { customerName: true }, compute: (b) => unseal(b.customerName) },
customerEmail: { needs: { customerEmail: true }, compute: (b) => unseal(b.customerEmail) },
customerPhone: { needs: { customerPhone: true }, compute: (b) => unseal(b.customerPhone) },
address: { needs: { address: true }, compute: (b) => openAddress(b.address) as Prisma.JsonValue },
},
waitlistEntry: {
name: { needs: { name: true }, compute: (w) => unseal(w.name) },
email: { needs: { email: true }, compute: (w) => unseal(w.email) },
phone: { needs: { phone: true }, compute: (w) => unseal(w.phone) },
},
},
});