Repository navigation
Description
PATCH /v1/instrument-records/:id is gated on update InstrumentRecord, but InstrumentRecordsService.updateById scopes the write with the caller's delete rules. There are two effects:
- A caller who holds
updateonInstrumentRecordbut nodeleterule passes the guard, thenaccessibleQuery(ability, 'delete', ...)throws CASL'sForbiddenError, which surfaces as a 500. Granting a non-admin user "update instrument records" throughadditionalPermissionstherefore does not let them update anything. - When
updateanddeletegrants have different scopes, the wrong rows are writable. Take a user grantedupdate InstrumentRecordconfined to group A anddelete InstrumentRecordconfined to group B. They pass the guard, cannot update records in group A (which their update grant covers), and can update records in group B (which it does not). Authorization follows thedeletepolicy instead of the declaredupdatepolicy.
Only ADMIN (manage all) holds both actions by default, so admins are unaffected. The problem is limited to users given these actions through additionalPermissions (apps/api/src/auth/ability.factory.ts:90-100).
Where
apps/api/src/instrument-records/instrument-records.controller.ts:98-105:@RouteAccess({ action: 'update', subject: 'InstrumentRecord' })apps/api/src/instrument-records/instrument-records.service.ts:398-405:return this.instrumentRecordModel.update({ ... where: { AND: [accessibleQuery(ability, 'delete', 'InstrumentRecord')], id } });
- Also, the lookup at
instrument-records.service.ts:367-369(findFirst({ where: { id } })) is not scoped at all. A caller can merge and validate their payload against a record outside their groups, and can tell from a 404 versus a 400 or 500 whether an ID exists.
Reproduce
- Grant a STANDARD user the additional permission
update InstrumentRecord(no group). - As that user,
PATCH /v1/instrument-records/<id>with a valid partial payload.
Actual: 500 (ForbiddenError: It's not allowed to run "delete" on "InstrumentRecord").
Expected: 200 with the updated record.
Mixed scope: grant update InstrumentRecord scoped to group A and delete InstrumentRecord scoped to group B. Patching a group B record succeeds, while patching a group A record fails. Checked against @casl/prisma: ability.can('update', 'InstrumentRecord') is true, and accessibleBy(ability, 'delete').InstrumentRecord is { OR: [{ groupId: { in: ['B'] } }] }.
Tests
apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts, test should scope the write by the caller's delete rules (line 1199, with a "Pins a reported defect" comment). When fixing, change it to build the ability from update rules, expect accessibleQuery(ability, 'update', 'InstrumentRecord'), and rename it accordingly. Add a case where an update-only ability succeeds.
Suggested fix
Use accessibleQuery(ability, 'update', 'InstrumentRecord') in the update call, and apply the same scope to the initial findFirst so out-of-scope IDs return 404 before any merge or validation.
Found while raising unit test coverage in #1632.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).updateByIdhas not changed since f29adf1, but the lines have moved. The guard is atinstrument-records.controller.ts:106, the unscoped lookup atinstrument-records.service.ts:441-443and thedelete-scoped write at:477. efa9070 renamed the test file, so the pinned test is now__tests__/instrument-records.service.test.ts:1170.In the mixed-scope case, patching the group A record is also a 500, not a refusal. The unscoped
findFirstfinds the record, thedelete-scopedupdatematches no row, and Prisma throws P2025. That is not anHttpException, so libnest's global filter answers 500. #1759 describes the same mechanism.Fix
Build the
whereonce and pass it to both queries.deleteById(:163) andAssignmentsService.updateById(assignments.service.ts:199) already do this:With one constant, the lookup and the write cannot end up on different actions again. No
ability.canshort-circuit is needed. The guard (jwt-auth.guard.ts:63-65) only admits a caller who holds anupdate InstrumentRecordrule, so this call cannot throw.apps/api/AGENTS.md:102and.agents/docs/architecture/auth-and-permissions.md:76name only a mismatch between the guard's subject and the service's subject as the way to reach theForbiddenError500. Add the action mismatch to both in the same PR. #1660 rewrites that paragraph ofauth-and-permissions.md, so whichever lands second has to rebase it. #1807 movesupdateByIdto:431but does not change it.An update grant also reads
$UpdateInstrumentRecordDataaccepts{ "data": {} }, and the route returns the stored record. After the fix, anupdate InstrumentRecordgrant can therefore read every record in its scope. That includes a STANDARD user, who has noread InstrumentRecordrule. I'd accept this, since the 200 the issue expects implies it, and document it under "Where an ability comes from" inauth-and-permissions.md. If it is not acceptable, gate the route on both actions with an array, assummary.controller.ts:15does, and add thereadscope to thewhere. The guard then answers an update-only caller with 403, and no new 500 appears.Tests
:1170, also assert thatfindFirstreceives the samewhereasupdate. The model is mocked, so that argument is the only place the scope is visible. The existingnullcase at:1091already covers the 404.server-side authorizationblock oftesting/src/specs/authorization.spec.ts, next to the grant test at:617. Put a STANDARD user in groups A and B, because a scoped grant must name one of the user's own groups. As admin, seed oneDNP_HAPPINESS_QUESTIONNAIRErecord in each group withapi.uploadRecordsand thehappinessRecordhelper (:300). Its data has no date or set value, so Instrument record PATCH rejects every update to a record with a date field, because stored dates are never revived #1723 does not interfere.ApiClient.uploadRecordsis typed{ subjectId: string }[], so widen the type to expose the recordid.updateon A anddeleteon B, then log in again. PATCH A should return 200 and PATCH B 404. Onmainthey return 500 and 200.updateon A alone. PATCH A should return 200. Onmainit returns 500.Related
#1723 and #1639 also change
updateByIdand the samedescribe('updateById')block. Land all three in one PR, with onefix(api)commit each, in this order: this issue, #1723, #1639. Keep these authorization cases inauthorization.spec.ts, not in theinstrument-record-update.spec.tsthat #1639 proposes. #1728 is the same unscoped lookup by id, inInstrumentReposService.sync.Triage note written with Claude Code. Code references point at
main@d86740e.fix(api): scope instrument record updates by update rules