diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75e9f6c..c592fd5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,7 +54,7 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `lab-orders` — lab order issuance and versioning. - `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7). - `notifications` — outbound notification dispatch, provider-agnostic. -- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. +- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. ### 3.1 Patient data export registry (Phase 8, issue #40) @@ -292,6 +292,38 @@ Bundle format (schemaVersion 1): `{ format: "docforum.patient-export", schemaVersion, exportId, generatedAt, sections: { : { version, items } } }` — see §3.1 for where sections come from. +### 4.11 PHI read-access log (Phase 8, issue #45) +``` +AccessLog + id, accessed_by (nullable FK → User, SET NULL on delete — the row keeps + its timestamp and entity while the PII pointer is removed) + patient_profile_id (nullable FK → PatientProfile, CASCADE — the record + owner, whose data was viewed; used by the patient-facing query) + entity_type ('IntakeForm' | 'Consultation' | 'Prescription' | + 'PrescriptionItem' | 'LabOrder' | 'LabResult' | 'Referral' | + 'PaymentIntent' | 'PatientProfile' | 'Appointment'), entity_id + route (nullable — the HTTP route that returned the payload), + detail (nullable — context string, never PHI) + accessed_at (default now) + # indexes: (accessed_at), (accessed_by), (patient_profile_id, + accessed_at), (entity_type, entity_id). +``` +Answers "who looked at this" — distinct from `AuditEvent`'s "who changed +this" (§4.10). Reads are far higher volume than state changes, so: +- writes go through `auditService.recordAccess()`, which is + **fire-and-forget** (never awaited on the read path — a unit test pins + sub-50ms return with a hanging insert) and **fail-open** (a logging + failure never fails the read, but is counted and surfaced via + `GET /admin/access-logs/stats` for alerting); +- rows are **retention-managed**, not append-only: `pruneOldAccessLogs()` + (wired into the prune interval in `src/server.ts`) deletes rows older + than `ACCESS_LOG_RETENTION_DAYS` (default 365). That delete is the only + one in the audit repositories and is bounded to the retention cutoff. +Patient-facing query (`GET /patients/me/access-log`) is scoped server-side +to the caller's own profile (resolved from the authenticated user, never +from the request) and strips all viewer PII to a role — a deleted viewer +surfaces as `deleted_user`. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 7dccf3b..ee0db1e 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -27,6 +27,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `Consultation` → `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) `FulfillmentRecord` (facility-owned) references an order but doesn't mutate it. `User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). +`User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). `LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1). `FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004) diff --git a/ROADMAP.md b/ROADMAP.md index d00de89..0b1c4b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,6 +120,7 @@ for status and issue links. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. - [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**. - [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue. +- [ ] PHI read-access log: record who viewed which clinical record. `AccessLog` table (viewer, record owner, entity, route, retention-managed), fire-and-forget fail-open writes off the read path, admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log` with all viewer PII stripped to a role. Tracked as [issue #45](https://github.com/DocForum/docforum-core/issues/45) (High) — **in review**, PR #149. Read-path wiring into the Phase 2/3/4/5 controllers (intake, consultation, prescriptions, lab orders) happens as those routes are built. - [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now. ## Phase 9 — Deploy & CI diff --git a/backend/.env.example b/backend/.env.example index cbd3432..32c3d71 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,6 +31,10 @@ LOGIN_THROTTLE_LOCK_SECONDS=900 LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS=3600 LOGIN_THROTTLE_PRUNE_AFTER_SECONDS=86400 +# PHI read-access log retention (Phase 8, issue #45). AccessLog rows older +# than this many days are deleted by the prune job in src/server.ts. +ACCESS_LOG_RETENTION_DAYS=365 + # Patient data export (Phase 8, issue #40). Defaults are in src/config/index.ts. PATIENT_EXPORT_TTL_HOURS=24 PATIENT_EXPORT_PAGE_SIZE=200 diff --git a/backend/prisma/migrations/20261016150000_add_access_log/migration.sql b/backend/prisma/migrations/20261016150000_add_access_log/migration.sql new file mode 100644 index 0000000..ed33aff --- /dev/null +++ b/backend/prisma/migrations/20261016150000_add_access_log/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "AccessLog" ( + "id" TEXT NOT NULL, + "accessedBy" TEXT, + "patientProfileId" TEXT, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "route" TEXT, + "detail" TEXT, + "accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AccessLog_accessedAt_idx" ON "AccessLog"("accessedAt"); + +-- CreateIndex +CREATE INDEX "AccessLog_accessedBy_idx" ON "AccessLog"("accessedBy"); + +-- CreateIndex +CREATE INDEX "AccessLog_patientProfileId_accessedAt_idx" ON "AccessLog"("patientProfileId", "accessedAt"); + +-- CreateIndex +CREATE INDEX "AccessLog_entityType_entityId_idx" ON "AccessLog"("entityType", "entityId"); + +-- AddForeignKey +ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_accessedBy_fkey" FOREIGN KEY ("accessedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 57c4357..2822286 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -98,11 +98,11 @@ enum Urgency { // ========================================================================= model User { - id String @id @default(uuid()) - email String @unique - phone String? - passwordHash String - role Role + id String @id @default(uuid()) + email String @unique + phone String? + passwordHash String + role Role // Phase 8 (issue #34): null = never verified. Self-serve signup leaves // this null; existing rows were backfilled to `createdAt` in the // add_email_verification migration. Distinct from @@ -129,6 +129,9 @@ model User { emailVerificationTokens EmailVerificationToken[] adminInvitesSent AdminInvite[] + // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). + // Opposite of AccessLog.accessedByUser. + accessLogs AccessLog[] @@index([role]) } @@ -201,22 +204,25 @@ model LoginAttempt { } model PatientProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - dateOfBirth DateTime? + id String @id @default(uuid()) + userId String @unique + fullName String + dateOfBirth DateTime? // Nullable, no FK relation on purpose — the minor/dependent consent and // account-ownership model is an explicit open PRD question (§8.2.5), not // resolved here. Don't build on top of this field assuming it means // anything enforced yet. guardianUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] paymentIntents PaymentIntent[] dataExports PatientExport[] + + // Phase 8 (issue #45): who viewed this patient's records. + accessLogs AccessLog[] } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -341,7 +347,7 @@ model FacilityCapability { capabilityType CapabilityType // e.g. a lab test category, or a medication class — free text on purpose, // no controlled vocabulary decided yet (out of Phase 1 scope). - detail String? + detail String? facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) @@ -391,6 +397,44 @@ model Appointment { // TODO Phase 3: referralId (nullable FK -> Referral). } +// ========================================================================= +// Phase 8 (issue #45): PHI read-access logging — AccessLog +// +// Answers "who looked at this" (distinct from AuditEvent's "who changed +// this"). Written asynchronously off the read path so it never adds to +// p95 latency. Fail-open: a logging failure never fails the read, but is +// counted and surfaced for alerting. +// +// patientProfileId is the record owner (whose data was viewed) — used by +// GET /patients/me/access-log. accessedBy is the viewer. +// ========================================================================= + +model AccessLog { + id String @id @default(uuid()) + // Who viewed. SetNull on user delete keeps the log row (timestamp + + // entity remain) while removing the PII pointer. + accessedBy String? + // Which patient's records were viewed (the record owner, not the viewer). + patientProfileId String? + // Entity that was read: 'IntakeForm' | 'Consultation' | 'Prescription' | + // 'LabOrder' | 'LabResult' | 'Referral' | 'PaymentIntent' | 'PatientProfile' + entityType String + entityId String + // The HTTP route that returned the payload (for correlating with access logs). + route String? + // Free-text detail — no PHI here, just context like "GET /appointments/mine". + detail String? + accessedAt DateTime @default(now()) + + accessedByUser User? @relation(fields: [accessedBy], references: [id], onDelete: SetNull) + patientProfile PatientProfile? @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) + + @@index([accessedAt]) + @@index([accessedBy]) + @@index([patientProfileId, accessedAt]) + @@index([entityType, entityId]) +} + // ========================================================================= // Below this line: Phase 0 structural placeholders, not yet implemented. // See ROADMAP.md for phase order. @@ -403,19 +447,19 @@ model IntakeForm { } model Consultation { - id String @id @default(uuid()) + id String @id @default(uuid()) outcome ConsultationOutcome? notes String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // TODO: appointmentId (unique) } model Referral { - id String @id @default(uuid()) - status ReferralStatus @default(pending) - snapshotNotes String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + status ReferralStatus @default(pending) + snapshotNotes String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // TODO: sourceConsultationId, referringDoctorId, targetSpecialtyId, // targetDoctorId (nullable), snapshotIntakeId, resultingAppointmentId (nullable) // IMPORTANT: snapshotNotes / snapshotIntakeId must be copies, not live @@ -423,18 +467,18 @@ model Referral { } model Prescription { - id String @id @default(uuid()) - status OrderStatus @default(issued) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + status OrderStatus @default(issued) + createdAt DateTime @default(now()) // TODO: consultationId, patientProfileId, prescribingDoctorId, supersedesPrescriptionId (nullable, self-relation) } model PrescriptionItem { - id String @id @default(uuid()) - drugName String - dosage String + id String @id @default(uuid()) + drugName String + dosage String frequency String - duration String + duration String // TODO: prescriptionId } @@ -469,11 +513,11 @@ model FulfillmentRecord { } model Notification { - id String @id @default(uuid()) + id String @id @default(uuid()) type String payload Json readAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // TODO: userId } @@ -507,15 +551,15 @@ enum OrderTypeForPayment { } model PaymentIntent { - id String @id @default(uuid()) - orderType OrderTypeForPayment + id String @id @default(uuid()) + orderType OrderTypeForPayment // Opaque — Prescription/LabOrder don't exist as real records yet // (Phase 4). Not validated against anything until then. - orderId String - status PaymentIntentStatus @default(created) + orderId String + status PaymentIntentStatus @default(created) patientProfileId String - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) facilityId String facility FacilityProfile @relation(fields: [facilityId], references: [id]) diff --git a/backend/src/app.ts b/backend/src/app.ts index 834f950..337d4aa 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,7 @@ import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; import { adminRouter } from './modules/users/routes'; import { registerPatientExportSections } from './export-sections'; -import { auditRouter } from './modules/audit/routes'; +import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; import { requestId } from './middleware/request-id'; export function createApp() { @@ -48,6 +48,11 @@ export function createApp() { app.use('/admin', adminRouter); app.use('/admin', auditRouter); + // PHI read-access logging (Phase 8, issue #45). Mounted at root so the + // paths match the issue's spec exactly: /admin/access-logs, /patients/me/access-log. + app.use('/admin', adminAuditRouter); + app.use('/patients', patientAuditRouter); + // Must be registered last — Express only routes errors here once every // other handler has had a chance to run. app.use(errorHandler); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e2ee842..911967f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -87,6 +87,13 @@ export const config = { pruneIntervalSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS ?? 60 * 60), pruneAfterSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_AFTER_SECONDS ?? 24 * 60 * 60), }, + // PHI read-access log (Phase 8, issue #45). How long AccessLog rows are + // kept before the prune job (src/server.ts) deletes them. Reads are far + // higher volume than AuditEvent writes, so this gets its own, shorter + // retention instead of AuditEvent's append-forever lifetime. + accessLog: { + retentionDays: Number(process.env.ACCESS_LOG_RETENTION_DAYS ?? 365), + }, // Patient data export (Phase 8, issue #40). Env-configurable so a // deployment can tune retention and memory bounds without a code change. patientExport: { diff --git a/backend/src/modules/audit/controllers/index.ts b/backend/src/modules/audit/controllers/index.ts index b5a5d5d..f164628 100644 --- a/backend/src/modules/audit/controllers/index.ts +++ b/backend/src/modules/audit/controllers/index.ts @@ -1,9 +1,50 @@ import type { Request, Response } from 'express'; import * as auditService from '../services'; -import { listAuditEventsQuerySchema } from '../validation'; +import { getMyPatientProfile } from '../../patients/services'; +import { HttpError } from '../../../http-error'; +import { + listAuditEventsQuerySchema, + patientAccessLogQuerySchema, + accessLogByEntityQuerySchema, + accessLogByUserQuerySchema, +} from '../validation'; export async function listAuditEvents(req: Request, res: Response) { const query = listAuditEventsQuerySchema.parse(req.query); const page = await auditService.listAuditEvents(query); res.status(200).json(page); } + +// --- PHI read-access logging (Phase 8, issue #45) --------------------------- + +/** Admin: who accessed a specific kind of record (optionally a specific + * one). Viewer ids are kept — admins need attribution for incident + * response; the patient-facing endpoint is the one that strips PII. */ +export async function listAccessLogsByEntity(req: Request, res: Response) { + const query = accessLogByEntityQuerySchema.parse(req.query); + const logs = await auditService.listAccessForEntity(query); + res.status(200).json({ items: logs }); +} + +/** Admin: what a specific user accessed. */ +export async function listAccessLogsByUser(req: Request, res: Response) { + const query = accessLogByUserQuerySchema.parse(req.query); + const logs = await auditService.listAccessForUser(query); + res.status(200).json({ items: logs }); +} + +/** Admin: cumulative write-failure count + retention config (alerting). */ +export async function accessLogStats(_req: Request, res: Response) { + res.status(200).json(auditService.getAccessLogStats()); +} + +/** Patient: who viewed MY records. The profile id is resolved server-side + * from the authenticated user — never read from the request — so a caller + * can only ever see their own log. */ +export async function myAccessLog(req: Request, res: Response) { + const query = patientAccessLogQuerySchema.parse(req.query); + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const profile = await getMyPatientProfile(req.user.id); + const logs = await auditService.getAccessForPatient(profile.id, query.limit); + res.status(200).json({ items: logs }); +} diff --git a/backend/src/modules/audit/repositories/index.ts b/backend/src/modules/audit/repositories/index.ts index 18a3c43..be62a64 100644 --- a/backend/src/modules/audit/repositories/index.ts +++ b/backend/src/modules/audit/repositories/index.ts @@ -1,6 +1,13 @@ -// audit repositories — AuditEvent data access. INSERT and SELECT only: this -// table is append-only (issue #43), enforced again by a database trigger. -// There is intentionally no update or delete function here; don't add one. +// audit repositories — AuditEvent and AccessLog data access. +// +// AuditEvent (issue #43): INSERT and SELECT only — the table is +// append-only, enforced again by a database trigger. There is +// intentionally no update or delete function for it here; don't add one. +// +// AccessLog (issue #45) is a different table with a different lifecycle: +// reads are far higher volume and retention-managed, so `pruneAccessLogs` +// is the one deliberate delete — it removes rows older than the retention +// window, never arbitrary ones. import { prisma } from '../../../db/prisma-client'; import type { Prisma, Role } from '@prisma/client'; @@ -55,3 +62,83 @@ export function findAuditEvents(query: AuditEventQuery) { take: query.take, }); } + +// --- AccessLog (Phase 8, issue #45) ---------------------------------------- +// Written off the read path (fire-and-forget from the service), so this +// uses the root client, unlike insertAuditEvent above. + +export interface AccessLogRow { + accessedBy: string | null; + patientProfileId: string | null; + entityType: string; + entityId: string; + route: string | null; + detail: string | null; +} + +export function recordAccess(row: AccessLogRow) { + return prisma.accessLog.create({ data: row }); +} + +/** Viewer id/role only — callers that surface results to patients must + * strip even this (see services getAccessForPatient). */ +const ACCESS_LOG_VIEWER = { select: { id: true, role: true } } as const; + +export interface AccessLogByEntityQuery { + entityType: string; + entityId?: string; + take: number; +} + +/** Admin: who accessed a specific kind of record (optionally a specific + * one). Newest first; bounded by `take` — no unpaged dump. */ +export function findAccessByEntity(query: AccessLogByEntityQuery) { + return prisma.accessLog.findMany({ + where: { + entityType: query.entityType, + ...(query.entityId ? { entityId: query.entityId } : {}), + }, + include: { accessedByUser: ACCESS_LOG_VIEWER }, + orderBy: { accessedAt: 'desc' }, + take: query.take, + }); +} + +export interface AccessLogForPatientQuery { + patientProfileId: string; + take: number; +} + +/** Patient-facing source rows: whose records were viewed, by whom. The + * service strips the viewer's identity before this shape leaves the API. */ +export function findAccessForPatient(query: AccessLogForPatientQuery) { + return prisma.accessLog.findMany({ + where: { patientProfileId: query.patientProfileId }, + include: { accessedByUser: ACCESS_LOG_VIEWER }, + orderBy: { accessedAt: 'desc' }, + take: query.take, + }); +} + +export interface AccessLogByUserQuery { + accessedBy: string; + take: number; +} + +/** Admin: what a specific user accessed. */ +export function findAccessByUser(query: AccessLogByUserQuery) { + return prisma.accessLog.findMany({ + where: { accessedBy: query.accessedBy }, + include: { accessedByUser: ACCESS_LOG_VIEWER }, + orderBy: { accessedAt: 'desc' }, + take: query.take, + }); +} + +/** Retention pruning: removes rows strictly older than `before`. Returns + * the number of rows deleted. This is the only delete on AccessLog. */ +export function pruneAccessLogs(before: Date) { + return prisma.accessLog + .deleteMany({ where: { accessedAt: { lt: before } } }) + .then((result) => result.count); +} diff --git a/backend/src/modules/audit/routes/index.ts b/backend/src/modules/audit/routes/index.ts index a8f74aa..deb25d7 100644 --- a/backend/src/modules/audit/routes/index.ts +++ b/backend/src/modules/audit/routes/index.ts @@ -7,3 +7,22 @@ export const auditRouter = Router(); // Phase 8, issue #43 — admin-only, read-only. There is no write route: events // are only ever created by record() inside the transaction they describe. auditRouter.get('/audit-events', authenticate, requireRole('admin'), auditController.listAuditEvents); + +// Phase 8, issue #45 — PHI read-access log. Separate routers because the +// two audiences have genuinely different shapes: the admin queries keep +// viewer ids for attribution, the patient one strips all viewer PII down +// to a role. Mounted in app.ts at /admin and /patients respectively. +export const adminAuditRouter = Router(); + +// No write routes: rows are only ever written by recordAccess() off the +// read path. Order matters only between overlapping paths — here the +// stats route is a sub-path of the collection and must be listed first. +adminAuditRouter.get('/access-logs/stats', authenticate, requireRole('admin'), auditController.accessLogStats); +adminAuditRouter.get('/access-logs', authenticate, requireRole('admin'), auditController.listAccessLogsByEntity); +adminAuditRouter.get('/access-logs/user', authenticate, requireRole('admin'), auditController.listAccessLogsByUser); + +export const patientAuditRouter = Router(); + +// Scoped server-side to the caller's own patient profile — the profile id +// is resolved from the authenticated user, never from the request. +patientAuditRouter.get('/me/access-log', authenticate, requireRole('patient'), auditController.myAccessLog); diff --git a/backend/src/modules/audit/services/index.ts b/backend/src/modules/audit/services/index.ts index daea059..cf89fbe 100644 --- a/backend/src/modules/audit/services/index.ts +++ b/backend/src/modules/audit/services/index.ts @@ -10,6 +10,7 @@ // entity, action, from/to state, request id — plus a small, flat, // validated metadata map of ids/codes. Never PHI, never request bodies. import type { Prisma, Role } from '@prisma/client'; +import { config } from '../../../config'; import { HttpError } from '../../../http-error'; import * as auditRepo from '../repositories'; @@ -159,3 +160,134 @@ export async function listAuditEvents(filters: AuditEventFilters) { const last = items[items.length - 1]; return { items, nextCursor: rows.length > filters.limit && last ? encodeAuditCursor(last) : null }; } + +// --- PHI read-access logging (Phase 8, issue #45) --------------------------- +// Answers "who looked at this" — distinct from record()'s "who changed this". +// Writes are fire-and-forget (never awaited on the read path) and fail +// open: a logging failure must never fail the read, but it IS counted and +// surfaced for alerting via GET /admin/access-logs/stats. + +/** Entity kinds a read can be logged against. Mirrors the schema comment + * on AccessLog.entityType — extend both together. */ +export type AccessEntityType = + | 'IntakeForm' + | 'Consultation' + | 'Prescription' + | 'PrescriptionItem' + | 'LabOrder' + | 'LabResult' + | 'Referral' + | 'PaymentIntent' + | 'PatientProfile' + | 'Appointment'; + +export interface AccessLogInput { + /** The viewer. Null only for anonymous/system reads (there are none today). */ + accessedBy?: string | null; + /** The record owner (whose data was viewed) — used by the patient-facing + * /patients/me/access-log. Null when the read isn't attributable to one. */ + patientProfileId?: string | null; + entityType: AccessEntityType; + entityId: string; + route?: string | null; + /** Context string — no PHI, just enough to correlate with request logs. */ + detail?: string | null; +} + +/** Retention window for AccessLog rows (env ACCESS_LOG_RETENTION_DAYS). + * pruneOldAccessLogs() deletes anything older; wired into the prune + * interval in src/server.ts. */ +export const ACCESS_LOG_RETENTION_DAYS = config.accessLog.retentionDays; + +let logFailureCount = 0; + +/** Cumulative count of failed access-log writes since process start — + * readable by admins via GET /admin/access-logs/stats for alerting. */ +export function getLogFailureCount(): number { + return logFailureCount; +} + +/** Records one read, off the call path: returns immediately, never throws. + * Callers must NOT await this on a request path — the unit test asserting + * sub-50ms return with a hanging insert is the guard. */ +export function recordAccess(input: AccessLogInput): void { + void auditRepo + .recordAccess({ + accessedBy: input.accessedBy ?? null, + patientProfileId: input.patientProfileId ?? null, + entityType: input.entityType, + entityId: input.entityId, + route: input.route ?? null, + detail: input.detail ?? null, + }) + .catch((err: unknown) => { + logFailureCount += 1; + // Fail-open by design, but loudly: this is a privacy trail, so a + // silent gap is worse than a noisy one. + console.error(`[audit] FAILED to record access (${input.entityType} ${input.entityId})`, err); + }); +} + +// --- Patient-facing query (PII stripped) ------------------------------------ + +export interface PatientAccessLogEntry { + id: string; + accessedAt: Date; + entityType: string; + entityId: string; + /** The viewer's role only — never their email or internal id. A deleted + * viewer (FK SetNull) surfaces as the 'deleted_user' placeholder. */ + viewerRole: string; +} + +/** Who viewed MY records. The caller supplies only their own + * patientProfileId (resolved server-side from the authenticated user, never + * from the request), and the response carries no viewer PII. */ +export async function getAccessForPatient( + patientProfileId: string, + limit = 100, +): Promise { + const rows = await auditRepo.findAccessForPatient({ patientProfileId, take: limit }); + return rows.map((row) => ({ + id: row.id, + accessedAt: row.accessedAt, + entityType: row.entityType, + entityId: row.entityId, + viewerRole: row.accessedByUser?.role ?? 'deleted_user', + })); +} + +// --- Admin queries ---------------------------------------------------------- + +/** Admin: who accessed a specific kind of record (optionally a specific + * one). Unlike the patient-facing query this keeps viewer ids — admins + * need attribution, and role-only would be useless for incident response. */ +export function listAccessForEntity(filters: { entityType: string; entityId?: string; limit?: number }) { + return auditRepo.findAccessByEntity({ + entityType: filters.entityType, + entityId: filters.entityId, + take: filters.limit ?? 50, + }); +} + +/** Admin: what a specific user accessed. */ +export function listAccessForUser(filters: { userId: string; limit?: number }) { + return auditRepo.findAccessByUser({ + accessedBy: filters.userId, + take: filters.limit ?? 50, + }); +} + +/** Admin: cumulative write-failure count + retention config (alerting). */ +export function getAccessLogStats() { + return { logFailureCount, retentionDays: ACCESS_LOG_RETENTION_DAYS }; +} + +// --- Retention -------------------------------------------------------------- + +/** Deletes AccessLog rows older than the retention window. Returns the + * number of rows removed. Runs from the prune interval in src/server.ts. */ +export async function pruneOldAccessLogs(): Promise { + const cutoff = new Date(Date.now() - ACCESS_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + return auditRepo.pruneAccessLogs(cutoff); +} diff --git a/backend/src/modules/audit/validation.ts b/backend/src/modules/audit/validation.ts index 561ca85..59cc98c 100644 --- a/backend/src/modules/audit/validation.ts +++ b/backend/src/modules/audit/validation.ts @@ -22,3 +22,35 @@ export const listAuditEventsQuerySchema = z }) .strict() .refine((q) => !q.entityId || q.entityType, { message: 'entityId requires entityType' }); + +// Shared page bound for the access-log queries — no unpaged dump of the +// read trail, same reasoning as listAuditEventsQuerySchema's `limit`. +const accessLogLimit = z.coerce.number().int().min(1).max(200).default(50); + +// `GET /admin/access-logs` (Phase 8, issue #45). Strict: unknown query +// parameters are client bugs worth surfacing. `entityType` alone lists +// everyone who viewed that kind of record; add `entityId` to narrow to one. +export const accessLogByEntityQuerySchema = z + .object({ + entityType: z.string().min(1).max(64), + entityId: z.string().min(1).max(128).optional(), + limit: accessLogLimit, + }) + .strict(); + +// `GET /admin/access-logs/user?userId=` — what a specific user accessed. +export const accessLogByUserQuerySchema = z + .object({ + userId: z.string().min(1).max(128), + limit: accessLogLimit, + }) + .strict(); + +// `GET /patients/me/access-log` — no filters accepted: the profile is +// resolved from the authenticated user, so there is deliberately nothing +// else a patient may query by. +export const patientAccessLogQuerySchema = z + .object({ + limit: accessLogLimit, + }) + .strict(); diff --git a/backend/src/server.ts b/backend/src/server.ts index 0b7e729..2b58895 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,7 @@ import { createApp } from './app'; import { config } from './config'; import { pruneLoginAttempts } from './modules/auth/services'; +import { pruneOldAccessLogs } from './modules/audit/services'; import { startPatientExportJobs } from './jobs/patient-exports'; import { verifyPaymentsNetwork } from './modules/payments/services'; @@ -37,6 +38,22 @@ const pruneTimer = setInterval(() => { // eslint-disable-next-line no-console console.error('[jobs] login-throttle prune failed:', err); }); + + // Access-log retention (Phase 8, issue #45): drop rows older than + // config.accessLog.retentionDays. Same interval as the throttle prune — + // hourly is far more often than a 365-day window needs, but a second + // timer for one cheap DELETE is not worth the complexity. + void pruneOldAccessLogs() + .then((removed) => { + if (removed > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] pruned ${removed} expired access-log row(s)`); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('[jobs] access-log prune failed:', err); + }); }, config.loginThrottle.pruneIntervalSeconds * 1000); pruneTimer.unref(); diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 78538e6..03bb722 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -364,9 +364,21 @@ describe('AuditEvent foundation', () => { }); describe('append-only', () => { - it('has no update or delete path in the audit module', async () => { + it('has no update path, and no delete beyond access-log retention', async () => { const repo = await import('../../src/modules/audit/repositories/index.js'); - expect(Object.keys(repo).sort()).toEqual(['findAuditEvents', 'insertAuditEvent']); + // AuditEvent: INSERT + SELECT only (the DB triggers below enforce the + // rest). AccessLog (issue #45) is a different table with its own + // retention: `pruneAccessLogs` is the one deliberate delete, bounded + // to rows older than the retention window — never an arbitrary one. + expect(Object.keys(repo).sort()).toEqual([ + 'findAccessByEntity', + 'findAccessByUser', + 'findAccessForPatient', + 'findAuditEvents', + 'insertAuditEvent', + 'pruneAccessLogs', + 'recordAccess', + ]); }); it('rejects UPDATE, DELETE and TRUNCATE at the database level', async () => { diff --git a/backend/tests/unit/access-log.test.ts b/backend/tests/unit/access-log.test.ts new file mode 100644 index 0000000..19c7e24 --- /dev/null +++ b/backend/tests/unit/access-log.test.ts @@ -0,0 +1,197 @@ +// Unit tests for the PHI read-access logging service (Phase 8, issue #45). +// Tests the async fire-and-forget write path, fail-open behavior, the +// failure counter for alerting, and the patient-facing PII stripping. +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the repository before importing the service +vi.mock('../../src/modules/audit/repositories', () => ({ + recordAccess: vi.fn().mockResolvedValue({}), + findAccessByEntity: vi.fn().mockResolvedValue([]), + findAccessForPatient: vi.fn().mockResolvedValue([]), + findAccessByUser: vi.fn().mockResolvedValue([]), + pruneAccessLogs: vi.fn().mockResolvedValue(0), +})); + +import * as auditService from '../../src/modules/audit/services'; +import * as auditRepo from '../../src/modules/audit/repositories'; + +/** Waits for fire-and-forget promises to settle. */ +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('access-log service: PHI read-access logging (issue #45)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('recordAccess (async, fire-and-forget)', () => { + it('returns immediately without awaiting the insert (off the read path)', () => { + // Make the repo insert hang to prove recordAccess doesn't block on it. + vi.mocked(auditRepo.recordAccess).mockReturnValue(new Promise(() => {}) as never); + + const start = performance.now(); + auditService.recordAccess({ + accessedBy: 'user-123', + entityType: 'Consultation', + entityId: 'consult-456', + route: '/appointments/mine', + }); + const elapsed = performance.now() - start; + + // Fire-and-forget: must return in well under 1ms (no DB round-trip). + expect(elapsed).toBeLessThan(50); + }); + + it('passes all fields through to the repository', async () => { + auditService.recordAccess({ + accessedBy: 'user-123', + patientProfileId: 'profile-789', + entityType: 'Consultation', + entityId: 'consult-456', + route: '/appointments/mine', + detail: 'GET /appointments/mine', + }); + await flush(); + + expect(auditRepo.recordAccess).toHaveBeenCalledWith({ + accessedBy: 'user-123', + patientProfileId: 'profile-789', + entityType: 'Consultation', + entityId: 'consult-456', + route: '/appointments/mine', + detail: 'GET /appointments/mine', + }); + }); + + it('defaults optional fields to null', async () => { + auditService.recordAccess({ + entityType: 'IntakeForm', + entityId: 'intake-000', + }); + await flush(); + + expect(auditRepo.recordAccess).toHaveBeenCalledWith({ + accessedBy: null, + patientProfileId: null, + entityType: 'IntakeForm', + entityId: 'intake-000', + route: null, + detail: null, + }); + }); + + it('fails open: does not throw when the repository write rejects', async () => { + vi.mocked(auditRepo.recordAccess).mockRejectedValueOnce(new Error('DB down')); + + expect(() => + auditService.recordAccess({ + accessedBy: 'user-123', + entityType: 'Prescription', + entityId: 'presc-001', + }), + ).not.toThrow(); + + await flush(); + + // The failure was surfaced to the server console for operators + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('[audit] FAILED to record access'), + expect.any(Error), + ); + }); + + it('increments the failure counter on each logging failure (alerting)', async () => { + const before = auditService.getLogFailureCount(); + + vi.mocked(auditRepo.recordAccess).mockRejectedValueOnce(new Error('fail 1')); + auditService.recordAccess({ entityType: 'LabOrder', entityId: 'lab-1' }); + await flush(); + + vi.mocked(auditRepo.recordAccess).mockRejectedValueOnce(new Error('fail 2')); + auditService.recordAccess({ entityType: 'LabOrder', entityId: 'lab-2' }); + await flush(); + + expect(auditService.getLogFailureCount()).toBe(before + 2); + }); + + it('accepts all AccessEntityType variants', async () => { + const types = [ + 'IntakeForm', + 'Consultation', + 'Prescription', + 'PrescriptionItem', + 'LabOrder', + 'LabResult', + 'Referral', + 'PaymentIntent', + 'PatientProfile', + 'Appointment', + ] as const; + + for (const entityType of types) { + auditService.recordAccess({ entityType, entityId: 'x' }); + } + await flush(); + + expect(auditRepo.recordAccess).toHaveBeenCalledTimes(types.length); + }); + }); + + describe('getAccessForPatient (patient-facing, PII stripped)', () => { + it('returns only role, entity, and timestamp — no viewer email or ids', async () => { + vi.mocked(auditRepo.findAccessForPatient).mockResolvedValueOnce([ + { + id: 'log-1', + accessedAt: new Date('2026-10-16T10:00:00Z'), + entityType: 'Consultation', + entityId: 'consult-1', + accessedBy: 'doctor-user-id', + accessedByUser: { id: 'doctor-user-id', role: 'doctor' }, + }, + { + id: 'log-2', + accessedAt: new Date('2026-10-16T11:00:00Z'), + entityType: 'IntakeForm', + entityId: 'intake-1', + accessedBy: null, + accessedByUser: null, + }, + ] as never); + + const logs = await auditService.getAccessForPatient('profile-1'); + + expect(logs).toHaveLength(2); + // Patient-facing shape: only these keys — no email, no viewer internal id + for (const log of logs) { + expect(Object.keys(log).sort()).toEqual( + ['accessedAt', 'entityId', 'entityType', 'id', 'viewerRole'].sort(), + ); + expect(log).not.toHaveProperty('accessedBy'); + expect(log).not.toHaveProperty('email'); + } + expect(logs[0]!.viewerRole).toBe('doctor'); + // Deleted user (SetNull) surfaces as a placeholder, not an error + expect(logs[1]!.viewerRole).toBe('deleted_user'); + }); + }); + + describe('pruneOldAccessLogs (retention)', () => { + it('deletes rows older than the retention window', async () => { + vi.mocked(auditRepo.pruneAccessLogs).mockResolvedValueOnce(42); + + const deleted = await auditService.pruneOldAccessLogs(); + + expect(auditRepo.pruneAccessLogs).toHaveBeenCalledWith(expect.any(Date)); + expect(deleted).toBe(42); + + // The cutoff should be ~retention-days in the past + const cutoff = vi.mocked(auditRepo.pruneAccessLogs).mock.calls[0]![0] as Date; + const daysOld = (Date.now() - cutoff.getTime()) / (24 * 60 * 60 * 1000); + expect(daysOld).toBeCloseTo(auditService.ACCESS_LOG_RETENTION_DAYS, 0); + }); + }); +});