Skip to content

CLARIN-DSpace v9/Stop the request-a-copy access token from bypassing the CLARIN licence gate - #1437

Merged
milanmajchrak merged 1 commit into
dtq-dev-9-basefrom
ufal/fix-bitstream-accesstoken-bypass-9-base
Sep 10, 2026
Merged

CLARIN-DSpace v9/Stop the request-a-copy access token from bypassing the CLARIN licence gate#1437
milanmajchrak merged 1 commit into
dtq-dev-9-basefrom
ufal/fix-bitstream-accesstoken-bypass-9-base

Conversation

@milanmajchrak

Copy link
Copy Markdown
Collaborator

References

Description

On dtq-dev-9-base, a valid request-a-copy access token streamed bitstream content past both the
resource policies and the CLARIN licence gate. This PR makes the token path go through the same licence
check as an ordinary download, without disabling request-a-copy.

Instructions for Reviewers

The defect

BitstreamRestController.retrieve carried the vanilla DSpace 9 annotation:

@PreAuthorize("#accessToken != null|| hasPermission(#uuid, 'BITSTREAM', 'READ')")

The operator is ||, so a non-null accessToken satisfied authorization on its own and the right-hand
side was never evaluated. That right-hand side is the only thing that reaches the CLARIN licence gate:
hasPermissionAuthorizeServicePermissionEvaluatorPluginAuthorizeService.authorizeActionBoolean
AuthorizeServiceImpl.authorizeAction, which calls
AuthorizationBitstreamUtils.authorizeBitstream(...) for every non-WRITE bitstream action.

Vanilla 9 also ships BitstreamResourceAccessByToken, which serves the bytes from a context with
turnOffAuthorisationSystem(), and dspace/config/modules/requestitem.cfg has request.item.type = all,
so tokens really are minted. origin/dtq-dev has neither the token branch nor that class, because CLARIN
gates restricted downloads behind its own licence flow.

Net effect: anyone holding an access token could download a file behind a CLARIN licence they had never
agreed to.

List of changes in this PR

  • new dspace-server-webapp/src/main/java/org/dspace/app/rest/security/ClarinBitstreamAccessTokenSecurityBean.java
    @Component("clarinBitstreamAccessTokenSecurity") with one method,
    canDownloadWithAccessToken(UUID, String). It returns true only when request-a-copy is enabled, the
    token is valid for this bitstream (RequestItemService.authorizeAccessByAccessToken) and
    AuthorizationBitstreamUtils.authorizeBitstream passes. It follows the existing VersioningSecurityBean
    idiom (@versioningSecurity.isEnableVersioning()), so no method-security configuration changes.
  • BitstreamRestController.retrieve — the annotation becomes
    @PreAuthorize("hasPermission(#uuid, 'BITSTREAM', 'READ') or @clarinBitstreamAccessTokenSecurity.canDownloadWithAccessToken(#uuid, #accessToken)").
    hasPermission is evaluated first, so a download without a token behaves exactly as before.
  • BitstreamResourceAccessByToken.fetchDocument() — calls
    authorizationBitstreamUtils.authorizeBitstream(fileRetrievalContext, bitstream) before building the
    document. That class opens its own context and turns authorisation off in it, so a controller-only guard
    would leave it as a second door. authorizeBitstream never consults the authorisation system (submitter
    check, dtoken check, allowance check), so turnOffAuthorisationSystem() does not weaken it.
  • new ClarinBitstreamAccessTokenGateIT — 6 integration tests (below).

The licence rules are not duplicated. Both the new bean and the streaming resource call the single
existing implementation, AuthorizationBitstreamUtils.authorizeBitstream. That method is also what reads
the CLARIN dtoken from the request, which is how the two token kinds are wired together: a caller holding
a valid accessToken and a dtoken from the licence flow is served.

request.item.type is unchanged — request-a-copy stays enabled (O-8).

How to test

New IT:

mvn -o -pl dspace-server-webapp verify -DskipIntegrationTests=false \
    -Dit.test=ClarinBitstreamAccessTokenGateIT -Denforcer.skip=true -Dcheckstyle.skip=true \
    -Dlicense.skip=true -Dxml.skip=true
# Tests run: 6, Failures: 0, Errors: 0
Test What it pins down
accessTokenDoesNotBypassTheClarinLicenceGate valid token + CLARIN licence → 401 anonymous, 403 logged-in
accessTokenStillServesABitstreamWithoutAClarinLicence valid token, no CLARIN licence → 200 + body (request-a-copy not disabled)
accessTokenServesTheBitstreamOnceTheClarinLicenceIsSatisfied valid token + dtoken → 200 + body (the wiring)
behaviourWithoutAnAccessTokenIsUnchanged no token: anonymous 401, reader-group member 200, invalid token 401
bitstreamResourceRefusesToServeLicensedContentOnAnAccessTokenAlone the streaming resource itself refuses
bitstreamResourceStillServesUnlicensedContentOnAnAccessToken …and still serves an unlicensed bitstream

Regression gate — vanilla's own request-a-copy token test lives here and stays green:

mvn -o -pl dspace-server-webapp verify -DskipIntegrationTests=false -Dit.test=BitstreamRestControllerIT ...
# Tests run: 40, Failures: 0, Errors: 0

Negative control (the tests are real detectors, not decoration):

  • revert only the annotation → accessTokenDoesNotBypassTheClarinLicenceGate fails
    Status expected:<401> but was:<500> (Spring Security lets it through; the streaming resource stops it)
  • revert the whole fix → 2 failures:
    Status expected:<401> but was:<200> and
    BitstreamResourceAccessByToken served a CLARIN licence protected bitstream on an access token alone
    — i.e. the hole itself. The other four methods stay green in both controls.

mvn clean install -P-assembly -DskipTests → BUILD SUCCESS, 0 Checkstyle violations.

Not covered by this PR

  • The frontend half (card X-09): a restricted file still offers the vanilla request-a-copy form rather
    than the CLARIN licence page.
  • /api/authrn/{id}, which the UI uses to decide why a download is refused, does not know about
    accessToken; for a token holder it answers on the licence state alone. Refusals from this PR therefore
    surface as plain 401/403 rather than a MissingLicenseAgreementException error name.
  • Nothing here changes who may request a copy or how tokens are minted.

Checklist

  • My PR is created against the dtq-dev-9-base branch (fork's DSpace 9 base; not main).
  • My PR is small in size.
  • My PR passes Checkstyle validation.
  • My PR includes Javadoc for all new public methods and classes.
  • My PR passes all tests and includes new Integration Tests.
  • My PR includes details on how to test it.
  • My PR includes no new libraries/dependencies.
  • My PR modifies REST API endpoints — the endpoint contract is unchanged; only the authorization rule
    behind GET /api/core/bitstreams/{uuid}/content?accessToken= is tightened, so no REST Contract PR.
  • My PR includes no new configuration keys.

🤖 Generated with Claude Code

…e gate

BitstreamRestController.retrieve was annotated
@PreAuthorize("#accessToken != null|| hasPermission(#uuid, 'BITSTREAM', 'READ')"),
so a non-null request-a-copy access token satisfied authorization on its own:
neither the resource policies nor the CLARIN licence gate that
AuthorizeServiceImpl.authorizeAction runs for every other bitstream read were
consulted. request.item.type is "all", so tokens really are minted, and the
fork has no such token path at all.

The token is still honoured (request.item.type is unchanged), but only after
the same licence check a download without a token goes through. The check is
not reimplemented: the new clarinBitstreamAccessTokenSecurity bean and
BitstreamResourceAccessByToken both call
AuthorizationBitstreamUtils.authorizeBitstream, which is the method the normal
download path reaches. That gate reads the CLARIN dtoken from the request, so
a caller who holds both an access token and a download token is served.

Card X-12, owner decision O-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milanmajchrak
milanmajchrak merged commit e94ced2 into dtq-dev-9-base Sep 10, 2026
12 checks passed
@milanmajchrak
milanmajchrak deleted the ufal/fix-bitstream-accesstoken-bypass-9-base branch September 10, 2026 16:02
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