Skip to content

Listing or cancelling assignments returns each assignment's private encryption key #1696

Closed

Description

@joshunrau

GET /v1/assignments and PATCH /v1/assignments/:id return each assignment's encryptionKeyPair, including the private key that decrypts the patient's submission. AssignmentsService.find returns raw assignmentModel.findMany rows, and updateById returns the raw assignmentModel.update row. Prisma includes the EncryptionKeyPair composite field on both, and nothing omits it, so Fastify serializes privateKey into the response body. Commit 88648a3 removed the keypair from the create responses (stageAssignment), and testing/src/specs/bulk-remote-assignments.spec.ts states the rule: "The private key that decrypts an assignment must never reach a client". The read and cancel paths were not covered by that fix. The web client calls the leaking route every time a clinician opens a subject's Assignments tab (/datahub/subjects/$subjectId/assignments) or the Delete Remote Assignments page, which lists every assignment in the group. So private keys for a whole group reach the browser, its caches and any HAR file or proxy log. $Assignment.array().parse strips the field afterwards, but only after it has crossed the wire.

Where

apps/api/src/assignments/assignments.service.ts:177-186:

async find(
  { groupId, subjectId }: { groupId?: string; subjectId?: string } = {},
  { ability }: EntityOperationOptions = {}
): Promise<Assignment[]> {
  return this.assignmentModel.findMany({
    where: {
      AND: [accessibleQuery(ability, 'read', 'Assignment'), { groupId, subjectId }]
    }
  });
}

apps/api/src/assignments/assignments.service.ts:206-208 (cancel):

const assignment = await this.assignmentModel.update({ data, where });
await this.auditLogger.log('UPDATE', 'ASSIGNMENT', { groupId: assignment.groupId, userId: currentUser.id });
return assignment;

Reproduce

  1. As a group manager, create a remote assignment for a subject.
  2. GET /v1/assignments?subjectId=<id> (or open the subject's Assignments tab and inspect the network response).

Actual: each assignment object carries "encryptionKeyPair": { "privateKey": { "0": …, "1": … }, "publicKey": { … } }. Checked against the generated Prisma client on a MongoDB replica set: assignment.findMany() returns the composite field, and JSON.stringify emits the private key bytes. PATCH /v1/assignments/:id with { "status": "CANCELED" } returns it too.
Expected: no response from /v1/assignments contains encryptionKeyPair.

Tests

  • apps/api/src/assignments/__tests__/assignments.service.test.ts: it('should leave the encryption keypair out of the assignments it finds, so the private key never reaches a client'), and the same for updateById. Have the mocked model resolve rows that carry encryptionKeyPair, as the existing stageAssignment tests do.
  • testing/src/specs/remote-assignment.spec.ts: after creating an assignment, GET /v1/assignments?subjectId=… and cancel it, then assert that neither response body contains encryptionKeyPair.

Suggested fix

Leave the field out at the query, not after it: pass omit: { encryptionKeyPair: true } to find and to the update in updateById, or add assignment: { encryptionKeyPair: true } to the global omit in src/core/prisma.ts (as is done for user.hashedPassword) and have the synchronizer opt back in with omit: { encryptionKeyPair: false } where it decrypts. The global omit also covers future queries.

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 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched assignments.service.ts, core/prisma.ts or gateway.synchronizer.ts since this was filed, and no open PR does.

    Fix: take the global omit

    Of the two options, the global omit is the one the compiler enforces. Model<'Assignment'> is derived from RuntimePrismaClient (libnest.config.ts:20), so the omit reaches every model method's result type. Checked against the installed Prisma 6.19.3 client with LibnestPrismaExtension applied: omit: { assignment: { encryptionKeyPair: true } } in the constructor (core/prisma.ts:177-181) drops the field from the findMany, findFirst, create and update result types, and a query-level omit: { encryptionKeyPair: false } brings it back. Typechecking a copy of apps/api with only that line added gives exactly two errors:

    • gateway.synchronizer.ts:181, the decrypt. The row comes from findById (:108), which sendEmail also calls (assignments.controller.ts:118), so don't opt back in there. Add a method only handleAssignmentComplete uses, e.g. findByIdWithKeyPair(id), that passes omit: { encryptionKeyPair: false } and, like updateStatusById, takes no ability.
    • The destructure in stageAssignment (assignments.service.ts:361-367). It is dead once create stops returning the key. Delete it and its comment.

    With the method added, the destructure gone and the synchronizer test's mocks moved, the copy typechecks clean. deleteBulk (:147) and updateStatusById (:213) also stop loading keys they never use.

    Tests

    The service tests proposed above can't see a global omit. The mocked model never applies client options, so a mocked row that carries a keypair comes straight back. For the same reason the two existing keypair tests (assignments.service.test.ts:225 and :368) fail once the destructure is gone. Instead:

    • src/core/__tests__/prisma.test.ts: a test beside the hashedPassword one (:263) asserting the constructor options carry omit: { assignment: { encryptionKeyPair: true } }.
    • assignments.service.test.ts: delete the two tests above. Test that the new method passes omit: { encryptionKeyPair: false } and throws NotFoundException for an unknown id.
    • gateway.synchronizer.test.ts: move the findById mocks at :81 and :337 to the new method.
    • e2e: extend the create test at remote-assignment.spec.ts:155-166 to read the assignment back with the existing api.findAssignments(subjectId) and then cancel it, asserting no response body contains encryptionKeyPair. ApiClient has no cancel helper, only raw PATCHes in specs (gateway-assignment.spec.ts:178), so add cancelAssignment(id). bulk-remote-assignments.spec.ts:91-96 already GETs /assignments?subjectId= and can take the same assertion as :87.
    • The smoke test at gateway-assignment.spec.ts:67-138 already covers the opt-in. Without the key, decryption throws, the synchronizer skips updateStatusById, and its final "Complete" assertion fails.

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

  3. added a commit that references this issue on Nov 3, 2026
    6b5640d
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