Skip to content

feat(audit): AuditEvent table and write path for existing state transitions - #158

Merged
afeezorobsco-cyber merged 1 commit into
DocForum:mainfrom
zainabwahab-eth:feat/43-auditevent-foundation
Oct 18, 2026
Merged

afeezorobsco-cyber merged 1 commit into
DocForum:mainfrom
zainabwahab-eth:feat/43-auditevent-foundation

Conversation

@zainabwahab-eth

Copy link
Copy Markdown
Contributor

Closes #43

What changed

AuditEvent model (ARCHITECTURE.md §4.10, migration 20261017212335_add_audit_event). Fields: actorUserId, actorRole, entityType, entityId, action, fromState, toState, requestId, metadata (jsonb), createdAt. Indexes on (entityType, entityId, createdAt), (actorUserId, createdAt) and (createdAt, id).

  • Append-only, enforced twice. There is no update or delete path in code (the repository exports only insertAuditEvent and findAuditEvents, and a test asserts exactly that). In the database, a BEFORE UPDATE OR DELETE row trigger and a BEFORE TRUNCATE statement trigger reject mutation. A future retention policy has to drop and recreate them in its own reviewed migration.
  • No foreign keys, on purpose. An ON DELETE action would have to rewrite audit rows, which the trigger forbids. RESTRICT would let the audit trail block account lifecycle (e.g. [Phase 8] Account deletion with clinical-record retention: anonymise identity, keep the audit trail #41's purge). Ids are recorded as facts.

audit module (routes/controllers/services/repositories):

  • record(tx, actor, event) refuses the root Prisma client at runtime, so it can only run inside the transaction of the change it describes. If the change rolls back, so does its event.
  • Metadata must be a flat map of at most 16 scalars, with strings up to 200 chars. Any key that could carry personal or secret data (name, email, phone, dob, notes, intake, symptom, password, secret, token, key, body, payload, ...) is refused. Callers pass only ids and codes.
  • Request ids: a new middleware keeps a well-formed inbound X-Request-Id or mints a UUID, and echoes it on every response. Controllers pass auditActor(req) (user id, role, request id).

Wired transitions. States are taken from the real implementation, not invented:

Flow Action from → to Where
Booking appointment.booked null → scheduled third step inside bookSlot's existing transaction, after claimOpenSlot and the Appointment insert. The concurrency mechanism is untouched; race losers fail at step 1 and record nothing.
Doctor verification doctor.verification_status_changed previous → new previous status read FOR UPDATE in the same transaction, so concurrent admins each record the real from-state. Setting the same status records nothing.
Payments payment_intent.funded / .released / .refunded created → escrowed, escrowed → released, escrowed → refunded the status write and the event commit together. Metadata is public on-chain references only (tx hash, escrow id, contract id, amount, order type).

Behaviour change in payments, needed for a truthful from-state. The status writes are now conditional on the observed state (UPDATE ... WHERE status = 'escrowed'). Previously a racing second fund/release/refund silently overwrote the first; now it gets 409 and records nothing. If the on-chain call already succeeded for the losing request, the ledger is ahead of the database. That gap already existed for crashes at the same point; reconciling it is #24's scope (fundPaymentIntent check-then-act).

GET /admin/audit-events (admin only):

  • Filters: entityType, entityId, actorUserId, from/to.
  • limit is 1–200 (default 50), so there's no unbounded dump. Unknown parameters return 400.
  • Ordering is (createdAt DESC, id DESC) with an opaque keyset cursor on that pair, returned as { items, nextCursor }. No existing pagination pattern was in the repo to reuse.

Docs

  • ARCHITECTURE.md §3 (audit module, and the rule for Phase 3/4) and §4.10 (model and recorded transitions)
  • §6.2.4 marked "foundation resolved", with the remaining gaps listed
  • ARCHITECTURE_ESSENTIALS.md known-gaps entry rewritten
  • docs/api/README.md (endpoint, X-Request-Id, the payments 409)
  • ROADMAP.md: Phase 8 item marked "foundation done, Phase 3/4 wiring pending", plus a changelog line

Tests

tests/integration/audit.test.mts (20, own embedded Postgres on port 5441). These run the real flows over HTTP, not the service in isolation. The escrow SDK is faked through a new test seam (setEscrowSdkForTests).

  • Booking:
    • an event with the right actor, role, entity, action, states, request id and timestamp, and no email or name in it
    • a request id is minted when the inbound one is malformed
    • a 5-way race gives one appointment and one event
  • Rollback coupling: a real BEFORE INSERT trigger that fails the audit write, installed in the test DB (no mocks).
    • Booking returns 500, the slot stays open, there is no appointment and no event. The retry then succeeds.
    • Doctor verification and payment fund roll back the same way.
  • Verification: pending→verified→rejected recorded with the admin actor; a no-op set records nothing.
  • Payments:
    • created→escrowed→released and escrowed→refunded, with tx hash in metadata and no platform secrets (sentinel values checked)
    • an on-chain failure leaves the status escrowed and records no released event
    • an invalid transition gets 409 and no event
    • a release-vs-refund race gives exactly one 200 and one 409, and exactly one terminal event matching the final status
  • Append-only: update, updateMany, delete, deleteMany and TRUNCATE are all rejected by the database, and the rows are unchanged. record() with the root client is refused.
  • Endpoint:
    • 401 and 403 for patient and doctor
    • entity, entity+id, actor and date-range filters
    • 7 invalid queries give 400
    • pagination over 23 events with identical createdAt: every event exactly once, in a total order, across 5 pages, with a newer event inserted mid-scan not disturbing the remaining pages; a repeat scan is deterministic
  • Every booked slot still has exactly one appointment.

tests/unit/audit.test.ts (26): metadata rules (16 forbidden-key cases, nested values, long strings, NaN, too many keys), record() guards, cursor round-trip and rejection, query schema, request-id middleware.

Mutation checks:

  • Moving the booking's record() into a separate transaction after the booking fails the booking and rollback tests.
  • Dropping the id tie-break from the cursor fails the pagination test.
npm run typecheck        → clean
npm test                 → Test Files 7 passed (7) · Tests 56 passed (56)
npm run test:integration → Test Files 5 passed (5) · Tests 42 passed (42)   (existing booking/csrf/email/throttle suites unchanged and passing)
npm run build            → clean

These were run in Ubuntu (WSL) on Node 20 to match CI, with --hookTimeout raised locally (the existing suites' beforeAll exceeds 10s on this machine). tests/testnet-integration/payments.test.mts was updated for the new actor argument and typechecks, but was not run: it needs live testnet and funded platform identities.

Not done here (explicitly)

  • Phase 3/4/5 wiring (referrals, prescriptions, lab orders, fulfillment): those modules don't exist yet, and each must call record() in its own transaction when built.
  • POST /auth/login-attempts/clear still logs to console only. It's out of the issue's three flows, and the comment and docs now say so.
  • The app connects as the database owner, which could disable the triggers. Enforcing append-only against the owner needs a separate least-privilege DB role, which is deployment work ([Phase 9] ADR: choose production deployment target #22).

Notes for reviewers

🤖 Generated with Claude Code

…itions

- AuditEvent model + migration: actor user/role, entity type/id, action,
  from/to state, request id, minimal validated metadata, createdAt.
  Append-only: no update/delete path in code, and DB triggers reject
  UPDATE/DELETE (row) and TRUNCATE (statement). No FKs, on purpose.
- New audit module. record(tx, actor, event) refuses the root client, so
  an event can only be written inside the transaction of the change it
  describes. Metadata is limited to flat scalars, bounded, and keys that
  could carry PHI or secrets are refused.
- X-Request-Id middleware (keeps a sane inbound id, otherwise mints a
  UUID); controllers pass auditActor(req).
- Wired into appointment booking (inside bookSlot's existing transaction,
  after the slot claim), doctor verification (previous status read FOR
  UPDATE; a no-op records nothing), and payment fund/release/refund (status
  writes conditional on the observed state; a racing second action gets
  409 and records nothing).
- GET /admin/audit-events: admin-only, entity/actor/date filters,
  (createdAt, id) keyset cursor, limit capped at 200.
- Escrow SDK test seam so the payment flows are tested without testnet.

Phase 3/4 transitions are explicitly left to wire when they're built.

Closes DocForum#43

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@zainabwahab-eth Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber
afeezorobsco-cyber merged commit e2043df into DocForum:main Oct 18, 2026
1 check passed
zainabwahab-eth added a commit to zainabwahab-eth/docforum-core that referenced this pull request Oct 19, 2026
Brings in DocForum#158 (AuditEvent), DocForum#147 (access-control matrix) and DocForum#150
(field-level encryption), and fixes what broke CI on the PR merge ref:

- Conflict resolution keeps both sides in app.ts, appointments, payments
  and doctors services/repositories, using DocForum#43's tx-aware
  updateVerificationStatus/markEscrowed/markStatus.
- The patient-export suite moved from port 5438 to 5442. DocForum#147's
  access-control suite also uses 5438, and the parallel integration
  files collided.
- The three /patients/me/export routes are added to DocForum#147's
  access-control matrix (KNOWN_ROUTES + expected status per actor).
- ARCHITECTURE: the export model section is renumbered to §4.11
  (§4.10 is now the audit trail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zainabwahab-eth added a commit to zainabwahab-eth/docforum-core that referenced this pull request Oct 19, 2026
Brings in DocForum#158 (AuditEvent), DocForum#147 (access-control matrix) and DocForum#150.
Conflicts in app.ts, ROADMAP.md and docs/api resolved by keeping both
sides. DELETE /patients/me is added to DocForum#147's access-control matrix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zainabwahab-eth added a commit to zainabwahab-eth/docforum-core that referenced this pull request Oct 19, 2026
Brings in DocForum#158 (AuditEvent), DocForum#147 (access-control matrix) and DocForum#150.

- bookSlot keeps both changes: bookSlot(actor, slotId, patientProfileId?).
  Guardian authorisation (resolveActingProfile) still runs before the
  transaction, and DocForum#43's audit write stays inside it. The event's
  metadata now names the patientProfileId booked for, so a guardian
  booking is attributable (actor = guardian), and an integration test
  asserts it.
- app.ts mounts both /patients and /admin.
- POST/GET /patients/me/dependents are added to DocForum#147's access-control
  matrix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] AuditEvent table and write path for existing state transitions

2 participants