Repository navigation
Instrument record PATCH is scoped by delete rules instead of update rules #1633
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