MODSIDECAR-191 Spike - Implementation plan for wildcard permissionsRequired token-enforcement in folio-module-sidecar

MODSIDECAR-191 Spike - Implementation plan for wildcard permissionsRequired token-enforcement in folio-module-sidecar

Spike Overview

https://folio-org.atlassian.net/browse/MODSIDECAR-191

Objective: Produce a concrete, agreed-upon implementation plan for a ["*"] wildcard
convention in permissionsRequired that allows module descriptors to signal "valid access token
is required, but no specific named permission is checked" — distinct from the existing "fully
public, no token required" semantics of [].


Background

The FOLIO module descriptor contract includes a permissionsRequired field on each routing
entry. It carries a list of Okapi/Keycloak permission names that the caller must hold in order to
access the endpoint.

In the Eureka architecture, folio-module-sidecar reads this field from the module bootstrap
response (supplied by mgr-applications) and uses it to decide which security filters to apply
to an incoming ingress request. The sidecar's central gate is:

// RoutingUtils.java public static boolean hasNoPermissionsRequired(RoutingContext rc) { return isEmpty(endpoint.getPermissionsRequired()); // true for null AND [] }

When hasNoPermissionsRequired returns true, the sidecar:

  • Skips Keycloak tenant validation (KeycloakTenantFilter)

  • Skips Keycloak RPT permission evaluation (KeycloakAuthorizationFilter)

  • Allows the request through even if no token is present (via KeycloakJwtFilter's recover
    handler)

This is correct for genuinely anonymous endpoints such as POST /users-keycloak/forgotten/password.
However, MODSIDECAR-190 identified that GET /users-keycloak/_self also declares "permissionsRequired": [] — yet the handler extracts the caller's user ID from X-Okapi-User-Id or directly from the JWT payload, throwing a 404 EntityNotFoundException when neither is present. The endpoint is not truly public; it simply has no named permission requirement.

There is currently no way in the module descriptor format to distinguish between:

Category

Meaning

Current representation

Correct representation

Category

Meaning

Current representation

Correct representation

Truly public

No token at all required

"permissionsRequired": []

"permissionsRequired": [] (unchanged)

Authenticated, no named permission

Valid JWT required; no specific permission checked

"permissionsRequired": []wrong

"permissionsRequired": ["*"] ← proposed


Problem Statement

Because [] is overloaded to mean both "public" and "authenticated but permission-free",
the sidecar cannot enforce token presence for the second category. Any caller — including
unauthenticated clients — can reach _self-style endpoints without a token, bypassing the
authentication layer entirely. The sidecar silently passes the request on; the module handler may
then return a 404 instead of a 401, masking the underlying security gap.


Scope

In Scope

  • Investigate whether "permissionsRequired": ["*"] is the right convention.

  • Identify which sidecar filters must be updated and what the minimal, safest change is.

  • Assess backwards compatibility with existing module descriptors.

  • Identify other ecosystem endpoints that should adopt the new convention.

  • Risk-assess edge cases, particularly the forgotten-password/username and logout flows.

  • Define and optionally implement a PoC demonstrating the wildcard parsed and enforced end-to-end.

Out of Scope

  • Production-ready implementation (this spike defines what it will look like).

  • Changes to any module beyond what is needed to validate the PoC.


Investigation

1. Module Descriptor Schema — Is ["*"] the Right Convention?

The module descriptor model is defined in
applications-poc-tools/folio-backend-common/src/main/java/org/folio/common/domain/model/RoutingEntry.java:

private List<String> permissionsRequired = new ArrayList<>(); private List<String> permissionsDesired = new ArrayList<>(); private List<String> modulePermissions = new ArrayList<>();

permissionsRequired is a plain List<String> with no Bean Validation annotations and no
JSON Schema constraints. mgr-applications/ModuleDescriptorValidator only checks
module-to-descriptor ID consistency and does not inspect permission field content.

Alternative conventions considered:

Candidate

Assessment

Candidate

Assessment

"permissionsRequired": ["*"]

Simple, contained within the existing field, consistent with the wildcard idiom already used for HTTP methods in RoutingEntryUtils. No schema change required.

New boolean field "tokenRequired": true

Cleaner semantics, but requires a schema change, a Jackson mapping change in RoutingEntry, and updates in every consumer that maps this model. Higher blast radius for the same result.

New field "permissionsPolicy": "authenticated"

Expressive, but introduces a second authorization axis that is difficult to evolve.

Recommendation: "permissionsRequired": ["*"] is the correct and minimal convention.
It reuses the existing field and its string-list type, requires no schema change, and is
unambiguous — "*" is not a valid Keycloak permission name, so there is no collision risk.


2. Sidecar Filter Chain — Impact Analysis

Three filters reference hasNoPermissionsRequired and must be updated. All other filters are
unaffected.

Order

Filter

Current shouldSkip condition

Behavior for [] (truly public)

Behavior for ["*"] (auth required)

Change needed

Order

Filter

Current shouldSkip condition

Behavior for [] (truly public)

Behavior for ["*"] (auth required)

Change needed

120

KeycloakJwtFilter

Skip if isSystemRequest && !isTimerRequest

Runs; absent token allowed via recover handler

Runs; absent token must be rejected

Update handleFailedTokenParsing recover handler

130

KeycloakTenantFilter

Skip if !isTimer && (isSystem || hasNoPermsRequired) || isSelf

Skipped

Must run (validate token tenant)

Update shouldSkip

160

KeycloakAuthorizationFilter

Skip if !isTimer && (isSystem || hasNoPermsRequired) || isSelf

Skipped

Should skip (no named permission to evaluate)

Update shouldSkip (use broader condition)

Key observation: KeycloakAuthorizationFilter and KeycloakTenantFilter share identical
shouldSkip logic today, but for ["*"] they must diverge:

  • KeycloakTenantFilter → must run (verify tenant in JWT)

  • KeycloakAuthorizationFilter → must skip (no permission to evaluate)

KeycloakJwtFilter — Recover Handler Detail

Current logic in handleFailedTokenParsing (L124):

if (hasNoPermissionsRequired(rc) && !Objects.equals(FAILED_TO_PARSE_JWT_ERROR_MSG, error.getMessage()) || isSelfRequest(rc) && !hasToken(rc) || getParsedSystemToken(rc).isPresent()) { return succeededFuture(rc); // allow through } return failedFuture(error);

The first branch allows a missing token ("Failed to find JWT in request" error) for any
no-permissions endpoint. For ["*"] endpoints this must be closed — a missing token must
produce a 401.


3. Backwards Compatibility

  • All existing "permissionsRequired": [] entries continue to work exactly as before. The
    sidecar's handling of empty lists is unchanged.

  • "*" is a new, additive string value. No existing module descriptor in the examined projects
    uses "*" in permissionsRequired.

  • The CapabilitiesModuleEventPublisher in mgr-tenant-entitlements logs a WARN when a
    permission name in permissionsRequired has no matching entry in permissionSets. For "*"
    this warning would fire unless explicitly filtered. It does not cause a failure — it is noise
    only, but should be cleaned up.

  • The KeycloakModuleDescriptorMapper in applications-poc-tools/folio-security maps
    permissionsRequired entries to Keycloak resource scopes. If "*" is passed through
    unchanged it will find no matching scope and emit no policy — harmless but should be guarded.

Verdict: Zero breaking changes to existing consumers.


4. Applicability — Endpoints That Should Adopt ["*"]

Scan Methodology

All folio-org GitHub repositories with names starting mod-* were scanned for "permissionsRequired": [] entries in descriptors/ModuleDescriptor-template.json.
46 repositories contained such entries. Source code was examined for each user-facing
handler to determine whether it accesses caller identity from the JWT context.
A targeted GitHub code search for _self paths in module descriptors returned exactly
5 repositories — all analyzed below.


Category B — Must Migrate to ["*"]

These 6 endpoints require a valid JWT-backed access token but currently declare
"permissionsRequired": [], causing the sidecar to skip token validation entirely.

Module

Method

Path

Evidence

Module

Method

Path

Evidence

mod-users-keycloak

GET

/users-keycloak/_self

UserService.getUserId() extracts userId from X-Okapi-User-Id or JWT sub claim; throws EntityNotFoundException if absent.

mod-users-bl

GET

/bl-users/_self

getBlUsersSelf() reads X-Okapi-Token to identify the caller; returns 401 if absent.

mod-users-bl

POST

/bl-users/settings/myprofile/password

Builds OkapiConnectionParams from X-Okapi-Token and passes it to all downstream credential-update and password-validation calls; cannot function without a valid token.

mod-consortia-keycloak

GET

/consortia/{consortiumId}/_self

Self-endpoint returning the caller's consortium membership; user identity is the sole input. Same pattern as mod-users-keycloak.

mod-consortia

GET

/consortia/{consortiumId}/_self

Self-endpoint returning the caller's consortium membership. Same analysis applies.

mod-login-keycloak

POST

/authn/logout-all

KeycloakService.logoutAll() calls folioExecutionContext.getUserId() and throws BadRequestException("User id is not found") if the user ID is absent from context.

mod-inventory

DELETE

/inventory/instances/{id}/mark-deleted

Data-mutation endpoint requiring caller identity; requires a valid access token and user ID in context.


Category A — Confirmed Truly Public (Keep [])

The following representative endpoints are correctly public and must not be changed:

Module

Method

Path

Reason

Module

Method

Path

Reason

mod-users-keycloak

POST

/users-keycloak/forgotten/password

Password-reset flow; no prior token exists.

mod-users-keycloak

POST

/users-keycloak/forgotten/username

Username-recovery flow; no prior token exists.

mod-login-keycloak

POST

/authn/login

Initial authentication — no prior access token.

mod-login-keycloak

POST

/authn/login-with-expiry

Initial authentication — no prior access token.

mod-login-keycloak

GET

/authn/token

SAML/auth-code token exchange — uses an authorization code, not an access token.

mod-login-keycloak

POST

/authn/logout

Uses a refresh-token cookie for session invalidation; no access token needed.

mod-login-keycloak

POST

/authn/refresh

Exchanges a refresh token for a new access token; access token not yet available.

mod-users-bl

POST

/bl-users/login

Initial login — no prior token.

mod-users-bl

POST

/bl-users/forgotten/password

Password reset — no prior token.

mod-consortia-keycloak

GET

/consortia

Public consortium discovery.

mod-consortia-keycloak

GET

/consortia/{consortiumId}/tenants

Public tenant listing.

mod-consortia-keycloak

GET

/consortia-configuration

Public configuration lookup.

mod-consortia

GET

/consortia

Public consortium discovery.

mod-consortia

GET

/consortia/{consortiumId}/tenants

Public tenant listing.

mod-consortia

GET

/consortia-configuration

Public configuration lookup.

mod-okapi-facade

GET

/_/proxy/health

Health check; must be callable without any token.

All modules

various

/_/tenant, /_/jsonSchemas, /_/ramls

System interfaces; invoked by the platform, not end users.

Remaining Repositories

The remaining ~34 mod-* repositories that contained "permissionsRequired": [] entries
(including mod-bulk-operations, mod-linked-data, mod-data-import, mod-circulation,
mod-inventory-storage, mod-orders-storage, and others) have [] entries exclusively in
_tenant, _timer, _jsonSchemas, or _ramls system interface handlers. The exhaustive
_self-path search confirmed none of these repositories expose user-facing endpoints
with empty permissions that require a token. No migration action is needed for these modules.


5. Risk Assessment

Risk

Severity

Risk

Severity