refactor: merge tenant isolation into single HandlerInterceptor
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 37s

Replace TenantResolutionFilter + TenantOwnershipValidator (15 manual
calls across 5 controllers) with a single TenantIsolationInterceptor
that uses Spring HandlerMapping path variables for fail-closed tenant
isolation. New endpoints with {tenantId}, {environmentId}, or {appId}
path variables are automatically isolated without manual code.

Simplify OrgResolver from dual-token fetch to single token — Logto
merges all scopes into either token type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 15:48:04 +02:00
parent 051f7fdae9
commit 1ef8c9dceb
13 changed files with 205 additions and 218 deletions

View File

@@ -181,7 +181,7 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
2. Frontend obtains org-scoped access token via `getAccessToken(resource, orgId)`.
3. Backend validates via Logto JWKS (Spring OAuth2 Resource Server).
4. `organization_id` claim in JWT resolves to internal tenant ID via
`TenantResolutionFilter`.
`TenantIsolationInterceptor`.
**SaaS platform -> cameleer3-server API (M2M):**
@@ -230,9 +230,7 @@ public class SecurityConfig {
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.addFilterAfter(tenantResolutionFilter,
BearerTokenAuthenticationFilter.class);
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
}
@@ -246,9 +244,11 @@ public class SecurityConfig {
3. `JwtAuthenticationConverter` maps the `scope` claim to Spring authorities:
`scope: "platform:admin observe:read"` becomes `SCOPE_platform:admin` and
`SCOPE_observe:read`.
4. `TenantResolutionFilter` reads `organization_id` from the JWT, resolves it
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores
it on `TenantContext` (ThreadLocal).
4. `TenantIsolationInterceptor` (registered as a `HandlerInterceptor` on
`/api/**` via `WebConfig`) reads `organization_id` from the JWT, resolves it
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, stores it
on `TenantContext` (ThreadLocal), and validates path variable isolation (see
Section 8.1).
**Authorization enforcement** -- Every mutating API endpoint uses Spring
`@PreAuthorize` annotations with `SCOPE_` authorities. Read-only list/get
@@ -294,10 +294,11 @@ in sync:
fetch tenant memberships, maps them to `OrgInfo` objects in the Zustand org
store, and auto-selects the first org if the user belongs to exactly one.
- **Effect 2: Scope fetching** (depends on `[me, currentOrgId]`) -- Fetches the
API resource identifier from `/api/config`, then obtains both an org-scoped
access token (`getAccessToken(resource, orgId)`) and a global access token
(`getAccessToken(resource)`). Scopes from both tokens are decoded from the JWT
payload and merged into a single `Set<string>` via `setScopes()`.
API resource identifier from `/api/config`, then obtains an org-scoped access
token (`getAccessToken(resource, orgId)`). Scopes are decoded from the JWT
payload and written to the store via `setScopes()`. A single token fetch is
sufficient because Logto merges all granted scopes (including global scopes
like `platform:admin`) into the org-scoped token.
The two-effect split ensures scopes are re-fetched whenever the user switches
organizations, preventing stale scope sets from a previously selected org.
@@ -679,36 +680,43 @@ public String spa() { return "forward:/index.html"; }
### 8.1 Tenant Isolation
Tenant isolation is enforced through two defense layers that operate in sequence:
Tenant isolation is enforced by a single Spring `HandlerInterceptor` --
`TenantIsolationInterceptor` -- registered on `/api/**` via `WebConfig`. It
handles both tenant resolution and ownership validation in one place:
**Layer 1: Path-based validation (`TenantResolutionFilter`)**
**Resolution (every `/api/**` request):**
Runs after JWT authentication on every request. First, it resolves the JWT's
`organization_id` claim to an internal tenant UUID via `TenantService` and stores
it on `TenantContext` (ThreadLocal). Then, for `/api/tenants/{uuid}/**` paths,
it compares the path UUID against the resolved tenant ID:
The interceptor's `preHandle()` reads the JWT's `organization_id` claim,
resolves it to an internal tenant UUID via `TenantService.getByLogtoOrgId()`,
and stores it on `TenantContext` (ThreadLocal). If no organization context is
resolved and the user is not a platform admin, the interceptor returns
**403 Forbidden**.
- If the path segment is a valid UUID and does not match the JWT's resolved
tenant, the filter returns **403 Forbidden** (`"Tenant mismatch"`).
- If no organization context is resolved and the user is not a platform admin,
the filter returns **403 Forbidden** (`"No organization context"`).
- Non-UUID path segments (e.g., `/api/tenants/by-slug/...`) pass through
without validation (these use slug-based lookup, not UUID matching).
- Users with `SCOPE_platform:admin` bypass both checks.
**Path variable validation (automatic, fail-closed):**
**Layer 2: Entity-ownership validation (`TenantOwnershipValidator`)**
After resolution, the interceptor reads Spring's
`HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE` to inspect path variables
defined on the matched handler method. It checks three path variable names:
A Spring `@Component` injected into `AppController`, `DeploymentController`,
`LogController`, `AgentStatusController`, and `EnvironmentController`. Provides
two methods:
- `{tenantId}` -- Compared directly against the resolved tenant ID.
- `{environmentId}` -- The environment is loaded and its `tenantId` is compared.
- `{appId}` -- The app -> environment -> tenant chain is followed and compared.
- `validateEnvironmentAccess(UUID)` -- Loads the environment by ID and confirms
its `tenantId` matches `TenantContext.getTenantId()`. Throws
`AccessDeniedException` on mismatch.
- `validateAppAccess(UUID)` -- Follows the app -> environment -> tenant chain
and confirms tenant ownership. Throws `AccessDeniedException` on mismatch.
If any path variable is present and the resolved tenant does not own that
resource, the interceptor returns **403 Forbidden**. This is **fail-closed**:
any new endpoint that uses these path variable names is automatically isolated
without requiring manual validation calls.
Platform admins (`TenantContext.getTenantId() == null`) bypass both validations.
**Platform admin bypass:**
Users with `SCOPE_platform:admin` bypass all isolation checks. Their
`TenantContext` is left empty (null tenant ID), which downstream services
interpret as unrestricted access.
**Cleanup:**
`TenantContext.clear()` is called in `afterCompletion()` to prevent ThreadLocal
leaks regardless of whether the request succeeded or failed.
**Additional isolation boundaries:**
@@ -815,8 +823,8 @@ Audit entries are immutable (append-only, no UPDATE/DELETE operations).
|
v
OrgResolver -- Effect 1 [me]: populate org store from /api/me
| -- Effect 2 [me, currentOrgId]: fetch org-scoped +
| -- global access tokens, merge scopes into Set
| -- Effect 2 [me, currentOrgId]: fetch org-scoped
| -- access token, decode scopes into Set
| -- Re-runs Effect 2 on org switch (stale scope fix)
v
Layout + pages -- Read from useOrgStore for tenant context
@@ -959,8 +967,8 @@ can request the correct API resource scopes during Logto sign-in.
| `docker-compose.yml` | Service topology and configuration |
| `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap |
| `src/.../config/SecurityConfig.java` | Spring Security filter chain |
| `src/.../config/TenantResolutionFilter.java` | JWT org_id -> tenant resolution + path-based tenant validation |
| `src/.../config/TenantOwnershipValidator.java` | Entity-level tenant ownership checks (env, app) |
| `src/.../config/TenantIsolationInterceptor.java` | JWT org_id -> tenant resolution + path variable ownership validation (fail-closed) |
| `src/.../config/WebConfig.java` | Registers `TenantIsolationInterceptor` on `/api/**` |
| `src/.../config/TenantContext.java` | ThreadLocal tenant ID holder |
| `src/.../config/MeController.java` | User identity + tenant endpoint |
| `src/.../config/PublicConfigController.java` | SPA configuration endpoint (Logto config + scopes) |