@@ -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 } )
0 commit comments