Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2f29bcc
fix(security): require authentication for all endpoints except login …
p-hoffmann Jun 19, 2026
f4f4a0b
test(security): regression test that anonymous requests get 401
p-hoffmann Jun 19, 2026
6a12900
fix(security): require admin:cache for cache endpoints
p-hoffmann Jun 19, 2026
3171d02
fix(security): require admin:tags for tag mutations
p-hoffmann Jun 19, 2026
25a06cb
fix(security): require admin:tools for tool mutations
p-hoffmann Jun 19, 2026
c50d707
fix(security): require admin:security for user-import endpoints
p-hoffmann Jun 19, 2026
fd6fe44
fix(security): apply admin:security per HTTP handler, not class-level
p-hoffmann Jun 19, 2026
bf3fcfe
docs(security): document defaultGlobalReadPermissions in application.…
p-hoffmann Jun 19, 2026
0d0a708
test(security): harness for per-source authorization (limited user)
p-hoffmann Jun 19, 2026
fdb566e
fix(security): require source access for cohort sample endpoints
p-hoffmann Jun 19, 2026
4830da5
fix(security): require source access for cdmresults endpoints
p-hoffmann Jun 19, 2026
7732557
fix(security): require admin:cache for global cdmresults cache clear
p-hoffmann Jun 19, 2026
0ed2596
fix(security): require source access for vocabulary endpoints
p-hoffmann Jun 19, 2026
eea48f2
fix(security): require source access for evidence endpoints
p-hoffmann Jun 19, 2026
632d41d
fix(security): require admin for statistic endpoints
p-hoffmann Jun 19, 2026
a68d4f0
fix(security): route internal callers to ungated vocabulary/treemap m…
p-hoffmann Jun 19, 2026
66a6414
feat(security): register AOT reflection hints for method-security SpEL
p-hoffmann Jun 19, 2026
2e5311e
feat(security): add security.anonymousAccess.enabled to run without a…
p-hoffmann Jun 20, 2026
7179555
fix(security): add @PreAuthorize to all remaining un-gated endpoints
p-hoffmann Jun 20, 2026
4f77902
Merge remote-tracking branch 'ohdsi/webapi-3.0' into p-hoffmann/security
p-hoffmann Jun 20, 2026
9ea3a85
fix(security): use isAuthenticated() for generation endpoints
p-hoffmann Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ private void cacheDrilldown(String domain) {
}

private List<Integer> getConceptIds(String domain) {
ArrayNode treeMap = service.getTreemap(domain, source.getSourceKey());
// call ungated raw method: this runs in a batch job with no security context
ArrayNode treeMap = service.getRawTreeMap(domain, source.getSourceKey());
Stream<JsonNode> nodes = IntStream.range(0, treeMap.size()).mapToObj(treeMap::get);
return nodes.map(node -> node.get("conceptId").intValue())
.distinct()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public CohortCharacterizationDTO copy(@PathVariable("id") final Long id) {
* @return A json object with information about the characterization analyses in WebAPI.
*/
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization'))")
public Page<CcShortDTO> list(@Pagination Pageable pageable) {
return service.getPage(pageable);
}
Expand All @@ -149,6 +150,7 @@ public Page<CcShortDTO> list(@Pagination Pageable pageable) {
* @return A json object with all characterization design specifications.
*/
@GetMapping(value = "/design", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization'))")
public Page<CohortCharacterizationDTO> listDesign(@Pagination Pageable pageable) {
return service.getPageWithLinkedEntities(pageable).map(entity -> {
CohortCharacterizationDTO dto = convertCcToDto(entity);
Expand Down Expand Up @@ -194,6 +196,7 @@ public CohortCharacterizationDTO getDesign(@PathVariable("id") final Long id) {
* @return The number of existing characterizations with the same name that was passed as a query parameter
*/
@GetMapping(value = "/{id}/exists", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isOwner(#id, COHORT_CHARACTERIZATION) or isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization')) or hasEntityAccess(#id, COHORT_CHARACTERIZATION, READ)")
public int getCountCcWithSameName(@PathVariable(value = "id", required = false) final long id, @RequestParam("name") String name) {
return service.getCountCcWithSameName(id, name);
}
Expand Down Expand Up @@ -280,6 +283,7 @@ public ResponseEntity<StreamingResponseBody> exportConceptSets(@PathVariable("id
* @return A list of warnings that is possibly empty
*/
@PostMapping(value = "/check", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization'))")
public CheckResult runDiagnostics(@RequestBody CohortCharacterizationDTO characterizationDTO){
return new CheckResult(checker.check(characterizationDTO));
}
Expand Down Expand Up @@ -335,6 +339,9 @@ public List<CommonGenerationDTO> getGenerationList(@PathVariable("id") final Lon
* @return Data about the generation including the generation id, sourceKey, hashcode, start and end times
*/
@GetMapping(value = "/generation/{generationId}", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public CommonGenerationDTO getGeneration(@PathVariable("generationId") final Long generationId) {
checkGenerationReadAccess(generationId);

Expand All @@ -348,6 +355,9 @@ public CommonGenerationDTO getGeneration(@PathVariable("generationId") final Lon
* @param generationId
*/
@DeleteMapping(value = "/generation/{generationId}")
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public void deleteGeneration(@PathVariable("generationId") final Long generationId) {
checkGenerationWriteAccess(generationId);
service.deleteCcGeneration(generationId);
Expand All @@ -359,6 +369,9 @@ public void deleteGeneration(@PathVariable("generationId") final Long generation
* @return A cohort characterization definition
*/
@GetMapping(value = "/generation/{generationId}/design", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public CcExportDTO getGenerationDesign(
@PathVariable("generationId") final Long generationId) {
checkGenerationReadAccess(generationId);
Expand All @@ -372,6 +385,9 @@ public CcExportDTO getGenerationDesign(
* @return The total number of analyses in the given cohort characterization
*/
@GetMapping(value = "/generation/{generationId}/result/count", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public Long getGenerationsResultsCount( @PathVariable("generationId") final Long generationId) {
checkGenerationReadAccess(generationId);
return service.getCCResultsTotalCount(generationId);
Expand All @@ -385,26 +401,38 @@ public Long getGenerationsResultsCount( @PathVariable("generationId") final Long
* @return The complete set of characterization analyses filtered by the thresholdLevel parameter
*/
@GetMapping(value = "/generation/{generationId}/result", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public List<CcResult> getGenerationsResults(
@PathVariable("generationId") final Long generationId, @RequestParam(value = "thresholdLevel", defaultValue = "0.01") final float thresholdLevel) {
checkGenerationReadAccess(generationId);
return service.findResultAsList(generationId, thresholdLevel);
}

@GetMapping(value = "/generation/{generationId}/temporalresult", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public List<CcTemporalResult> getGenerationTemporalResults(@PathVariable("generationId") final Long generationId) {
checkGenerationReadAccess(generationId);
return service.findTemporalResultAsList(generationId);
}

@PostMapping(value = "/generation/{generationId}/result", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public GenerationResults getGenerationsResults(
@PathVariable("generationId") final Long generationId, @RequestBody ExportExecutionResultRequest params) {
checkGenerationReadAccess(generationId);
return service.findData(generationId, params);
}

@PostMapping(value = "/generation/{generationId}/result/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportGenerationsResults(
@PathVariable("generationId") final Long generationId, @RequestBody ExportExecutionResultRequest params) {
checkGenerationReadAccess(generationId);
Expand Down Expand Up @@ -458,6 +486,9 @@ private void createZipEntry(ZipOutputStream zos, Report report) throws IOExcepti
}

@GetMapping(value = "/generation/{generationId}/explore/prevalence/{analysisId}/{cohortId}/{covariateId}", produces = MediaType.APPLICATION_JSON_VALUE)
// Per-generation entity + source access is enforced in-body by checkGeneration*Access
// (which resolves the generation to its parent CC); this gate only blocks anonymous.
@PreAuthorize("isAuthenticated()")
public List<CcPrevalenceStat> getPrevalenceStat(@PathVariable("generationId") Long generationId,
@PathVariable("analysisId") Long analysisId,
@PathVariable("cohortId") Long cohortId,
Expand Down Expand Up @@ -602,6 +633,7 @@ public CohortCharacterizationDTO copyAssetFromVersion(@PathVariable("id") final
* @return
*/
@PostMapping(value = "/byTags", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-characterization','write:cohort-characterization'))")
public List<CcShortDTO> listByTags(@RequestBody TagNameListRequestDTO requestDTO) {
if (requestDTO == null || requestDTO.getNames() == null || requestDTO.getNames().isEmpty()) {
return Collections.emptyList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,7 @@ public GenerateSqlRequest() {
* @param request A GenerateSqlRequest containing the cohort expression and options.
* @return The OHDSI template SQL needed to generate the input cohort definition as a character string
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/sql", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
public GenerateSqlResult generateSql(@RequestBody GenerateSqlRequest request) {
CohortExpressionQueryBuilder.BuildExpressionQueryOptions options = request.options;
Expand All @@ -714,6 +715,7 @@ public GenerateSqlResult generateSql(@RequestBody GenerateSqlRequest request) {
* @return List of metadata about all cohort definitions in WebAPI
* @see org.ohdsi.webapi.cohortdefinition.CohortMetadataDTO
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional(readOnly = true)
@Cacheable(cacheNames = CachingSetup.COHORT_DEFINITION_LIST_CACHE, key = "@authorizationService.getAuthenticatedPrincipal().getUserId()")
Expand Down Expand Up @@ -967,6 +969,7 @@ public ResponseEntity cancelGenerateCohort(@PathVariable("id") final int id, @Pa
* @param id - the Cohort Definition ID to generate
* @return information about the Cohort Analysis Job for each source
*/
@PreAuthorize("isOwner(#id, COHORT_DEFINITION) or isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition')) or hasEntityAccess(#id, COHORT_DEFINITION, READ)")
@GetMapping(value = "/{id}/info", produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public List<CohortGenerationInfoDTO> getInfo(@PathVariable("id") final int id) {
Expand Down Expand Up @@ -1198,6 +1201,7 @@ public List<Report> getDemographicsReport(
* The cohort definition expression
* @return The cohort check result
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/check", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public CheckResultDTO runDiagnostics(@RequestBody CohortExpression expression) {
Expand All @@ -1216,6 +1220,7 @@ public CheckResultDTO runDiagnostics(@RequestBody CohortExpression expression) {
* @param cohortDTO The cohort definition expression
* @return The cohort check result
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/checkV2", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public CheckResult runDiagnosticsWithTags(@RequestBody CohortDTO cohortDTO) {
Expand Down Expand Up @@ -1244,6 +1249,7 @@ public CheckResult runDiagnosticsWithTags(@RequestBody CohortDTO cohortDTO) {
* @return an HTTP response with the content, with the appropriate MediaType
* based on the format that was requested.
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/printfriendly/cohort", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity cohortPrintFriendly(@RequestBody CohortExpression expression, @RequestParam(value = "format", defaultValue = "html") String format) {
String markdown = convertCohortExpressionToMarkdown(expression);
Expand All @@ -1264,6 +1270,7 @@ public ResponseEntity cohortPrintFriendly(@RequestBody CohortExpression expressi
* @return an HTTP response with the content, with the appropriate MediaType
* based on the format that was requested.
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/printfriendly/conceptsets", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity conceptSetListPrintFriendly(@RequestBody List<ConceptSet> conceptSetList, @RequestParam(value = "format", defaultValue = "html") String format) {
String markdown = markdownPF.renderConceptSetList(conceptSetList.toArray(new ConceptSet[0]));
Expand Down Expand Up @@ -1477,6 +1484,7 @@ public CohortDTO copyAssetFromVersion(@PathVariable("id") final int id, @PathVar
* @param requestDTO contains a list of tag names
* @return the set of cohort definitions that match one of the included tag names.
*/
@PreAuthorize("isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition'))")
@PostMapping(value = "/byTags", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
@Transactional
public List<CohortDTO> listByTags(@RequestBody TagNameListRequestDTO requestDTO) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

Expand Down Expand Up @@ -44,6 +45,7 @@ public CohortSampleService(
* @param sourceKey
* @return JSON containing information about cohort samples
*/
@PreAuthorize("isAnyPermitted(anyOf('read:source','write:source')) or hasSourceAccess(#sourceKey, READ)")
@GetMapping(value = "/{cohortDefinitionId}/{sourceKey}", produces = MediaType.APPLICATION_JSON_VALUE)
public CohortSampleListDTO listCohortSamples(
@PathVariable("cohortDefinitionId") int cohortDefinitionId,
Expand Down Expand Up @@ -73,6 +75,7 @@ public CohortSampleListDTO listCohortSamples(
* @param fields
* @return personId, gender, age of each person in the cohort sample
*/
@PreAuthorize("isAnyPermitted(anyOf('read:source','write:source')) or hasSourceAccess(#sourceKey, READ)")
@GetMapping(value = "/{cohortDefinitionId}/{sourceKey}/{sampleId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CohortSampleDTO getCohortSample(
@PathVariable("cohortDefinitionId") int cohortDefinitionId,
Expand All @@ -94,6 +97,7 @@ public CohortSampleDTO getCohortSample(
* @param fields
* @return A sample of persons from a cohort
*/
@PreAuthorize("isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE)")
@PostMapping(value = "/{cohortDefinitionId}/{sourceKey}/{sampleId}/refresh", produces = MediaType.APPLICATION_JSON_VALUE)
public CohortSampleDTO refreshCohortSample(
@PathVariable("cohortDefinitionId") int cohortDefinitionId,
Expand All @@ -112,6 +116,7 @@ public CohortSampleDTO refreshCohortSample(
* @param cohortDefinitionId
* @return true or false
*/
@PreAuthorize("isOwner(#cohortDefinitionId, COHORT_DEFINITION) or isAnyPermitted(anyOf('read:cohort-definition','write:cohort-definition')) or hasEntityAccess(#cohortDefinitionId, COHORT_DEFINITION, READ)")
@GetMapping(value = "/has-samples/{cohortDefinitionId}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Boolean> hasSamples(
@PathVariable("cohortDefinitionId") int cohortDefinitionId
Expand All @@ -126,6 +131,7 @@ public Map<String, Boolean> hasSamples(
* @param cohortDefinitionId
* @return true or false
*/
@PreAuthorize("isAnyPermitted(anyOf('read:source','write:source')) or hasSourceAccess(#sourceKey, READ)")
@GetMapping(value = "/has-samples/{cohortDefinitionId}/{sourceKey}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Boolean> hasSamplesForSource(
@PathVariable("sourceKey") String sourceKey,
Expand All @@ -143,6 +149,7 @@ public Map<String, Boolean> hasSamplesForSource(
* @param sampleParameters
* @return
*/
@PreAuthorize("isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE)")
@PostMapping(value = "/{cohortDefinitionId}/{sourceKey}", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
public CohortSampleDTO createCohortSample(
@PathVariable("sourceKey") String sourceKey,
Expand All @@ -169,6 +176,7 @@ public CohortSampleDTO createCohortSample(
* @param sampleId
* @return
*/
@PreAuthorize("isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE)")
@DeleteMapping("/{cohortDefinitionId}/{sourceKey}/{sampleId}")
public ResponseEntity<Void> deleteCohortSample(
@PathVariable("sourceKey") String sourceKey,
Expand All @@ -189,6 +197,7 @@ public ResponseEntity<Void> deleteCohortSample(
* @param cohortDefinitionId
* @return
*/
@PreAuthorize("isPermitted('write:source') or hasSourceAccess(#sourceKey, WRITE)")
@DeleteMapping("/{cohortDefinitionId}/{sourceKey}")
public ResponseEntity<Void> deleteCohortSamples(
@PathVariable("sourceKey") String sourceKey,
Expand Down
Loading
Loading