Repository navigation
fix: scope record edits and repo re-adds by grant, and refuse remote file assignments - #1899
Merged
joshunrau merged 10 commits intoNov 10, 2026
Merged
Conversation
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>
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.
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
PATCH /v1/instrument-records/:idlooks the record up and writes it scoped by the caller'supdate InstrumentRecordrules. It used to look the record up unscoped and scope the write bydeleterules. A user granted onlyupdatecan now edit records within that grant, where every request used to fail with a 500. A user whoseupdateanddeletegrants name different groups now edits in the groups theupdategrant names. A record outside the caller's scope answers 404 before the payload is validated, so a 400 no longer reveals that the id exists.POST /v1/assignments,POST /v1/assignments/bulkandPOST /v1/assignments/bulk/preflightrefuse aFILEinstrument, and a series containing one, with 422INSTRUMENT_UNAVAILABLE. The gateway cannot deliver a patient's files, so such an assignment used to end asCOMPLETEwith a record stuck atpending: true. Without storage, it failed to sync on every pass instead. The Remote Assignment page and the bulk wizard no longer offer file instruments. In-person sessions are unchanged.POST /v1/instrument-repos, which replaces its token and re-syncs it, now requiresupdateon that repository. Anyone else gets a 409 before the token is touched. A group-scopedcreate InstrumentRepogrant could previously retoken a private repository used only by another group.InstrumentsService.findselects only ids, then readsbundlejust for the instruments not already evaluated and cached./v1/instruments/info,/v1/instruments/listand the dashboard no longer load every bundle on the instance on every request. The remote-assignability check from File instruments can be assigned remotely, but the patient's files are discarded and the record stays pending forever #1708 reads bundles the same way.127.0.0.1and fetch there. They used to bind the wildcard address, which another process (the API tests' mongod) could shadow on127.0.0.1for the same port and answer instead.Decisions
count. Instrument queries read every bundle from MongoDB even when the evaluated instance is cached #1537 originally also scoped the dashboard's instrument count and made it a plain row count. fix: count every instrument edition on the dashboard #1893 has since merged acountthat counts the latest edition of each instrument the group can administer, which also resolves the edition mismatch Instrument queries read every bundle from MongoDB even when the evaluated instance is cached #1537 left open. That commit is dropped here, andfind's filter is no longer split into a helper.apps/api/test/suites/06-instrument-repo-readd.suite.tscovers it server-side, with real users, real scoped grants and the stored ciphertext read back.InstrumentsService.findUnassignableIdsonly narrows ids the caller already named. It reads onlyarchivedAtandid, and evaluates a bundle only for a non-archived instrument that isn't cached yet.Risks
Access changes for custom grants. Admins (
manage all) are unaffected, and no base level below admin holdsupdateordeleteon records. Only users with custom permissions or a permission set are affected:update InstrumentRecordgain access within that grant and lose access in groups only theirdeletegrant names;create InstrumentRepogrant 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
Authorization, and the tests check only that a download happened and the token was stored — assert the download'sAuthorizationisBearer new-tokenin both.findUnassignableIdsandcollectsFilesread every bundle even when the instance is cached, the pattern this branch removes fromfind— share one cache-aware id-to-instance helper, and selectarchivedAtandidonly.resolveBulkRequestasksfindUnassignableIdsabout 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
expectSyncedWithNewTokenasserts the download'sAuthorizationheader in both tests. Both fail againstsyncRepo(existing).findInstancesByIdsis used byfind,collectsFilesandfindUnassignableIds, which now selectsarchivedAtandidand evaluates only the non-archived ids.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