[DT-3989] Restrict election open and cancel actions for admins - #3019
Conversation
There was a problem hiding this comment.
Pull request overview
This PR tightens authorization around DAR collection election “open” (create elections) and “cancel” actions by removing Admin capabilities and ensuring Admin-facing collection summaries no longer advertise those actions, while preserving Chairperson/Researcher behavior and documenting the operational cleanup path.
Changes:
- Restrict election creation and collection cancellation to DAC Chairpersons and (for cancellation) Researchers; Admin becomes read-only for these actions.
- Remove
Open/Cancelactions from Admin DAR collection summaries. - Update OpenAPI docs and add/adjust regression + authorization tests; document exceptional election cleanup process.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/org/broadinstitute/consent/http/service/DarCollectionService.java | Enforces Chairperson-only election creation, restricts cancellation to Chairperson/Researcher, clears Admin summary actions. |
| src/main/java/org/broadinstitute/consent/http/resources/DarCollectionResource.java | Narrows @RolesAllowed for cancel/create-elections endpoints and adds explicit runtime role checks. |
| src/test/java/org/broadinstitute/consent/http/service/DarCollectionServiceTest.java | Updates tests to assert Admin is forbidden for cancel/create-elections and that Admin summaries have no actions. |
| src/test/java/org/broadinstitute/consent/http/resources/DarCollectionResourceTest.java | Adds/updates endpoint authorization tests (including annotation checks) for cancel/create-elections. |
| src/main/resources/assets/paths/createCollectionElectionsByCollectionId.yaml | Documents Chairperson-only authorization and adds explicit 401/403 responses. |
| src/main/resources/assets/paths/cancelCollectionByCollectionId.yaml | Removes Admin from roleName enum and documents updated cancel authorization + 403 response. |
| docs/README.md | Links the new election operations runbook doc. |
| docs/ELECTION_OPERATIONS.md | Documents ownership/escalation and the exceptional cleanup process without Admin API fallback. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Collapse the unreachable default branch in cancelDarCollectionByRole and extract the duplicated authorization messages into constants. - Imply only the Researcher role when roleName is omitted on cancel. A chairperson must request the role explicitly, since canceling as a chair cancels the elections for every dataset that chair governs. - Require a chairperson to govern at least one of the collection's datasets before creating elections, returning 403 instead of 500. - Update the OpenAPI paths and mark the exceptional cleanup process in ELECTION_OPERATIONS.md as pending support sign-off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/main/java/org/broadinstitute/consent/http/service/DarCollectionService.java:796
darDatasetIds.stream().noneMatch(governedDatasetIds::contains)does a linear lookup in aListfor each dataset, which can become O(n*m) if a DAR includes many datasets. Converting the governed IDs to aSetmakes the membership check O(1) and keeps this authorization check cheap.
List<Integer> governedDatasetIds = datasetDAO.findDatasetIdsByDACUserId(user.getUserId());
if (darDatasetIds.stream().noneMatch(governedDatasetIds::contains)) {
throw new ForbiddenException(CREATE_ELECTION_DAC_ERROR);
src/main/resources/assets/paths/createCollectionElectionsByCollectionId.yaml:27
- The 403 response description is narrower than the actual behavior: this endpoint can return 403 either because the caller is not a Chairperson (role-based) or because the Chairperson does not govern any dataset in the collection (dataset-governance check). Consider documenting both cases to match the implementation.
401:
description: Authentication required.
403:
description: Forbidden. Not a Chairperson for any dataset in the collection.
src/main/resources/assets/paths/cancelCollectionByCollectionId.yaml:31
- The 403 response description suggests the caller lacks Chairperson/Researcher, but the implementation can also return 403 when a caller with those roles requests an unsupported
roleName(e.g.,Admin). Updating the description helps keep the OpenAPI contract aligned with actual error conditions.
400:
description: Bad Request (result of elections present on DARs, or a role name that is missing or invalid for this user)
403:
description: Forbidden. Not a Chairperson or Researcher.
- Use a Set for the chairperson's governed dataset ids, matching cancelDarCollectionElectionsAsChair. - Correct the cancel 403 description; a Chairperson requesting an unsupported role also gets a 403. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/main/java/org/broadinstitute/consent/http/service/DarCollectionService.java:798
- When the most recent DAR has no datasetIds, this method skips the governance check and proceeds to create elections.
createElectionsForDarByUserwill then create nothing and this method throws anIllegalStateException(500) instead of returning a client error. This should be rejected as a Bad Request up front, consistent with other DAR validations (e.g., DataAccessRequestService throws "At least one dataset is required").
DataAccessRequest dar = collection.getMostRecentDar();
List<Integer> darDatasetIds = dar.getDatasetIds();
if (!darDatasetIds.isEmpty()) {
Set<Integer> governedDatasetIds =
Set.copyOf(datasetDAO.findDatasetIdsByDACUserId(user.getUserId()));
src/main/java/org/broadinstitute/consent/http/resources/DarCollectionResource.java:275
createElectionsForCollectionis already protected by@RolesAllowed({CHAIRPERSON}), andDarCollectionService#createElectionsForDarCollectionnow enforces the same role check. Keeping a third role check here is redundant and risks drift (e.g., if annotation/service rules change but this check isn’t updated). Consider relying on@RolesAllowed+ service-layer authorization and keeping the role restriction covered by the reflection-based annotation test.
User user = duosUser.getUser();
if (!user.hasUserRole(UserRoles.CHAIRPERSON)) {
throw new ForbiddenException(CREATE_ELECTION_ROLE_ERROR);
}
@RolesAllowed and DarCollectionService already enforce the role, so a third check only risks drift. The resource test now covers the service's ForbiddenException mapping instead of the removed check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract the pre-flight checks so createElectionsForDarCollection stays under the cognitive complexity limit, and reject a collection with no DARs instead of dereferencing the null from getMostRecentDar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/main/java/org/broadinstitute/consent/http/service/DarCollectionService.java:801
validateElectionCreationskips the governing-DAC check when the DAR has no dataset IDs, which can allow election creation to proceed and later fail (e.g., with "No elections were created...") instead of returning a clear 4xx. Since other request flows treat empty dataset IDs as a bad request, this should be rejected here as well.
List<Integer> darDatasetIds = dar.getDatasetIds();
if (!darDatasetIds.isEmpty()) {
Set<Integer> governedDatasetIds =
Set.copyOf(datasetDAO.findDatasetIdsByDACUserId(user.getUserId()));
if (darDatasetIds.stream().noneMatch(governedDatasetIds::contains)) {
rushtong
left a comment
There was a problem hiding this comment.
Looks good, couple questions inline. 👍🏽
Fail closed on an empty dataset id list instead of skipping the governing DAC check, and share the cancel role error message with the resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
findDatasetIdsByDACUserId matches any user_role row for a DAC, so a member of one DAC and chair of another could open elections for the DAC they only sit on. Reuse the role-scoped lookup instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Addresses
https://broadworkbench.atlassian.net/browse/DT-3989
Follows duos-ui #3852 (merged), which removed the Action column from the Admin DAR table.
Security risk: low — this change only removes authorization; no endpoint gains access.
Summary
roleNameis omitted on cancel; a Chairperson must request the role explicitly, since cancelling as a chair cancels the elections for every dataset that chair governs.500instead of a403).400instead of skipping the governing-DAC check (which then failed as a500downstream).Notes for reviewers
Adminis removed from theroleNameenum onPUT /api/collections/{id}/cancel, and Admin callers now get403. No duos-ui call site passesAdmin—AdminManageDarCollectionspasses no cancel/open handlers after #3852.DarCollectionService, with@RolesAllowedat the boundary and a reflection test pinning the annotation. The cancel endpoint still resolves the acting role in the resource becauseroleNameselects it.Have you read CONTRIBUTING.md lately? If not, do that first.