Skip to content

feat: record admin changes in the audit log and offer only the filters it writes - #1881

Open
joshunrau wants to merge 10 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:worktree-agent-a8dee46c1541804f2
Open

joshunrau wants to merge 10 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:worktree-agent-a8dee46c1541804f2

Conversation

@joshunrau

Copy link
Copy Markdown
Collaborator

The audit log offered Entity and Action filters that nothing ever wrote, and most admin changes were never recorded. This makes the written pairs a single declared list, derives the filters from it, and records the admin activity the triage asked for. The log stays an admin activity log: an update names the fields it changed, never their values.

Reviews commit by commit: four Phase 1 commits, then one per entity.

Phase 1

  • AUDITED_ACTIONS in packages/schemas/src/audit/audit.ts declares every (entity, action) pair the API writes.
  • AuditLogger.log is typed against it, so a call with an undeclared pair does not compile.
  • The web Entity and Action menus are derived from it, and each is narrowed by the other's choice. Session is no longer offered. The Prisma enums are unchanged.
  • .agents/docs/architecture/auth-and-permissions.md and the comment in admin-audit-logs.spec.ts now point at the map instead of listing call sites.

Phase 2

Entity Events now written Where Metadata
User CREATE, UPDATE (admin edit, permissions, self-edit); archive and unarchive become ARCHIVE / UNARCHIVE create in UsersController (setup and demo seeding share the service method), the rest in UsersService targetUserId, plus fields on updates
Assignment DELETE, one entry per assignment actually deleted, in its group AssignmentsService.deleteBulk assignmentId
Instrument series CREATE and DELETE, in the owning group InstrumentsService instrumentId, title
Subject DELETE, one entry, groupId: null SubjectsController, after the service call returns subjectId, force, recordCount
Instrument record UPDATE, DELETE update in InstrumentRecordsService.updateById; delete in the controller (the gateway rollback shares the service method) recordId and fields; on delete also instrumentId, subjectId
Group CREATE, UPDATE create in GroupsController (demo seeding shares the service method), update in GroupsService fields on updates

Updates go through AuditLogger.logUpdate with changedFields (apps/api/src/audit/audit.utils.ts). It compares the stored row with the written one, ignores updatedAt, and treats a reordered list of ids as unchanged, because the web forms resubmit every field and rebuild id lists in their own order. An update that changed nothing writes no entry, which matches how series archiving already behaves. Entries are still written after the change, not in its transaction. Sessions and record creation are not logged, as decided.

Services that now receive the request user instead of the ability: UsersService.updatePermissions, AssignmentsService.deleteBulk, GroupsService.updateById and InstrumentRecordsService.updateById. No route's path, guard, body or response changed.

Judgment calls

  • "Changed fields" means fields whose stored value changed, not the keys the request sent. Otherwise every save of the user or group form would list every field.
  • The drop-the-other-choice rule is not written. With both menus narrowed by each other, neither menu can offer a choice that invalidates the other, so that code could never run.
  • User archive metadata loses its action key, now that the action column says ARCHIVE or UNARCHIVE. Earlier rows stay as they were, under UPDATE.
  • Deleted assignments are logged one per assignment (the same as emailed links), not one per bulk request.
  • The record count for a subject delete is read in the controller just before the delete, and only when force is set. An unforced delete removes no records, and a caller with delete Subject may hold no rule on records at all. SubjectsService.countDeletableRecords uses the same scope as the forced delete. deleteById itself is untouched, for Force-deleting a subject who has a remote assignment fails with 500 and deletes nothing #1748.
  • InstrumentRecordsService.updateById copies the stored data before merging, because lodash mergeWith writes into its target. Without the copy the diff would always be empty. A unit test pins this.
  • changedFields throws a TypeError on a value with no fields. Record data is typed as any JSON value, and an empty diff would hide the update.
  • Updates read the stored row first. UsersService.updateById now always loads the user, where before it did so only to check a new password. A missing id is therefore a 404 before any write. updateSelfById reads by id alone, like its existing where: { id } write. A group manager in no group holds no read User rule that matches themselves, so a scoped read would 404 their own profile save.

Verification

  • Unit: audit.utils.test.ts and audit.logger.test.ts (including a @ts-expect-error call with an undeclared pair); each service and controller test asserts its new log call and the refused-path no-op; logs.test.tsx covers the derived Entity and Action options and the narrowing, with logFixture paired as SEND_EMAIL / ASSIGNMENT.
  • E2E: testing/src/specs/admin-audit-logs.spec.ts filters by an entity still offered and asserts Session is gone. A new admin audit log coverage block has one test per event: users, assignments, series, subjects, records and groups. Group creation is also found through the page's group, entity and action filters. Subject and record events are checked through ApiClient.findAuditLogs, which now takes an entity.

Results: pnpm lint green. pnpm test:coverage 100% statements, branches, functions and lines, 450 of 450 files. pnpm test:e2e on chromium with one worker: 369 of 370 passed.

Exceptions:

  • Firefox e2e was not run locally, to keep machine load down; CI runs the @smoke set.
  • One unrelated e2e failure in the full run. admin-instrument-repos.spec.ts:196 ("should fit both tables to the page without a horizontal scroll bar") measured an overflowing sync log table. That spec passed 25 of 25 when re-run on its own, and nothing on this branch touches that page.

Closes #1473

Co-Authored-By: Claude Opus 5.5

…records

AUDITED_ACTIONS is the one list of pairs the API writes, so the logger and
the audit log filters can be derived from it instead of from the two enums,
which also hold values nothing writes.

Co-Authored-By: Claude Opus 5.5
A log call whose entity and action are not in AUDITED_ACTIONS no longer
compiles, so a new event cannot be written without the audit log filters
learning about it.

Co-Authored-By: Claude Opus 5.5
The Entity and Action menus are derived from AUDITED_ACTIONS instead of
listing every enum value, and each is narrowed to what the other's choice
allows, so no filter combination can match an entry nothing ever writes.
Session no longer appears, since nothing logs it.

Co-Authored-By: Claude Opus 5.5
The architecture doc and the audit log spec both listed which calls write
an entry, and both had gone stale. They now name the map instead.

Co-Authored-By: Claude Opus 5.5
…e audit log

An admin creating a user, editing one, or replacing their permissions, and
a user editing their own account, now each write an entry. An update names
the fields it changed and never their values, and one that changed nothing
writes no entry. Archiving and unarchiving a user are recorded as ARCHIVE
and UNARCHIVE instead of UPDATE, as they are for instruments.

User creation is logged in the controller, because setup and demo seeding
call the same service method with no one acting.

Co-Authored-By: Claude Opus 5.5
Deleting assignments in bulk writes one DELETE entry per assignment that
was actually removed, in that assignment's group, naming it. An assignment
that failed to delete is not recorded. The route now forwards the request
user, so the service can attribute the entries.

Co-Authored-By: Claude Opus 5.5
Creating a series and deleting one each write an entry in the group that
owns the series, naming it by id and title, as archiving one already did.
A request that only reports a duplicate creates nothing and records
nothing.

Co-Authored-By: Claude Opus 5.5
Deleting a subject writes one entry, carrying whether the delete was
forced and how many records it removed, rather than one per cascaded row.
A subject can belong to several groups and the route names none, so the
entry names no group. It is written in the controller once the service
call returns, leaving the delete and its transaction as they were.

Co-Authored-By: Claude Opus 5.5
…t log

Updating a record writes an entry naming the data fields the update
changed, never their values, in the record's group; an update that
changed nothing writes none. Deleting a record writes an entry naming the
record, its instrument and its subject. The delete is logged in the
controller, because the gateway synchronizer calls the same service
method to roll back.

changedFields now refuses a value with no fields, since record data is
typed as any JSON value.

Co-Authored-By: Claude Opus 5.5
Creating a group writes an entry in the new group, and updating one
writes an entry naming the fields the update changed. An update that
changed nothing writes none, so a resubmitted settings form adds no entry.
Creation is logged in the controller, because demo seeding calls the same
service method with no one acting.

Co-Authored-By: Claude Opus 5.5

This branch has not been deployed

No deployments
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.

Audit log UI offers Entity filters that can never return a result

1 participant