feat(security): log staff access to customers' personal data

DataAccessLog records bookings list/view/export and run-sheet views (actor, action,
target, time); pruned after 12 months by the worker; migration 0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-15 13:18:44 -04:00
parent ee8056efc2
commit c102594ca5
9 changed files with 62 additions and 4 deletions

View File

@ -25,6 +25,7 @@ and phone numbers. Merchant Twilio / WhatsApp credentials when connected.
- Secrets (`.env`: Shopify secret, database password, `APP_ENCRYPTION_KEY`) are never stored in Git or chat. - Secrets (`.env`: Shopify secret, database password, `APP_ENCRYPTION_KEY`) are never stored in Git or chat.
Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below). Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below).
- Access is reviewed every quarter. - Access is reviewed every quarter.
- **Access logging:** every time staff list, open or export bookings, or open a driver run sheet, the app records who, what and when (`DataAccessLog`, kept 12 months, deleted with the shop). Server access is logged by the host (SSH auth log).
## Incident response ## Incident response
1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path, 1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path,

View File

@ -14,6 +14,7 @@ import { runDueReminders } from "../services/reminders.server";
import { ReservationService } from "../services/reservations.server"; import { ReservationService } from "../services/reservations.server";
import { SELECTION_ATTR } from "../services/settings"; import { SELECTION_ATTR } from "../services/settings";
import shopify from "../shopify.server"; import shopify from "../shopify.server";
import { pruneAccessLogs } from "../lib/audit.server";
const reservations = new ReservationService(prisma); const reservations = new ReservationService(prisma);
let stopping = false; let stopping = false;
@ -97,6 +98,7 @@ async function main() {
while (!stopping) { while (!stopping) {
const started = Date.now(); const started = Date.now();
await tick(); await tick();
if (n % 1440 === 0) await pruneAccessLogs().catch((e) => console.error("prune access logs", e)); // daily
if (n++ % 15 === 0) await reconcile(); if (n++ % 15 === 0) await reconcile();
await new Promise((r) => setTimeout(r, Math.max(1000, 60_000 - (Date.now() - started)))); await new Promise((r) => setTimeout(r, Math.max(1000, 60_000 - (Date.now() - started))));
} }

17
app/lib/audit.server.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Access log for customers' personal data (protected customer data requirement: "log access to personal data").
* Never blocks the request: a failed write is reported and the page still loads.
*/
import prisma from "../db.server";
export function logAccess(shopId: string, actor: string, action: string, target?: string | null) {
prisma.dataAccessLog
.create({ data: { shopId, actor, action, target: target ?? null } })
.catch((e: unknown) => console.error("access log write failed", action, e));
}
/** Keep access logs for 12 months. Called by the worker. */
export async function pruneAccessLogs(now = new Date()) {
const cutoff = new Date(now.getTime() - 365 * 24 * 3600 * 1000);
return prisma.dataAccessLog.deleteMany({ where: { at: { lt: cutoff } } });
}

View File

@ -2,12 +2,14 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server"; import prisma from "../db.server";
import { adminContext, formBool, formString } from "../lib/admin.server"; import { adminContext, formBool, formString } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format"; import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format";
import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server"; import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server";
import type { Method } from "../engine/types"; import type { Method } from "../engine/types";
export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request); const { cfg, actor } = await adminContext(request);
logAccess(cfg.shop.id, actor, "booking.view", params.id ?? null);
const b = await prisma.booking.findFirst({ const b = await prisma.booking.findFirst({
where: { id: params.id, shopId: cfg.shop.id }, where: { id: params.id, shopId: cfg.shop.id },
include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } }, include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } },

View File

@ -3,13 +3,15 @@ import { Form, useLoaderData, useSearchParams } from "react-router";
import prisma from "../db.server"; import prisma from "../db.server";
import { localParts } from "../engine/time"; import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server"; import { adminContext } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { bookingWhere } from "../lib/bookings-query.server"; import { bookingWhere } from "../lib/bookings-query.server";
import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format"; import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request); const { cfg, actor } = await adminContext(request);
const sp = new URL(request.url).searchParams; const sp = new URL(request.url).searchParams;
logAccess(cfg.shop.id, actor, "bookings.list", sp.toString() || null);
const today = localParts(Date.now(), cfg.shop.timezone).date; const today = localParts(Date.now(), cfg.shop.timezone).date;
const page = Math.max(1, Number(sp.get("page") ?? 1)); const page = Math.max(1, Number(sp.get("page") ?? 1));
const where = bookingWhere(cfg.shop.id, sp, today); const where = bookingWhere(cfg.shop.id, sp, today);

View File

@ -3,12 +3,14 @@ import prisma from "../db.server";
import { toCsv } from "../engine/runsheet"; import { toCsv } from "../engine/runsheet";
import { localParts } from "../engine/time"; import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server"; import { adminContext } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { bookingWhere } from "../lib/bookings-query.server"; import { bookingWhere } from "../lib/bookings-query.server";
/** Excel-friendly CSV (UTF-8 BOM) of the bookings matching the list filters. */ /** Excel-friendly CSV (UTF-8 BOM) of the bookings matching the list filters. */
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request); const { cfg, actor } = await adminContext(request);
const sp = new URL(request.url).searchParams; const sp = new URL(request.url).searchParams;
logAccess(cfg.shop.id, actor, "bookings.export", sp.toString() || null);
const today = localParts(Date.now(), cfg.shop.timezone).date; const today = localParts(Date.now(), cfg.shop.timezone).date;
const rows = await prisma.booking.findMany({ const rows = await prisma.booking.findMany({
where: bookingWhere(cfg.shop.id, sp, today), where: bookingWhere(cfg.shop.id, sp, today),

View File

@ -4,17 +4,19 @@ import prisma from "../db.server";
import { buildRunSheet, toCsv } from "../engine/runsheet"; import { buildRunSheet, toCsv } from "../engine/runsheet";
import { localParts } from "../engine/time"; import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server"; import { adminContext } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { niceDate, slotLabel } from "../lib/format"; import { niceDate, slotLabel } from "../lib/format";
import { rowToBooking } from "../services/reminders.server"; import { rowToBooking } from "../services/reminders.server";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features } = await adminContext(request); const { cfg, features, actor } = await adminContext(request);
const sp = new URL(request.url).searchParams; const sp = new URL(request.url).searchParams;
const today = localParts(Date.now(), cfg.shop.timezone).date; const today = localParts(Date.now(), cfg.shop.timezone).date;
const date = sp.get("date") || today; const date = sp.get("date") || today;
const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled); const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled);
const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0]; const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0];
if (!loc) return { empty: true as const }; if (!loc) return { empty: true as const };
logAccess(cfg.shop.id, actor, "runsheet.view", `${loc.id} ${date}`);
const rows = await prisma.booking.findMany({ where: { shopId: cfg.shop.id, locationId: loc.id, date, method: "delivery", status: "confirmed" } }); const rows = await prisma.booking.findMany({ where: { shopId: cfg.shop.id, locationId: loc.id, date, method: "delivery", status: "confirmed" } });
const sheet = buildRunSheet(rows.map(rowToBooking), loc, date); const sheet = buildRunSheet(rows.map(rowToBooking), loc, date);
const csv = toCsv( const csv = toCsv(

View File

@ -0,0 +1,15 @@
-- Access log for customers' personal data (who viewed or exported it, and when).
CREATE TABLE "DataAccessLog" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"action" TEXT NOT NULL,
"target" TEXT,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataAccessLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "DataAccessLog_shopId_at_idx" ON "DataAccessLog"("shopId", "at");
ALTER TABLE "DataAccessLog" ADD CONSTRAINT "DataAccessLog_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -53,6 +53,7 @@ model Shop {
waitlist WaitlistEntry[] waitlist WaitlistEntry[]
reminderJobs ReminderJob[] reminderJobs ReminderJob[]
events BookingEvent[] events BookingEvent[]
accessLogs DataAccessLog[]
} }
model Location { model Location {
@ -242,6 +243,20 @@ model ReminderJob {
@@index([status, sendAt]) @@index([status, sendAt])
} }
/// Who looked at customers' personal data, and when (staff views/exports, customer self-service).
/// Kept 12 months (worker), deleted with the shop on shop/redact.
model DataAccessLog {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
actor String // merchant:<staff email> | customer:<customer id> | system
action String // bookings.list | booking.view | bookings.export | runsheet.view | customer.booking.view
target String?
at DateTime @default(now())
@@index([shopId, at])
}
/// Webhook idempotency: Shopify may deliver the same event more than once. /// Webhook idempotency: Shopify may deliver the same event more than once.
model ProcessedWebhook { model ProcessedWebhook {
id String @id // X-Shopify-Event-Id (falls back to webhook id) id String @id // X-Shopify-Event-Id (falls back to webhook id)