Skip to content

Instrument record PATCH is scoped by delete rules instead of update rules #1633

Open

Description

@joshunrau

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 update on InstrumentRecord but no delete rule passes the guard, then accessibleQuery(ability, 'delete', ...) throws CASL's ForbiddenError, which surfaces as a 500. Granting a non-admin user "update instrument records" through additionalPermissions therefore does not let them update anything.
  • When update and delete grants have different scopes, the wrong rows are writable. Take a user granted update InstrumentRecord confined to group A and delete InstrumentRecord confined 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 the delete policy instead of the declared update policy.

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

  1. Grant a STANDARD user the additional permission update InstrumentRecord (no group).
  2. 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

  1. added
    BugType: existing behavior is wrong
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    SecurityAuthentication, authorization, data exposure or a vulnerable dependency
    on Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: HighSignificant or frequently hit user impact; target the next release or twoSecurityAuthentication, authorization, data exposure or a vulnerable dependency

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions