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 |
|---|---|---|---|
Truly public | No token at all required |
|
|
Authenticated, no named permission | Valid JWT required; no specific permission checked |
|
|
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 inapplications-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 |
|---|---|
| Simple, contained within the existing field, consistent with the wildcard idiom already used for HTTP methods in |
New boolean field | Cleaner semantics, but requires a schema change, a Jackson mapping change in |
New field | 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 | Behavior for | Behavior for | Change needed |
|---|---|---|---|---|---|
120 |
| Skip if | Runs; absent token allowed via recover handler | Runs; absent token must be rejected | Update |
130 |
| Skip if | Skipped | Must run (validate token tenant) | Update |
160 |
| Skip if | Skipped | Should skip (no named permission to evaluate) | Update |
Key observation: KeycloakAuthorizationFilter and KeycloakTenantFilter share identicalshouldSkip 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"*"inpermissionsRequired.The
CapabilitiesModuleEventPublisherinmgr-tenant-entitlementslogs aWARNwhen a
permission name inpermissionsRequiredhas no matching entry inpermissionSets. For"*"
this warning would fire unless explicitly filtered. It does not cause a failure — it is noise
only, but should be cleaned up.The
KeycloakModuleDescriptorMapperinapplications-poc-tools/folio-securitymapspermissionsRequiredentries 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 |
|---|---|---|---|
| GET |
|
|
| GET |
|
|
| POST |
| Builds |
| GET |
| Self-endpoint returning the caller's consortium membership; user identity is the sole input. Same pattern as |
| GET |
| Self-endpoint returning the caller's consortium membership. Same analysis applies. |
| POST |
|
|
| DELETE |
| 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 |
|---|---|---|---|
| POST |
| Password-reset flow; no prior token exists. |
| POST |
| Username-recovery flow; no prior token exists. |
| POST |
| Initial authentication — no prior access token. |
| POST |
| Initial authentication — no prior access token. |
| GET |
| SAML/auth-code token exchange — uses an authorization code, not an access token. |
| POST |
| Uses a refresh-token cookie for session invalidation; no access token needed. |
| POST |
| Exchanges a refresh token for a new access token; access token not yet available. |
| POST |
| Initial login — no prior token. |
| POST |
| Password reset — no prior token. |
| GET |
| Public consortium discovery. |
| GET |
| Public tenant listing. |
| GET |
| Public configuration lookup. |
| GET |
| Public consortium discovery. |
| GET |
| Public tenant listing. |
| GET |
| Public configuration lookup. |
| GET |
| Health check; must be callable without any token. |
All modules | various |
| 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 |
|---|