Repository navigation
Plan 4b: the platform audit log (reads, chain verification, AuditorWrite) - #88
Merged
Merged
Conversation
Types, kinds (with their type, target kinds and note flag), facts keys and filter shapes as mhai-web's audit screens accept them, copied from audit-contract.ts, kinds.ts and read.ts. The six kinds Plan 4 sends that mhai-web has yet to ship are listed under pending_in_web.
Migration 00014: audit_verify_runs (one running run, the proof fields an intact run needs, a 1000-character outcome note on broken runs only), mhai_platform's audit append narrowed to spec 5.5's eight actions with no hospital, and the four platform SECURITY DEFINER functions with their final signatures and a refusal body until Tasks 11-13. audit/catalogue.go maps each action to the contract's type, kind, target, outcome, flag and facts; anything unmapped, withheld, off its schema or pseudonym-shaped is sent as the bare entry. The six audit.* actions get closed schemas.
… job rows - The intact proof CHECK is NULL-safe (entries_checked NULL passed it). - audit_verify_runs: the platform inserts only running runs, and a trigger fixes a finished run but for its outcome and version. - job.retried and job.cancelled map to the platform's jobs_* kinds only for the batch row (job_count, no job, no entity); per-job rows are system rows and are withheld (R24). - C17 pins the exact relations, sequences and SECURITY DEFINER functions mhai_platform can reach, and that it is a member of no role. - The catalogue fuzz requires a schema-valid payload to map unless its row is withheld or gated, and every row to map; the runtime pseudonym guard has its own test. - The platform append test finds its row by its own id.
GET /platform/audit, /platform/audit/{id}, /platform/audit/hospitals and
/platform/audit/people for platform admins and auditors (platform
audience, two-step always), in authz.Tx as mhai_platform. Entries leave
only through the catalogue (Map, or the bare entry); the actor, hospital
and request id are added from the database and the whole entry is dropped
to the bare form if anything in it has the pseudonym's shape (C22).
The type, outcome and flagged filters run in SQL over conditions that
Classify evaluates in Go; a test holds Classify against Map. The cursor
is base64url(last id, sha256(canonical filters)[:16]) and is refused
under other filters (C33). total counts to 10000 with total_capped. q is
an exact event or request id, ignores the other filters and is refused
when pseudonym-shaped.
The first page is recorded once per session, list and filters for 5
minutes: the dedupe key is an idempotency_keys row inserted in the same
transaction as audit.viewed or audit.searched, so both commit or neither.
A cursor read records nothing. after_break follows the latest broken
verify run while its failure is open. The deferred routes are not
registered and answer 404. openapi 1.4.0, additive.
The cursor was base64url(last id, filter hash) with no key, and a cursor
page skipped the read record, so a forged cursor (last = MaxInt64 and the
filter hash) read the newest page with no audit.viewed row. Now:
- The cursor is hex(last id || HMAC-SHA256 tag[:16]) under a key derived
with HKDF from MFA_ENCRYPTION_KEY ("mhai audit cursor key v1"), over the
session id, the canonical filters and the id. A forged, altered, other
session's or other filters' cursor is 422 validation_failed. Without a
usable key the list is 503 not_ready. Hex can't form a pseudonym.
- Every page calls RecordRead; the 5-minute dedupe (session, list, filter
hash, q included) collapses a page load into one row, and each distinct
search is its own row.
- 00014 adds audit_log (actor_id, id) for the person filter (and drops it
on Down); a test checks the plan uses it.
- TODO: mhai-web to add an analyst actor role.
…ople paging
- GET /platform/audit/{id} records the read as the exact-id search for
that id (audit.searched, searched_kind event, entity audit_event), in
the read's transaction and deduped per session and id for 5 minutes.
If the row can't be written the reply is 500 with no entry.
- The person picker and the hospitals list are recorded as audit.searched
with searched_kind person or hospital, never the text; the person
search is deduped on a hash of q. mhai-web has no such searched_kind,
so the catalogue gates audit.searched to event and request
(searchedInWeb) and the new rows are sent bare (TODO for mhai-web). The
reader's SQL gates are now a table checked against each When.
- 00014 adds audit_log (request_id), dropped on Down; a test checks the
plan uses it.
- People search pages on (display_name, id) until 20 showable people or
the end of the matches.
- openapi: q's pattern is what the handler takes; the recorded reads are
described.
…that admits auditors
…n when it finishes
… and undisplayable routes
…nd stays broken; every tick refreshes the gauge, closes stale runs, and is due by the database clock
…d never makes one up
… recorded, undisplayable dedupe, note re-check, half-set contact, Down test, spec wording
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What 4b adds
The audit log for platform admins and auditors, on the platform audience as
/platform/audit(Plan 4, part 4b; frontend contract §14.9).GET /platform/audit(filters, exact-idq, session-bound HMAC cursor),/{id},/hospitals,/people,POST /platform/audit/undisplayable. A catalogue maps chain rows to mhai-web's types and kinds; a row it can't map is sent as the bare entry, never as stored.audit.viewedoraudit.searchedentry, deduped per session for 5 minutes in the same transaction.POST /platform/audit/verify,GET .../verify/runs,/runs/{id},/status, and an auditor'sPOST .../runs/{id}/outcome. A check walks the whole chain in keyset pages inside one read-only snapshot, with a 10-minute budget; it isintactonly when the whole chain was walked. A daily check runs at 06:00 UTC and sets theaudit_chain_brokengauge.AuditorWrite: a route rule that lets auditors make exactly the audit writes the contract gives them, and nothing else.SECURITY_CONTACT_NAMEandSECURITY_CONTACT_EMAILare required outside development.deploy/setup-host.shasks for them, checks them as core does (a Go test keeps the two in step) and refuses one without the other.TestSmokeAuditLog(a synthetic auditor overapi.v1.platform, two-step enrolled: start a check, poll it tointact, read the status, find clinician B'sreview_submittedundertype=review). The smoke stack gets abff_platformpassword and a synthetic contact.Migration 00014
audit_verify_runs(with a transition trigger and proof-field CHECKs),mhai_platform's append toaudit_lognarrowed to the eight audit actions, indexes onaudit_log (actor_id, id)and(request_id), and four SECURITY DEFINER read functions whose bodies Tasks 11 to 13 (4c) fill. Down restores 00003's append and drops both indexes (TestMigration14DownRestoresPlatformAppend).OpenAPI 1.6.0
Merged on top of main's 1.4.0 (
mfa_enrolled) and this branch's 1.5.0..github/scripts/openapi-breaking.shagainstorigin/main: no breaking changes.Rulings (ledger R21 to R31)
review_submittedsends nofacts.state(mhai-web has no approved/rejected value).audit_undisplayable_reportedis already in mhai-web).job.retried/job.cancelledrows are system rows and stay withheld;jobs_*kinds are for batch rows only.idempotency_keys(scopeaudit.read, 5 minutes) in the same transaction, not a KV bucket.audit.searcheddedupe key includes a hash ofq, so each distinct search is recorded.audit_log(actor_id, id)index in 00014; hex HMAC cursor so it never has a pseudonym's shape.open_failureis aVerifyRunor null, set exactly while the failure is open.VerifyRunon the platform routes, never elsewhere), the 00014 Down test; M2, M4, M7, M8 went to TODO.md.audit.searched.Deferred (TODO.md, spec §16)
answer_agreed,review_withdrawn,review_refused,learning_recorded,audit_check_started,audit_check_finished),searched_kindperson/hospital/run, ananalystactor role, andapproved/rejectedonreview_submitted; until then those entries show as "can't be displayed"./{id}/patients, named access, exports, the bell and email on a broken chain, signed checkpoints.TestTwoCoreCopiesShareGrants(internal/grant), seen once in CI.Deploy
The demo needs
SECURITY_CONTACT_NAMEandSECURITY_CONTACT_EMAILin/srv/mhai-backend/.envfor the integrity status; until they are set,GET /platform/audit/verify/statusanswers 503 (seedeploy/README.md).Test evidence (remote, steven-reid)
make smoke: pass, all five smoke tests includingTestSmokeAuditLog.go vet, staticcheck 2025.1, andgo test -raceplain,-tags integration,-tags "integration test": all green.pytest -m "not models and not boundary": green (nothing Python changed since).🤖 Generated with Claude Code