Skip to content

Enforce authorization on every endpoint (default-deny + complete @PreAuthorize coverage)#2521

Merged
p-hoffmann merged 21 commits into
webapi-3.0from
p-hoffmann/security
Jun 20, 2026
Merged

Enforce authorization on every endpoint (default-deny + complete @PreAuthorize coverage)#2521
p-hoffmann merged 21 commits into
webapi-3.0from
p-hoffmann/security

Conversation

@p-hoffmann

@p-hoffmann p-hoffmann commented Jun 20, 2026

Copy link
Copy Markdown
Member

Summary

Hardens WebAPI authorization so no endpoint is reachable without an explicit check. Before this PR, the API filter chain was effectively permit-all and several handlers were without per-operation authorization.

Changes

  • The API filter chain no longer permits all requests. security.anonymousAccess.enabled (default true) decides how token-less requests are handled: admitted as the anonymous user, then denied by per-endpoint @PreAuthorize on protected endpoints
  • Per-operation @PreAuthorize across the API, using existing 3.0 permissions:
    • admin operations: admin:cache, admin:tags, admin:tools, admin:security (user/role/permission + user-import), admin:source, admin for statistics
    • source-scoped reads/writes: read:source/hasSourceAccess on cdmresults, cohort-sample, vocabulary, evidence, ddl, sqlrender
    • per-domain read:<x>, entity owner/access expressions for cohort, concept-set, characterization, pathway, feature-analysis, reusable
    • generic read for tag/tool/job reads; isAuthenticated() for self-scoped endpoints (/user/me, notifications)

…allow-list

Switch the catch-all SecurityFilterChain from permitAll to default-deny.
Only /info, /auth/providers and /i18n/** remain anonymous (login page needs them).
Token-less requests are now rejected with 401.
Class-level @PreAuthorize broke Spring Batch user-import tasklets, which
call UserImportService interface methods as SYSTEM_USER. Annotate only the
HTTP handler methods so background jobs are unaffected.
Add @PreAuthorize("isPermitted('admin:cache')") to CDMResultsService#clearCache
(POST /cdmresults/clearCache) and remove the satisfied TODO comment.
Add postAsLimitedUser helper and limitedUserDeniedGlobalCdmResultsCacheClear
test to SourceAccessIT to assert 403 for non-admin users.
…ethods

AchillesCacheTasklet (batch job, no security context) now calls getRawTreeMap
instead of the @PreAuthorize-gated getTreemap. ConceptSetService now calls
the new executeIdentifierLookupInternal instead of the gated overload.
@PreAuthorize SpEL reads public fields on WebApiSecurityExpressionRoot
(READ/WRITE/SOURCE/...) and the AccessType/EntityType enums reflectively.
Register them via RuntimeHintsRegistrar so AOT-processed runtimes keep the
types reflection-reachable and authorization evaluation works. No-op on a
standard JVM.
…n auth provider

Default-deny rejects token-less requests with 401, which makes WebAPI
unusable when no auth provider is configured. Add security.anonymousAccess.enabled
(default true): when on, token-less requests are served as the built-in
anonymous user instead of 401; set false for strict default-deny.

Only the web-layer catch-all changes (permitAll vs authenticated). Per-endpoint
@PreAuthorize is unchanged, so authorization is still evaluated against the
anonymous user's roles plus defaultGlobalReadPermissions — anonymous gets reads,
admin/write stay gated. Not a blanket bypass.

AnonymousAccessIT (flag on): unprivileged anonymous reaches ungated endpoints
(200) but is denied gated endpoints (403). SecurityIT pins the flag off and
still proves token-less requests get 401.
Closes the defense-in-depth gap where 116 HTTP handlers had no method-level
authorization, relying solely on the filter chain. Gates 105 of them with
existing 3.0 permissions; 11 auth/bootstrap endpoints (login flows, /info,
/auth/providers, /i18n) intentionally stay open.

Mapping (existing permissions only, matching each controller's gated siblings
and 2.15 intent):
- source data (vocabulary default-source overloads, evidence, source reads,
  ddl, sqlrender) -> isAnyPermitted(anyOf('read:source','write:source'))
  (+ hasSourceAccess(#sourceKey, READ) where a sourceKey is present)
- per-domain list/check/byTags/print -> read:<domain> (cohort-definition,
  conceptset, cohort-characterization, pathway, feature-analysis, reusable)
- entity read-by-id -> isOwner/isAnyPermitted(read|write)/hasEntityAccess
- user/role/permission reads -> admin:security; source refresh -> admin:source
- tag/tool/job reads -> read (no domain-specific permission exists in 3.0)
- self-scoped (/user/me, notifications) -> isAuthenticated()

Only @PreAuthorize annotations (and the PreAuthorize import) were added; no
method bodies changed. Updates AnonymousAccessIT, whose /cohortdefinition
example is now gated, to assert anonymous reaches method security (403) with
the flag on versus 401 at the filter under default-deny.
The generation endpoints (CC and pathway) were gated with an expression that
passed the path's #generationId to isOwner/hasEntityAccess as if it were the
parent entity id — but a generation id and an entity id are different id spaces,
making those terms semantically wrong (and able to block a legitimate
entity-owner who lacks the global read permission).

Each of these endpoints already enforces the correct authorization in-body via
checkGenerationReadAccess/checkGenerationWriteAccess, which resolves the
generation to its real parent entity and checks owner/permission/entity-access
plus source. Replace the coarse gate with isAuthenticated() so it only blocks
anonymous and defers the precise per-generation decision to the authoritative
in-body check.
@p-hoffmann p-hoffmann merged commit 2772441 into webapi-3.0 Jun 20, 2026
6 checks passed
@p-hoffmann p-hoffmann deleted the p-hoffmann/security branch June 20, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant