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:
parent
ee8056efc2
commit
c102594ca5
@ -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,
|
||||
|
||||
@ -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))));
|
||||
}
|
||||
|
||||
17
app/lib/audit.server.ts
Normal file
17
app/lib/audit.server.ts
Normal 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 } } });
|
||||
}
|
||||
@ -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" } } },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
15
prisma/migrations/0003_data_access_log/migration.sql
Normal file
15
prisma/migrations/0003_data_access_log/migration.sql
Normal 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;
|
||||
@ -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:<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.
|
||||
model ProcessedWebhook {
|
||||
id String @id // X-Shopify-Event-Id (falls back to webhook id)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user