Study read, risk-of-bias and PDF-correction authorization¶
Seven actions on StudyController (/api/projects/{projectId}/studies) had no resource policy.
The global authenticated-user filter was their only gate, so any signed-in user could, for any
project:
- read a study by ID (
GET {studyId}), or every study's risk-of-bias fields (GET getRobStudies); - write risk-of-bias fields into any study (
POST addRob,POST addRobs), including studies of a different project from the one in the route; - submit a PDF correction with a body-supplied project, study, search and submitter, and approve or
reject any correction (
POST {studyId}/Pdf/Correction,PUT …/{correctionId}/Approve|Reject).
The study and correction IDs were never compared with the route project. These were #3335's "entry points lacking a resource policy", confirmed open on main during application-authority milestone M3b.
The rule now¶
| Action | Policy (deployed default grantees) |
|---|---|
GET {studyId}, GET getRobStudies |
ProjectViewStudiesPolicy (every active member, project Administrator group) |
POST addRob, POST addRobs |
ProjectBulkUpdateStudiesPolicy (project Administrator group) |
POST {studyId}/Pdf/Correction |
ProjectSubmitPdfCorrectionsPolicy (every active member, project Administrator group) |
PUT …/Approve, PUT …/Reject |
ProjectApprovePdfCorrectionsPolicy (project Administrator group) |
- The two correction policies are new constants for activities (
SubmitPdfCorrections,ApprovePdfCorrections) whoseResourceSecurity.jsondefaults already existed but which no endpoint used. The registration loop inProgram.csregisters them; no host change is needed. - Risk-of-bias writes use the bulk study-update grant: they change stored study data in bulk. Starting a batch Risk of
Bias job is a separate activity,
CalculateRob(Calculate Risk of Bias authorization); both default to the project Administrator group. - Every study or correction an action touches must belong to the route project (and, for a correction,
to the route study). Otherwise the answer is
404and nothing is written. A batch naming one foreign or unknown study writes nothing: all studies are checked before any is changed. - A submitted correction takes its project and study from the route, its search from the study, and its
submitter from the signed-in user. The body's
ProjectId,StudyId,SearchIdandInvestigatorIdare ignored. - Approve and Reject now return the study they resolved (they previously mapped an un-awaited task).
The web app calls none of these actions. Callers that relied on sign-in alone now receive 403
(not permitted on the route project) or 404 (ID not in the route project).
Studies workspace W2: the study detail's PDF actions (November 2026)¶
W2 maps the two PDF capabilities of design decision R2 onto these policies. Default grants are unchanged.
| Action | Policy (deployed default grantees) | Gate |
|---|---|---|
Upload: POST {studyId}/Pdf/Correction (propose a PDF) |
ProjectSubmitPdfCorrectionsPolicy (every active member) |
none |
Set: PUT …/Pdf/Correction/{id}/Approve ("Use proposed PDF"), …/Reject |
ProjectApprovePdfCorrectionsPolicy (Administrator group) |
none |
Set: PUT {studyId}/pdf (add or replace), DELETE {studyId}/pdf (remove the link) |
ProjectApprovePdfCorrectionsPolicy (Administrator group) |
FeatureGated(StudiesWorkspace): unroutable 404 while off |
POST {studyId}/UploadSignature (sign one PDF upload) |
ProjectImportSearchPolicy (Administrator group), unchanged |
none |
- Set (
PUT/DELETE …/pdf) changes the study's ownPdfRelativePathand clears its bulk-delivery marker (BulkPdfDeliveredAt,BulkPdfDeliveredPath; decision D4 = A), in one conditional update through the composite study write guard (IStudyRepository.SetPdfLinkAsync): it writes only while the stored path and marker are the ones the caller read, movesAudit.Version, and refuses a study locked by a bulk study update (409study-locked-by-bulk-update) or deletion-marked (410study-deleted), as Approve does under ADR-020. It never calls storage: removing a link never deletes a file. It also refuses (409) while a bulk PDF upload for the study's search is unfinished (study-pdf-bulk-upload-running), and when the path or marker changed since it was read (study-pdf-link-changed). A link is an absolutehttp(s)URL (stored with a lower-case scheme, so it's served verbatim and never fetched) or a path inside the study's search folder (StudyPdfLinkRules: no scheme or colon,.., backslash, leading slash or%at all, because the legacy link layout appends the path unencoded and a browser decodes%2e%2eand%2f). WithstudyPdfStatuson, a set queues slice 8's check. - Approve with
studiesWorkspaceon makes a waiting proposal the study's PDF through the same write, then queues the check; an already resolved proposal (study-pdf-proposal-resolved) or an uploaded-file proposal (study-pdf-proposal-file: SyRF can't serve a single uploaded file yet) is refused and stays as it was. Slice 9 makes the link write and the decision one transaction and both decisions idempotent (below). With the flag off, Approve records the decision only, as before (its domain event changes nothing on the study). - Approve and Reject responses now map the study with the project's agreement threshold, as every other
StudyDetailsDtomapping does. Without it the inclusion-status resolver threw after the decision was saved, which went unnoticed while nothing called these routes. Unflagged: a correction. - Propose with
studiesWorkspaceon must name an absolutehttp(s)URL (400study-pdf-link-refusedotherwise) and is stored in the form Accept writes. It never changes the study's PDF. UploadSignatureused to take the search, study and investigator ids it signs into the object's metadata and path from the request body. It now takes the project and study from the route, the search from the study and the investigator from the signed-in user; a study not in the route project, or deletion-marked, is404and nothing is signed. Its policy staysImportSearch: no client in this repository calls it, and moving it to the all-members Upload capability would let every member write into the search's shared upload folder, so it waits for the single-file upload path (W2's open question).StudyEndpointAuthorizationTestsproves body ids are ignored, with a negative control.
StudyEndpointAuthorizationTests adds PUT pdf, DELETE pdf and POST upload signature to its role matrix (owner,
administrator, member, non-member of the private project), and proves the W2 routes are 404 for every role while
the flag is off and follow it on, off, on. StudyControllerPdfLinkTests covers the refusals, the check queued
after a set and that clearing never touches storage.
Studies workspace slice 9: PDFs to check (November 2026)¶
Slice 9 adds two reads and a filter value for R2's Set holders, and makes Use and Reject safe to race. Default grants are unchanged.
| Action | Policy (deployed default grantees) | Gate |
|---|---|---|
GET pdf-proposals/summary (Overview's "N proposed PDFs to check") |
ProjectApprovePdfCorrectionsPolicy (Administrator group) |
FeatureGated(StudiesWorkspace): unroutable 404 while off; Available: false, reading nothing, while the 0035 index is missing |
GET pdf-proposals?studyId=… (1 to 50 studies; proposer names) |
ProjectApprovePdfCorrectionsPolicy (Administrator group) |
as above; 400 for no studies or more than 50 |
POST …/studies with pdfStatusFilter: ProposedPdfWaiting |
ProjectViewStudiesPolicy on the route, then ProjectApprovePdfCorrectionsPolicy for the value (403 otherwise, a member who may propose included) |
409 study-pdf-proposals-unavailable while studiesWorkspace is off or the index is missing; 409 study-pdf-proposals-too-many above 10,000 studies |
- Names.
ProposedByNameappears only in these two Set-only reads.StudyCorrectionDto(the Library list and the review pages) carries no name for anyone (its golden is unchanged). - Use (Approve, flag on) reads the proposal and the study uncached; a repeat of the same decision is
200with nothing written. Otherwise W2's compare-and-set link write and a conditional decision write (TryDecideAsync: still waiting, in its project, on its study, noCheckedProposalId) run in one MongoDB transaction. A miss aborts it and is classified from a re-read:200for the same decision,409study-pdf-proposal-resolvedorstudy-pdf-link-changedotherwise. A proposal bound to a checked upload (L9) is409 study-pdf-proposal-checked. Locked (409), deleted (410) and a running bulk PDF upload for the search (409) are refused before anything is written. - Reject (flag on) is one conditional write: a repeat is
200, after Use409("Someone has already used this proposal."), a checked proposal409. It keeps the404and410checks. - With the flag off, Approve and Reject are exactly as above (whole-document saves).
StudyEndpointAuthorizationTests adds both reads to its role matrix and to the flag-off 404 routes, the filter
value's 403 for a member and a non-member and 409 while off, and Use and Reject on, off, on.
StudyControllerPdfProposalsTests runs the reads, the filter and the two races (two Uses; Use against Reject) on a
replica set with a hook between Use's two writes.
Validation and rollout¶
StudyEndpointAuthorizationTests drives real HTTP requests through the global authentication filter,
the policies built by the same loop as Program.cs, the production authorization handler and the
deployed ResourceSecurity.json. For every action it covers owner, project administrator, ordinary
member and non-member; anonymous 401 with no read; refusal with no study or correction read and no
write; foreign-study and foreign-correction 404 with no write; an all-or-nothing batch; and server-side
attribution of a submitted correction. 28 of its 46 cases fail against the previous controller.
This security correction is unflagged: a runtime switch must not reopen the hole. It needs no data migration. In enforced application-authority mode these project policies are decided like every other project policy. Deploy with the normal API release.