Skip to content

fix(core-tenants): open the tenant scope for every request (#6636) - #6640

Merged
delchev merged 1 commit into
masterfrom
fix/static-paths-tenant-scope
Aug 10, 2026
Merged

fix(core-tenants): open the tenant scope for every request (#6636)#6640
delchev merged 1 commit into
masterfrom
fix/static-paths-tenant-scope

Conversation

@delchev

@delchev delchev commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #6636.

The bug

/webjars/** is permitAll and serves fine anonymously. Present valid credentials and the same URL answers 401:

Path anonymous with valid -u admin:admin
/webjars/alpinejs/dist/cdn.min.js 200 401
/favicon.ico 404 401

TenantContextInitFilter.shouldNotFilter() opted /webjars/, /css/, /js/ and *.ico out of the tenant execution scope. There is a single security filter chain, so BasicAuthenticationFilter still ran on those paths — with no tenant open. It reaches CustomUserDetailsService.loadUserByUsername, whose first line is tenantContext.getCurrentTenant(), and TenantContextImpl.getCurrentTenant() throws IllegalStateException when unscoped. Spring wraps that in an InternalAuthenticationServiceException, so the Basic entry point answers 401 — before resource resolution, which is why a non-existent /favicon.ico turns from 404 into 401.

Anonymous requests never attempt authentication, hence "works until you authenticate". It bites any client attaching a global Authorization header (API clients, smoke tests, monitoring probes, a reverse proxy injecting auth) and every webjar the UI loads in a browser that authenticated through the Basic challenge — a flow BasicSecurityConfig deliberately keeps for navigations.

The fix

Option 2 from the issue: drop the skip. The opt-out bought a ThreadLocal set/restore plus a Caffeine-cached tenant lookup (a constant in single-tenant mode), so narrowing it is not worth the second list to keep in sync with PUBLIC_PATTERNS — note /css/ and /js/ are not even in PUBLIC_PATTERNS, so they are denyAll regardless.

Every request now runs tenant-scoped. That also closes the latent trap that anything reading tenant-scoped state on those paths — configuration overrides, datasource routing, the Translator — throws instead of falling back.

Behaviour change: on an unregistered tenant host these paths now answer 404 instead of serving, consistent with every other path on that host (/ already 404s there). The SSO tenant-membership filters (KeycloakTenantFilter, CognitoTenantFilter) keep their own skip lists — those are an authorization allowance for pre-login assets, a different concern, and are untouched.

Verification

SecurityIT.testCredentialsDoNotBreakPublicStaticPaths asserts the invariant rather than a hard-coded status: presenting credentials must not change the outcome of a public static path, and that path must not be 401 anonymously. This survives a webjar version bump.

  • With the fix: SecurityIT 7/7 green — webjar 200/200, favicon 404/404.
  • Against the unfixed filter (negative control, same test): fails with Authenticated status of the public path [/webjars/alpinejs/dist/cdn.min.js] expected: 200 but was: 401 — exactly the reported symptom.

mvn formatter:validate and the release-profile javadoc build pass on the touched modules.

🤖 Generated with Claude Code

/webjars/**, /css/**, /js/** and *.ico were opted out of the tenant
execution scope. There is a single security filter chain, so the basic
authentication filter still ran on those paths - with no tenant open.
CustomUserDetailsService resolves the user against the current tenant and
TenantContextImpl.getCurrentTenant() throws when unscoped, so Spring wrapped
that as an authentication failure and the basic entry point answered 401.
A permitAll static path therefore served 200 anonymously and 401 to VALID
credentials - which bites any client attaching a global Authorization header,
and every webjar the UI loads in a browser that authenticated through the
Basic challenge.

The skip only saved a ThreadLocal set/restore plus a cached tenant lookup, so
it is dropped rather than narrowed. Every request now runs tenant-scoped,
which also closes the latent trap that anything reading tenant-scoped state on
those paths (configuration overrides, datasource routing, translations) throws
rather than falling back. On an unregistered tenant host those paths now
answer 404, like every other path on that host.

SecurityIT guards the invariant - presenting credentials must not change the
outcome of a public static path. It fails against the unfixed filter with
exactly the reported 200 -> 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit a02a1fe into master Aug 10, 2026
15 of 16 checks passed
@delchev
delchev deleted the fix/static-paths-tenant-scope branch August 10, 2026 07:16
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.

[Security] A permitAll static path answers 401 when valid Basic credentials are presented

1 participant