Repository navigation
Description
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
- As a group manager, create a remote assignment for a subject.
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 forupdateById. Have the mocked model resolve rows that carryencryptionKeyPair, as the existingstageAssignmenttests 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 containsencryptionKeyPair.
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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedassignments.service.ts,core/prisma.tsorgateway.synchronizer.tssince 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 fromRuntimePrismaClient(libnest.config.ts:20), so the omit reaches every model method's result type. Checked against the installed Prisma 6.19.3 client withLibnestPrismaExtensionapplied:omit: { assignment: { encryptionKeyPair: true } }in the constructor (core/prisma.ts:177-181) drops the field from thefindMany,findFirst,createandupdateresult types, and a query-levelomit: { encryptionKeyPair: false }brings it back. Typechecking a copy ofapps/apiwith only that line added gives exactly two errors:gateway.synchronizer.ts:181, the decrypt. The row comes fromfindById(:108), whichsendEmailalso calls (assignments.controller.ts:118), so don't opt back in there. Add a method onlyhandleAssignmentCompleteuses, e.g.findByIdWithKeyPair(id), that passesomit: { encryptionKeyPair: false }and, likeupdateStatusById, takes noability.stageAssignment(assignments.service.ts:361-367). It is dead oncecreatestops 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) andupdateStatusById(: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:225and:368) fail once the destructure is gone. Instead:src/core/__tests__/prisma.test.ts: a test beside thehashedPasswordone (:263) asserting the constructor options carryomit: { assignment: { encryptionKeyPair: true } }.assignments.service.test.ts: delete the two tests above. Test that the new method passesomit: { encryptionKeyPair: false }and throwsNotFoundExceptionfor an unknown id.gateway.synchronizer.test.ts: move thefindByIdmocks at:81and:337to the new method.remote-assignment.spec.ts:155-166to read the assignment back with the existingapi.findAssignments(subjectId)and then cancel it, asserting no response body containsencryptionKeyPair.ApiClienthas no cancel helper, only rawPATCHes in specs (gateway-assignment.spec.ts:178), so addcancelAssignment(id).bulk-remote-assignments.spec.ts:91-96already GETs/assignments?subjectId=and can take the same assertion as:87.gateway-assignment.spec.ts:67-138already covers the opt-in. Without the key, decryption throws, the synchronizer skipsupdateStatusById, and its final "Complete" assertion fails.Triage note written with Claude Code. Code references point at
main@d86740e.fix(api): omit assignment encryptionKeyPair from default Prisma selects