Skip to content

Group and user routes return 500 for a malformed or unknown id instead of 400 or 404 #1759

Open

Description

@joshunrau

The group and user routes return 500 for a bad or stale id, and log it as a server error. GroupsController and UsersController pass :id straight to Prisma, as do the groupId query parameters of the users, sessions, subjects and summary routes. These fields are @db.ObjectId, so a value that is not 24 hex characters makes Prisma throw a malformed-ObjectID PrismaClientKnownRequestError. Separately, the write handlers that call update or delete directly throw P2025 when no row matches. Neither error is an HttpException, so libnest's global filter answers 500 and logs it at error level. The same happens when a group manager's DELETE /v1/groups/<foreign group id> is refused. apps/api/AGENTS.md (Validation) says params and query strings must be validated explicitly, and SubjectsController.findCustomIds already uses ValidObjectIdPipe for this.

Where

apps/api/src/groups/groups.controller.ts:25-30, and the same @Param('id') id: string on the get and patch handlers:

@Delete(':id')
@RouteAccess({ action: 'delete', subject: 'Group' })
deleteById(@Param('id') id: string, @CurrentUser('ability') ability?: AppAbility) {
  return this.groupsService.deleteById(id, { ability });
}

apps/api/src/groups/groups.service.ts:54-58, which deletes without first checking that a row exists:

async deleteById(id: string, { ability }: EntityOperationOptions = {}) {
  return this.groupModel.delete({
    where: { AND: [accessibleQuery(ability, 'delete', 'Group')], id }
  });
}

The same pattern appears in:

  • apps/api/src/users/users.controller.ts:35-111: every :id handler.
  • apps/api/src/users/users.service.ts:37-43, :175-181 and :204-217: archiveById, unarchiveById and updateById call userModel.update directly.
  • Unvalidated groupId query parameters: users.controller.ts:77, sessions/sessions.controller.ts:28, subjects/subjects.controller.ts:32, summary/summary.controller.ts:22.
  • The audit log page's URL filters groupId and userId (packages/schemas/src/audit/audit.ts:31-32).

Reproduce

Verified with the api integration harness, signed in as admin:

  • GET /v1/groups/abc, PATCH /v1/groups/abc and GET /v1/users/abc return 500.
  • GET /v1/users?groupId=abc, GET /v1/sessions?groupId=abc, GET /v1/subjects?groupId=abc and GET /v1/summary?groupId=abc return 500.
  • DELETE /v1/groups/000000000000000000000000, PATCH /v1/users/000000000000000000000000 and PATCH /v1/users/000000000000000000000000/archive return 500.

Actual: 500 Internal Server Error, logged at error level.
Expected: 400 for a malformed id and 404 for an id that matches no row the caller can access. GET /v1/groups/<unknown but well-formed id> already returns 404.

Tests

  • apps/api/src/groups/__tests__/groups.service.test.ts: it('should throw NotFoundException when the group to delete does not exist or is not accessible, rather than a server error'), with matching cases in users.service.test.ts for archive, unarchive and update.
  • apps/api/test/suites/ (integration, which runs the real pipes): it('should answer 400 for a malformed group or user id, rather than a server error').
  • testing/src/specs/authorization.spec.ts: tighten 'should not let a group manager delete a group they do not belong to' to expect 404 instead of any non-OK status.

Suggested fix

Add ValidObjectIdPipe to every :id param in GroupsController and UsersController except self-update/:id, which already returns 403 before Prisma sees the id. Validate the groupId query parameters with new ParseSchemaPipe({ isOptional: true, schema }) using an ObjectId schema, and do the same for the audit query's groupId and userId. In GroupsService.deleteById and the three UsersService writes, look the row up first, as GroupsService.updateById does, and throw NotFoundException when it is missing.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). No commit since this was filed touches these controllers or services.

    Other routes with the same hole

    • apps/api/src/assignments/assignments.controller.ts:100: GET /v1/assignments?groupId=abc reaches the findMany filter at assignments.service.ts:183.
    • apps/api/src/instrument-records/instrument-records.controller.ts:63, :92 and :100 (find, linear-model, summary/by-subject): the service calls groupsService.findById(groupId) first (instrument-records.service.ts:259, :313, :368). :82 (export) puts the value into $toObjectId in queryRecordsRaw (instrument-records.service.ts:630-632), so the conversion fails inside MongoDB.
    • apps/api/src/instruments/instruments.controller.ts:71: GET /v1/instruments/info?groupId=abc as an admin. resolveGroupIds returns the id unchecked for manage all (instruments.service.ts:765-773), and it reaches seriesGroupId: { in: groupIds }. Non-admins get 403 first.
    • apps/api/src/instrument-repos/instrument-repos.controller.ts:24 and :38: bare @Param('id') on delete and sync. The fix for Instrument repo sync and re-add ignore a group-scoped grant, letting it re-sync or replace the token of any repository #1728 changes the same sync handler, so whichever lands second adds the pipe.
    • feat: add the instrument hub and record provenance #1807 adds summary/by-instrument and summary/by-series, both with a bare @Query('groupId'). Land this after feat: add the instrument hub and record provenance #1807, or cover those two on rebase. feat: add the instrument hub and record provenance #1807's edit to authorization.spec.ts is one line in GROUP_MANAGER_ONLY_ROUTES, so it does not collide with the delete test.

    Leave the :id params of assignments, instruments and subjects alone. Those ids are plain strings, not ObjectIds (apps/api/prisma/schema.prisma:72, :215, :269), so the pipe would reject valid ids.

    On the suggested fix

    • Add one $ObjectId schema to packages/schemas/src/core/core.ts, matching ValidObjectIdPipe's /^[a-fA-F0-9]{24}$/, and use it in every optional query pipe and in the audit params. ValidObjectIdPipe cannot go on an optional query: it rejects undefined with 400, which would break every call that omits groupId. Keep it for the required :id params.
    • $AuditLogsQuerySearchParams is also the web route's validateSearch (apps/web/src/routes/_app/admin/audit/logs.tsx:318). Tightening it makes a hand-edited ?groupId=abc fail on the client before any request is sent, as an unknown action already does. Add that case beside the action: 'TELEPORT' one at apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx:202. fix: resolve 26 low-difficulty issues, one commit each #1825 also edits both files, so expect a small rebase.
    • The existence check only needs model.exists(where) with the same scoped where the write uses, as in AssignmentsService.updateById (assignments.service.ts:198-202). deleteById, archiveById and unarchiveById never use the row.
    • UsersService.updateById can already read the user twice: at :200 for the username, and in permissionsWithinGroups (:290). Replace both with one lookup at the top, scoped by the update rule the write uses rather than findById's read rule. Throw the 404 there and pass its username and additionalPermissions down.
    • Do not fix this with a global filter that maps Prisma codes. Prisma also raises P2023 ("Inconsistent column data") when a stored MongoDB document does not match the schema, and P2025 covers any required record that is missing, not only the target row. Mapping either would relabel server faults as 400 or 404.

    Tests

    Beyond the issue's list: packages/schemas/src/core/__tests__/core.test.ts for $ObjectId. Put the integration cases in a new apps/api/test/suites/04-id-validation.suite.ts. Suites run in filename order against one app, so it can sign in as the admin that 02-user-permissions sets up. In testing/src/specs/authorization.spec.ts, add one apiRequestContext case as admin expecting 400 for GET /api/v1/users?groupId=abc next to the tightened delete test.

    Related

    #1642 still needs its client guard. After this fix PATCH /v1/groups/undefined returns 400 instead of 500, but the request is still sent. Request bodies have the same gap: POST /v1/users with groupIds: ['abc'] fails in the findById loop at users.service.ts:100-102. Typing the write schemas' id fields as $ObjectId would let the global ValidationPipe answer 400. That belongs in a follow-up.

    Triage note written with Claude Code. Code references point at main @ d86740e.

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 lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions