Skip to content

Commit 25a81dd

Browse files
authored
fix: stop the study title reaching a refused caller, and correct a test claim
1 parent 48722a5 commit 25a81dd

3 files changed

Lines changed: 41 additions & 27 deletions

File tree

‎src/server/actions/study-request.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -716,10 +716,13 @@ describe('Request Study Actions', () => {
716716
expect(untitled).toBeUndefined()
717717
})
718718

719-
// The title rule must not answer before the ownership filter does: a distinct
720-
// "title too long" would tell any lab member that a guessed id is a real, currently-DRAFT
721-
// study. A caller outside the lab gets the same generic rejection either way.
722-
it('onUpdateDraftStudyAction does not leak DRAFT status to a cross-lab caller via the title rule', async () => {
719+
// CASL denies a cross-lab caller before the handler runs, so what this pins is the
720+
// CASL-level outcome rather than the handler's check ordering: the refusal carries no
721+
// title-specific message, tells the caller nothing about the stored title, and writes
722+
// nothing. The last assertion matters because requireAbilityTo serializes the ability
723+
// subject into the message it returns, so anything the middleware reads goes back to a
724+
// caller who was just refused.
725+
it('onUpdateDraftStudyAction rejects a cross-lab update without disclosing the stored title', async () => {
723726
const { enclave, studyId } = await createTestProposalDraft({
724727
enclaveSlug: 'title-cap-cross-lab',
725728
studyInfo: { title: 'LabA Draft' },
@@ -731,6 +734,7 @@ describe('Request Study Actions', () => {
731734

732735
expect(result).toHaveProperty('error')
733736
expect(result).not.toMatchObject({ error: expect.objectContaining({ title: expect.any(String) }) })
737+
expect(JSON.stringify(result)).not.toContain('LabA Draft')
734738

735739
const after = await db
736740
.selectFrom('study')

‎src/server/actions/study-request.ts‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,12 @@ export const onUpdateDraftStudyAction = new Action('onUpdateDraftStudyAction', {
236236
.requireAbilityTo('update', 'Study')
237237
.handler(async ({ db, params: { studyId, studyInfo }, session, orgSlug, status, submittedByOrgId }) => {
238238
// Allow co-authoring within the submitting lab while the proposal is editable.
239-
// CASL `update Study` is org-type-scoped (any lab member); the row filter below
240-
// scopes to the submitting lab + editable status set, and we throw on a 0-row
241-
// result so a user outside the lab or operating on a post-submit study gets a
242-
// hard rejection instead of a misleading success with signed upload URLs.
239+
// CASL `update Study` is already scoped to the caller's own labs, so a lab member outside
240+
// this study's lab never reaches here. The row filter below repeats that scope alongside
241+
// the editable status set and throws on a 0-row result, so a caller who passed the ability
242+
// check on a broader grant (an SI admin holds `manage all`), or one operating on a
243+
// post-submit study, gets a hard rejection instead of a misleading success with signed
244+
// upload URLs.
243245
const userLabOrgIds = Object.values(session.orgs)
244246
.filter((org) => org.type === 'lab')
245247
.map((org) => org.id)
@@ -249,11 +251,12 @@ export const onUpdateDraftStudyAction = new Action('onUpdateDraftStudyAction', {
249251
// resubmit form and still governed by its 20-word rule.
250252
//
251253
// Gated on lab membership so the message cannot be used as an oracle: answering "title too
252-
// long" would otherwise tell any lab member that a guessed id exists and is currently a
253-
// DRAFT. `submittedByOrgId` comes from the middleware's read, so the gate costs no extra
254-
// query, and the length check still runs before the UPDATE rather than after it, which is
255-
// what keeps an over-long title from being written and only then complained about. A caller
256-
// outside the lab falls through to the generic rejection below.
254+
// long" would otherwise tell the caller that a guessed id exists and is currently a DRAFT.
255+
// CASL already denies a lab member outside this study's lab, so the gate is defense in
256+
// depth there and only bites on a caller who passed on a broader grant. `submittedByOrgId`
257+
// comes from the middleware's read, so it costs no extra query, and the length check still
258+
// runs before the UPDATE rather than after it, which is what keeps an over-long title from
259+
// being written and only then complained about.
257260
if (
258261
userLabOrgIds.includes(submittedByOrgId) &&
259262
status === 'DRAFT' &&
@@ -390,13 +393,13 @@ export const finalizeStudySubmissionAction = new Action('finalizeStudySubmission
390393
.params(z.object({ studyId: z.string(), studyInfo: finalizeStudySubmissionInfoSchema.optional() }))
391394
.middleware(async ({ params: { studyId } }) => await getInfoForStudyId(studyId))
392395
.requireAbilityTo('update', 'Study')
393-
.handler(async ({ db, params: { studyId, studyInfo }, session, orgSlug, title: persistedTitle }) => {
396+
.handler(async ({ db, params: { studyId, studyInfo }, session, orgSlug }) => {
394397
const userId = session.user.id
395398

396-
// CASL `update Study` is org-type-scoped (any lab member), so we additionally
397-
// require the caller to belong to the study's submitting lab. Without this,
398-
// a user in a different lab could finalize someone else's draft just by
399-
// knowing the studyId.
399+
// CASL `update Study` is already scoped to the caller's own labs, so a lab member outside
400+
// this study's lab never reaches here. Repeated on the claiming UPDATE below so a caller
401+
// who passed the ability check on a broader grant (an SI admin holds `manage all`) cannot
402+
// finalize someone else's draft just by knowing the studyId.
400403
const userLabOrgIds = Object.values(session.orgs)
401404
.filter((org) => org.type === 'lab')
402405
.map((org) => org.id)
@@ -424,11 +427,17 @@ export const finalizeStudySubmissionAction = new Action('finalizeStudySubmission
424427
// DRAFT. Resolve it here so the researcher gets a message rather than a raw DB error;
425428
// /proposal redirects such a draft to Step 1 before it can reach this point.
426429
//
427-
// The persisted title comes from the middleware's read of this same row, so the common
428-
// omitted-title path costs no extra query. It cannot be deferred to the UPDATE's
429-
// `returning` instead: by then the status has already left DRAFT and the check constraint
430-
// has fired, which is the raw error this guard exists to replace.
431-
const submittedTitle = 'title' in snapshotFields ? (snapshotFields.title as string | null) : persistedTitle
430+
// Read here rather than folded into the middleware's read of this same row: middleware
431+
// output becomes the ability subject, and requireAbilityTo serializes that subject into the
432+
// permission_denied it returns to a caller it just refused, so a title carried that far
433+
// would travel back to anyone who guessed a study id (OTTER-724 / MA-6). It cannot be
434+
// deferred to the UPDATE's `returning` either: by then the status has already left DRAFT
435+
// and the check constraint has fired, which is the raw error this guard exists to replace.
436+
const submittedTitle =
437+
'title' in snapshotFields
438+
? (snapshotFields.title as string | null)
439+
: ((await db.selectFrom('study').select('title').where('id', '=', studyId).executeTakeFirst())?.title ??
440+
null)
432441

433442
if (!submittedTitle?.trim()) {
434443
throw new ActionFailure({ title: STUDY_TITLE_BLANK_ERROR })

‎src/server/db/queries.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,11 @@ export const getInfoForStudyId = async (studyId: string) => {
401401
'study.researcherId',
402402
'study.status',
403403
'study.submittedByOrgId',
404-
// OTTER-690: finalizeStudySubmissionAction needs the persisted title on the common
405-
// submit path, where the caller omits it. Selected here so that path reuses this read
406-
// instead of issuing a second lookup of the same row.
407-
'study.title',
404+
// No row content here, however convenient it would be for a handler: this is middleware
405+
// for many actions, its output becomes their ability subject, and requireAbilityTo
406+
// serializes that subject into the permission_denied it returns to a caller it just
407+
// refused (OTTER-724 / MA-6). A title selected here would travel back to anyone who
408+
// guessed a study id. Read content in the handler, which only runs after the check.
408409
])
409410
.where('study.id', '=', studyId)
410411
.executeTakeFirstOrThrow()

0 commit comments

Comments
 (0)