Skip to content

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) whose ResourceSecurity.json defaults already existed but which no endpoint used. The registration loop in Program.cs registers 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 404 and 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, SearchId and InvestigatorId are 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 own PdfRelativePath and 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, moves Audit.Version, and refuses a study locked by a bulk study update (409 study-locked-by-bulk-update) or deletion-marked (410 study-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 absolute http(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%2e and %2f). With studyPdfStatus on, a set queues slice 8's check.
  • Approve with studiesWorkspace on 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 StudyDetailsDto mapping 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 studiesWorkspace on must name an absolute http(s) URL (400 study-pdf-link-refused otherwise) and is stored in the form Accept writes. It never changes the study's PDF.
  • UploadSignature used 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, is 404 and nothing is signed. Its policy stays ImportSearch: 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). StudyEndpointAuthorizationTests proves 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. ProposedByName appears 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 200 with nothing written. Otherwise W2's compare-and-set link write and a conditional decision write (TryDecideAsync: still waiting, in its project, on its study, no CheckedProposalId) run in one MongoDB transaction. A miss aborts it and is classified from a re-read: 200 for the same decision, 409 study-pdf-proposal-resolved or study-pdf-link-changed otherwise. A proposal bound to a checked upload (L9) is 409 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 Use 409 ("Someone has already used this proposal."), a checked proposal 409. It keeps the 404 and 410 checks.
  • 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.