- 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>
102 lines
3.9 KiB
TypeScript
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) },
|
|
},
|
|
},
|
|
});
|