Skip to content

fix: scope record edits and repo re-adds by grant, and refuse remote file assignments - #1899

Merged
joshunrau merged 10 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/pending-prs
Nov 10, 2026
Merged

joshunrau merged 10 commits into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/pending-prs

Conversation

@joshunrau

Copy link
Copy Markdown
Collaborator

Four fixes that were planned as separate PRs, combined into one, plus a fix for a flaky gateway test. Record edits and repository re-adds now respect the group scope of the caller's grant, file instruments can no longer be assigned remotely, and instrument lists no longer read every stored bundle.

Closes #1633, closes #1708, closes #1728, closes #1537, closes #1897

Changes

Decisions

Risks

  • Access changes for custom grants. Admins (manage all) are unaffected, and no base level below admin holds update or delete on records. Only users with custom permissions or a permission set are affected:

    • holders of update InstrumentRecord gain access within that grant and lose access in groups only their delete grant names;
    • holders of a group-scoped create InstrumentRepo grant can no longer re-add, retoken or re-sync another group's repository.

    Admins should review such grants before upgrading. Whether changes like these count as breaking is undecided (Release and support policy: what a version promises, which lines get fixes, and how images and npm map onto it #1870), so the commit bodies describe them in prose, with no footer.

  • Existing remote file assignments. These are left in place. They can never deliver their files, so administrators should cancel any still outstanding from the subject's Assignments tab. On a storage-disabled instance, each submitted one logs a sync failure until it is cancelled.

Adversarial review

Reviewed at commit 851e740. pnpm check: green.

Findings

  1. apps/api/test/suites/06-instrument-repo-readd.suite.ts:161 — the administrator re-add test, and the own-group one at :148, pass against the pre-fix code that synced with the old token: the GitHub stub answers any Authorization, and the tests check only that a download happened and the token was stored — assert the download's Authorization is Bearer new-token in both.
  2. apps/api/src/instruments/instruments.service.ts:438 — findUnassignableIds and collectsFiles read every bundle even when the instance is cached, the pattern this branch removes from find — share one cache-aware id-to-instance helper, and select archivedAt and id only.
  3. apps/api/src/assignments/assignments.service.ts:292 — resolveBulkRequest asks findUnassignableIds about instruments the group has already refused, evaluating bundles for nothing (a broken one would turn the 422 into a 500), and a unit test pins that call — pass only opted-in ids and test the returned issues instead.

Fixed

    1. expectSyncedWithNewToken asserts the download's Authorization header in both tests. Both fail against syncRepo(existing).
    1. A private findInstancesByIds is used by find, collectsFiles and findUnassignableIds, which now selects archivedAt and id and evaluates only the non-archived ids.
    1. Only opted-in ids are passed. The test now checks that a refused instrument whose evaluation would throw still gets INSTRUMENT_UNAVAILABLE.

Attestation

I, Claude Opus 5.5, attest that the review block above reports the adversarial review as it ran on this branch at 5f30825, which the commit named differs from only by the fixes listed above, with every finding it returned.

🤖 Generated with Claude Code

joshunrau and others added 10 commits November 9, 2026 19:36
PATCH /v1/instrument-records/:id is gated on `update InstrumentRecord`,
but InstrumentRecordsService.updateById looked the record up with no
scope at all and then scoped the write by the caller's `delete` rules.
Both now use the caller's `update` rules, and the ability is a required
parameter, so a record outside the caller's scope is a 404 before the
payload is merged or validated.

Admins hold `manage all` and are unaffected. No base level below admin
holds `update` or `delete` on records, so this changes access only for
users granted those actions through custom permissions or a permission
set:

- A user granted `update InstrumentRecord` without `delete` can now edit
  records within that grant. Before, every such request failed with a
  500.
- A user whose `update` and `delete` grants name different groups now
  edits records in the groups their `update` grant names, and can no
  longer edit records in groups only their `delete` grant names.
- A payload sent for a record outside the caller's scope now answers 404
  instead of being merged and validated against it, so a 400 no longer
  reveals that the id exists.

Admins should review such grants before upgrading.

Closes DouglasNeuroInformatics#1633

Co-Authored-By: Claude Opus 5.5
A remote assignment has no way to deliver a patient's files: the gateway
submits only the result data, so the import either writes a record that
stays pending forever (storage enabled) or fails and retries on every
synchronizer pass (storage disabled). Bulk upload already refuses file
instruments for the same reason.

POST /v1/assignments, POST /v1/assignments/bulk and the bulk preflight now
report a FILE instrument, and a series with a FILE item, as
INSTRUMENT_UNAVAILABLE (422). The check replaces AssignmentsService's
archived-instrument lookup with InstrumentsService.findUnassignableIds,
which evaluates each requested bundle (and each item of a series) and
still refuses archived series. In-person sessions are unaffected.

Note for administrators: remote assignments of file instruments created
before this change are left in place. They can never deliver their files,
so cancel any that are still outstanding by hand from the subject's
Assignments tab. Records they already produced remain pending and hold no
recoverable data.

Refs DouglasNeuroInformatics#1708

Co-Authored-By: Claude Opus 5.5
The Remote Assignment page and the group's bulk Remote Assignments wizard
no longer offer file instruments, which the API now refuses to assign
remotely because the gateway cannot deliver a patient's uploads. The
in-person instrument list still offers them. A series with a file item is
left for the API to refuse, since an item pinned to an older edition is
absent from the instrument list the client has.

Refs DouglasNeuroInformatics#1708

Co-Authored-By: Claude Opus 5.5
The Remote Assignment page no longer offers Arbitrary File Upload, which
the in-person instrument list still offers, and POST /v1/assignments
answers 422 INSTRUMENT_UNAVAILABLE for a file instrument, with or without
a group, and for a series with a file item, creating nothing.

Refs DouglasNeuroInformatics#1708

Co-Authored-By: Claude Opus 5.5
POST /v1/instrument-repos treats the URL of a registered repository as
a re-sync, storing any access token sent with it and then syncing. It
did that for every caller the route admits, ignoring the group scope of
their grant, so a user granted create InstrumentRepo for one group could
replace the token of a private repository only another group uses, and
break every later sync of it.

The controller now forwards the caller's ability, and create re-syncs a
registered repository only for a caller who may update it. Anyone else
gets a 409 before the token is read or written. The check is ability.can
on the repository's groups rather than accessibleQuery, which throws a
500 for a caller holding no update rule at all. The lookup stays by URL
alone, since the URL is unique and the 409 has to say so.

Who loses what: holders of a group-scoped create InstrumentRepo grant can
no longer re-add, and so retoken or re-sync, a repository outside their
group. Administrators lose nothing, and re-adding with a new token is
still how one replaces an expired token. The earlier sync fix
(d2cf1e6) narrowed group-scoped update InstrumentRepo grants in the
same way, since POST /v1/instrument-repos/:id/sync now answers 404 for a
repository outside the grant's group.

Closes DouglasNeuroInformatics#1728

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`InstrumentsService.find` loaded every matching instrument document, bundle included, and then
served most of them from the evaluated-instance cache, ignoring the bundle it had just read. The
cost of each `/v1/instruments/info`, `/v1/instruments/list` and dashboard load grew with the total
size of every bundle on the instance.

`find` now selects only `id`, then reads `bundle` for just the ids missing from
`virtualizationService.context.instruments`. A row deleted between the two queries has no instance
and is dropped, rather than returned as `undefined`.

Refs DouglasNeuroInformatics#1537

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-adding a registered repository with a new access token stored the
token, but then synced the record read before the update, so the sync
the re-add started still downloaded with the old token and failed with
a 502 when that token had expired. The sync now uses the updated record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… share

The server and router tests listened on port 0 with no host, which binds
the wildcard address, and then fetched localhost or 127.0.0.1. A second
process can still bind 127.0.0.1 on that same port, and the more specific
bind takes the IPv4 connection. During a full unit run the API project's
mongodb-memory-server can: it probes for a free port, then mongod binds
it on 127.0.0.1, and mongod answers HTTP with a 200. A request was then
answered without reaching the server under test.

Each test server now binds 127.0.0.1 and is fetched there. A specific
bind cannot be shared, so the request always reaches it.

Closes DouglasNeuroInformatics#1897

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ability

`findUnassignableIds` and `collectsFiles` read the bundle of every
requested instrument and series item, even one already evaluated, which
`find` has stopped doing. All three now resolve ids to instances through
one private `findInstancesByIds`, which reads a bundle only for an
instrument missing from the cache. `findUnassignableIds` reads
`archivedAt` and `id` first and evaluates only the non-archived ones.

Bulk assignment now asks only about instruments the group has opted
into. Any other instrument is already refused, and evaluating its bundle
could only fail, which would have turned the 422 into a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration tests for re-adding a repository checked that a token
was stored and that something was downloaded, which both held while the
sync still used the old token. They now check the download's
`Authorization` header, for the administrator and for a user whose
update grant covers the repository's group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joshunrau
joshunrau merged commit 6c8d3c1 into DouglasNeuroInformatics:main Nov 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment