From c102594ca56ff05433c0096fda6d793767c6647b Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Thu, 15 Oct 2026 13:18:44 -0400 Subject: [PATCH] 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 --- SECURITY.md | 1 + app/jobs/worker.ts | 2 ++ app/lib/audit.server.ts | 17 +++++++++++++++++ app/routes/app.bookings.$id.tsx | 4 +++- app/routes/app.bookings._index.tsx | 4 +++- app/routes/app.bookings.export.tsx | 4 +++- app/routes/app.runsheet.tsx | 4 +++- .../0003_data_access_log/migration.sql | 15 +++++++++++++++ prisma/schema.prisma | 15 +++++++++++++++ 9 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 app/lib/audit.server.ts create mode 100644 prisma/migrations/0003_data_access_log/migration.sql diff --git a/SECURITY.md b/SECURITY.md index 6df7eaa..ef4379b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below). - 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 1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path, diff --git a/app/jobs/worker.ts b/app/jobs/worker.ts index c8703ed..c527885 100644 --- a/app/jobs/worker.ts +++ b/app/jobs/worker.ts @@ -14,6 +14,7 @@ import { runDueReminders } from "../services/reminders.server"; import { ReservationService } from "../services/reservations.server"; import { SELECTION_ATTR } from "../services/settings"; import shopify from "../shopify.server"; +import { pruneAccessLogs } from "../lib/audit.server"; const reservations = new ReservationService(prisma); let stopping = false; @@ -97,6 +98,7 @@ async function main() { while (!stopping) { const started = Date.now(); await tick(); + if (n % 1440 === 0) await pruneAccessLogs().catch((e) => console.error("prune access logs", e)); // daily if (n++ % 15 === 0) await reconcile(); await new Promise((r) => setTimeout(r, Math.max(1000, 60_000 - (Date.now() - started)))); } diff --git a/app/lib/audit.server.ts b/app/lib/audit.server.ts new file mode 100644 index 0000000..d7e985e --- /dev/null +++ b/app/lib/audit.server.ts @@ -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 } } }); +} diff --git a/app/routes/app.bookings.$id.tsx b/app/routes/app.bookings.$id.tsx index b704995..e15c14d 100644 --- a/app/routes/app.bookings.$id.tsx +++ b/app/routes/app.bookings.$id.tsx @@ -2,12 +2,14 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import prisma from "../db.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 { rescheduleBooking, rescheduleOptions } from "../services/bookings.server"; import type { Method } from "../engine/types"; 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({ where: { id: params.id, shopId: cfg.shop.id }, include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } }, diff --git a/app/routes/app.bookings._index.tsx b/app/routes/app.bookings._index.tsx index 27248ed..3b67da3 100644 --- a/app/routes/app.bookings._index.tsx +++ b/app/routes/app.bookings._index.tsx @@ -3,13 +3,15 @@ import { Form, useLoaderData, useSearchParams } from "react-router"; import prisma from "../db.server"; import { localParts } from "../engine/time"; import { adminContext } from "../lib/admin.server"; +import { logAccess } from "../lib/audit.server"; import { bookingWhere } from "../lib/bookings-query.server"; import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const { cfg } = await adminContext(request); + const { cfg, actor } = await adminContext(request); 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 page = Math.max(1, Number(sp.get("page") ?? 1)); const where = bookingWhere(cfg.shop.id, sp, today); diff --git a/app/routes/app.bookings.export.tsx b/app/routes/app.bookings.export.tsx index ec333ed..5d83a9a 100644 --- a/app/routes/app.bookings.export.tsx +++ b/app/routes/app.bookings.export.tsx @@ -3,12 +3,14 @@ import prisma from "../db.server"; import { toCsv } from "../engine/runsheet"; import { localParts } from "../engine/time"; import { adminContext } from "../lib/admin.server"; +import { logAccess } from "../lib/audit.server"; import { bookingWhere } from "../lib/bookings-query.server"; /** Excel-friendly CSV (UTF-8 BOM) of the bookings matching the list filters. */ export const loader = async ({ request }: LoaderFunctionArgs) => { - const { cfg } = await adminContext(request); + const { cfg, actor } = await adminContext(request); 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 rows = await prisma.booking.findMany({ where: bookingWhere(cfg.shop.id, sp, today), diff --git a/app/routes/app.runsheet.tsx b/app/routes/app.runsheet.tsx index fa7f058..aeb402c 100644 --- a/app/routes/app.runsheet.tsx +++ b/app/routes/app.runsheet.tsx @@ -4,17 +4,19 @@ import prisma from "../db.server"; import { buildRunSheet, toCsv } from "../engine/runsheet"; import { localParts } from "../engine/time"; import { adminContext } from "../lib/admin.server"; +import { logAccess } from "../lib/audit.server"; import { niceDate, slotLabel } from "../lib/format"; import { rowToBooking } from "../services/reminders.server"; 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 today = localParts(Date.now(), cfg.shop.timezone).date; const date = sp.get("date") || today; const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled); const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0]; 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 sheet = buildRunSheet(rows.map(rowToBooking), loc, date); const csv = toCsv( diff --git a/prisma/migrations/0003_data_access_log/migration.sql b/prisma/migrations/0003_data_access_log/migration.sql new file mode 100644 index 0000000..359040b --- /dev/null +++ b/prisma/migrations/0003_data_access_log/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f86adb5..b1c315f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,7 @@ model Shop { waitlist WaitlistEntry[] reminderJobs ReminderJob[] events BookingEvent[] + accessLogs DataAccessLog[] } model Location { @@ -242,6 +243,20 @@ model ReminderJob { @@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: | customer: | 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. model ProcessedWebhook { id String @id // X-Shopify-Event-Id (falls back to webhook id)