From 1ef8c9dceb612aec9b7c797e803410eff468a1f0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:48:04 +0200 Subject: [PATCH] refactor: merge tenant isolation into single HandlerInterceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 2 +- docs/architecture.md | 84 ++++++++------ .../cameleer/saas/app/AppController.java | 18 ++- .../cameleer/saas/config/SecurityConfig.java | 10 +- .../config/TenantIsolationInterceptor.java | 109 ++++++++++++++++++ .../saas/config/TenantOwnershipValidator.java | 42 ------- .../saas/config/TenantResolutionFilter.java | 72 ------------ .../cameleer/saas/config/WebConfig.java | 20 ++++ .../saas/deployment/DeploymentController.java | 16 +-- .../environment/EnvironmentController.java | 12 +- .../cameleer/saas/log/LogController.java | 7 +- .../observability/AgentStatusController.java | 10 +- ui/src/auth/OrgResolver.tsx | 21 ++-- 13 files changed, 205 insertions(+), 218 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java delete mode 100644 src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java delete mode 100644 src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/WebConfig.java diff --git a/CLAUDE.md b/CLAUDE.md index fbfea57..acc0234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstr Auth enforcement (current state): - All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations -- Tenant isolation enforced at two levels: `TenantResolutionFilter` (rejects cross-tenant path access) and `TenantOwnershipValidator` (verifies resource ownership at service level) +- Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass) - 10 OAuth2 scopes defined on the Logto API resource (`https://api.cameleer.local`), served to the frontend from `GET /api/config` ## Related Conventions diff --git a/docs/architecture.md b/docs/architecture.md index 6be26cd..e6c44b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 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) | diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java index 8ab1fb9..1c15e79 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java @@ -3,7 +3,6 @@ package net.siegeln.cameleer.saas.app; import com.fasterxml.jackson.databind.ObjectMapper; import net.siegeln.cameleer.saas.app.dto.AppResponse; import net.siegeln.cameleer.saas.app.dto.CreateAppRequest; -import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.environment.EnvironmentService; import net.siegeln.cameleer.saas.runtime.RuntimeConfig; import net.siegeln.cameleer.saas.tenant.TenantRepository; @@ -36,19 +35,16 @@ public class AppController { private final EnvironmentService environmentService; private final RuntimeConfig runtimeConfig; private final TenantRepository tenantRepository; - private final TenantOwnershipValidator tenantOwnershipValidator; public AppController(AppService appService, ObjectMapper objectMapper, EnvironmentService environmentService, RuntimeConfig runtimeConfig, - TenantRepository tenantRepository, - TenantOwnershipValidator tenantOwnershipValidator) { + TenantRepository tenantRepository) { this.appService = appService; this.objectMapper = objectMapper; this.environmentService = environmentService; this.runtimeConfig = runtimeConfig; this.tenantRepository = tenantRepository; - this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping(consumes = "multipart/form-data") @@ -58,7 +54,7 @@ public class AppController { @RequestPart("metadata") String metadataJson, @RequestPart("file") MultipartFile file, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { var request = objectMapper.readValue(metadataJson, CreateAppRequest.class); UUID actorId = resolveActorId(authentication); @@ -79,7 +75,7 @@ public class AppController { @GetMapping public ResponseEntity> list(@PathVariable UUID environmentId) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + var apps = appService.listByEnvironmentId(environmentId) .stream() .map(this::toResponse) @@ -91,7 +87,7 @@ public class AppController { public ResponseEntity getById( @PathVariable UUID environmentId, @PathVariable UUID appId) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + return appService.getById(appId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); @@ -104,7 +100,7 @@ public class AppController { @PathVariable UUID appId, @RequestPart("file") MultipartFile file, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { UUID actorId = resolveActorId(authentication); var entity = appService.reuploadJar(appId, file, actorId); @@ -120,7 +116,7 @@ public class AppController { @PathVariable UUID environmentId, @PathVariable UUID appId, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { UUID actorId = resolveActorId(authentication); appService.delete(appId, actorId); @@ -137,7 +133,7 @@ public class AppController { @PathVariable UUID appId, @RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { var actorId = resolveActorId(authentication); var app = appService.updateRouting(appId, request.exposedPort(), actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index b951ea0..e8e09a0 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -19,7 +19,6 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import java.net.URL; @@ -31,12 +30,6 @@ import java.util.List; @EnableMethodSecurity public class SecurityConfig { - private final TenantResolutionFilter tenantResolutionFilter; - - public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) { - this.tenantResolutionFilter = tenantResolutionFilter; - } - @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -51,8 +44,7 @@ public class SecurityConfig { .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> - jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) - .addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class); + jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))); return http.build(); } diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java new file mode 100644 index 0000000..916e53b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java @@ -0,0 +1,109 @@ +package net.siegeln.cameleer.saas.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.siegeln.cameleer.saas.app.AppRepository; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +import net.siegeln.cameleer.saas.tenant.TenantService; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.Map; +import java.util.UUID; + +/** + * Single interceptor handling both tenant resolution (JWT org_id → TenantContext) + * and tenant isolation (path variable validation). Fail-closed: any endpoint with + * {tenantId}, {environmentId}, or {appId} in its path is automatically isolated. + * Platform admins (SCOPE_platform:admin) bypass all isolation checks. + */ +@Component +public class TenantIsolationInterceptor implements HandlerInterceptor { + + private final TenantService tenantService; + private final EnvironmentRepository environmentRepository; + private final AppRepository appRepository; + + public TenantIsolationInterceptor(TenantService tenantService, + EnvironmentRepository environmentRepository, + AppRepository appRepository) { + this.tenantService = tenantService; + this.environmentRepository = environmentRepository; + this.appRepository = appRepository; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) return true; + + // 1. Resolve: JWT organization_id → TenantContext + Jwt jwt = jwtAuth.getToken(); + String orgId = jwt.getClaimAsString("organization_id"); + if (orgId != null) { + tenantService.getByLogtoOrgId(orgId) + .ifPresent(tenant -> TenantContext.setTenantId(tenant.getId())); + } + + // 2. Validate: read path variables from Spring's HandlerMapping + @SuppressWarnings("unchecked") + Map pathVars = (Map) request.getAttribute( + HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (pathVars == null || pathVars.isEmpty()) return true; + + UUID resolvedTenantId = TenantContext.getTenantId(); + boolean isPlatformAdmin = jwtAuth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("SCOPE_platform:admin")); + + if (isPlatformAdmin) return true; + + // Check tenantId in path (e.g., /api/tenants/{tenantId}/environments) + if (pathVars.containsKey("tenantId")) { + if (resolvedTenantId == null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context"); + return false; + } + UUID pathTenantId = UUID.fromString(pathVars.get("tenantId")); + if (!pathTenantId.equals(resolvedTenantId)) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Tenant mismatch"); + return false; + } + } + + // Check environmentId in path (e.g., /api/environments/{environmentId}/apps) + if (pathVars.containsKey("environmentId") && resolvedTenantId != null) { + UUID envId = UUID.fromString(pathVars.get("environmentId")); + environmentRepository.findById(envId).ifPresent(env -> { + if (!env.getTenantId().equals(resolvedTenantId)) { + throw new AccessDeniedException("Environment does not belong to tenant"); + } + }); + } + + // Check appId in path (e.g., /api/apps/{appId}/deploy) + if (pathVars.containsKey("appId") && resolvedTenantId != null) { + UUID appId = UUID.fromString(pathVars.get("appId")); + appRepository.findById(appId).ifPresent(app -> + environmentRepository.findById(app.getEnvironmentId()).ifPresent(env -> { + if (!env.getTenantId().equals(resolvedTenantId)) { + throw new AccessDeniedException("App does not belong to tenant"); + } + }) + ); + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + TenantContext.clear(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java deleted file mode 100644 index 3ae02db..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java +++ /dev/null @@ -1,42 +0,0 @@ -package net.siegeln.cameleer.saas.config; - -import net.siegeln.cameleer.saas.app.AppRepository; -import net.siegeln.cameleer.saas.environment.EnvironmentRepository; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.stereotype.Component; - -import java.util.UUID; - -@Component -public class TenantOwnershipValidator { - - private final EnvironmentRepository environmentRepository; - private final AppRepository appRepository; - - public TenantOwnershipValidator(EnvironmentRepository environmentRepository, AppRepository appRepository) { - this.environmentRepository = environmentRepository; - this.appRepository = appRepository; - } - - public void validateEnvironmentAccess(UUID environmentId) { - UUID currentTenantId = TenantContext.getTenantId(); - if (currentTenantId == null) return; // platform admin or no org context - environmentRepository.findById(environmentId).ifPresent(env -> { - if (!env.getTenantId().equals(currentTenantId)) { - throw new AccessDeniedException("Environment does not belong to current tenant"); - } - }); - } - - public void validateAppAccess(UUID appId) { - UUID currentTenantId = TenantContext.getTenantId(); - if (currentTenantId == null) return; - appRepository.findById(appId).ifPresent(app -> { - environmentRepository.findById(app.getEnvironmentId()).ifPresent(env -> { - if (!env.getTenantId().equals(currentTenantId)) { - throw new AccessDeniedException("App does not belong to current tenant"); - } - }); - }); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java deleted file mode 100644 index 720c477..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java +++ /dev/null @@ -1,72 +0,0 @@ -package net.siegeln.cameleer.saas.config; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import net.siegeln.cameleer.saas.tenant.TenantService; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.UUID; - -@Component -public class TenantResolutionFilter extends OncePerRequestFilter { - - private final TenantService tenantService; - - public TenantResolutionFilter(TenantService tenantService) { - this.tenantService = tenantService; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - var authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication instanceof JwtAuthenticationToken jwtAuth) { - Jwt jwt = jwtAuth.getToken(); - String orgId = jwt.getClaimAsString("organization_id"); - - if (orgId != null) { - tenantService.getByLogtoOrgId(orgId) - .ifPresent(tenant -> TenantContext.setTenantId(tenant.getId())); - } - - // Path-based tenant validation for /api/tenants/{uuid}/** endpoints - String path = request.getRequestURI(); - if (path.startsWith("/api/tenants/")) { - UUID resolvedTenantId = TenantContext.getTenantId(); - String[] segments = path.split("/"); - if (segments.length >= 4) { - try { - UUID pathTenantId = UUID.fromString(segments[3]); - boolean isPlatformAdmin = jwtAuth.getAuthorities().stream() - .anyMatch(a -> a.getAuthority().equals("SCOPE_platform:admin")); - if (resolvedTenantId == null && !isPlatformAdmin) { - response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context"); - return; - } - if (resolvedTenantId != null && !pathTenantId.equals(resolvedTenantId) && !isPlatformAdmin) { - response.sendError(HttpServletResponse.SC_FORBIDDEN, "Tenant mismatch"); - return; - } - } catch (IllegalArgumentException ignored) { - // Non-UUID segment like "by-slug" — allow through - } - } - } - } - - filterChain.doFilter(request, response); - } finally { - TenantContext.clear(); - } - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/WebConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/WebConfig.java new file mode 100644 index 0000000..ad5b21a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/WebConfig.java @@ -0,0 +1,20 @@ +package net.siegeln.cameleer.saas.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final TenantIsolationInterceptor tenantIsolationInterceptor; + + public WebConfig(TenantIsolationInterceptor tenantIsolationInterceptor) { + this.tenantIsolationInterceptor = tenantIsolationInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantIsolationInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java index dc19117..bc33901 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java @@ -1,6 +1,5 @@ package net.siegeln.cameleer.saas.deployment; -import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,12 +19,9 @@ import java.util.UUID; public class DeploymentController { private final DeploymentService deploymentService; - private final TenantOwnershipValidator tenantOwnershipValidator; - public DeploymentController(DeploymentService deploymentService, - TenantOwnershipValidator tenantOwnershipValidator) { + public DeploymentController(DeploymentService deploymentService) { this.deploymentService = deploymentService; - this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping("/deploy") @@ -33,7 +29,7 @@ public class DeploymentController { public ResponseEntity deploy( @PathVariable UUID appId, Authentication authentication) { - tenantOwnershipValidator.validateAppAccess(appId); + try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.deploy(appId, actorId); @@ -47,7 +43,7 @@ public class DeploymentController { @GetMapping("/deployments") public ResponseEntity> listDeployments(@PathVariable UUID appId) { - tenantOwnershipValidator.validateAppAccess(appId); + var deployments = deploymentService.listByAppId(appId) .stream() .map(this::toResponse) @@ -59,7 +55,7 @@ public class DeploymentController { public ResponseEntity getDeployment( @PathVariable UUID appId, @PathVariable UUID deploymentId) { - tenantOwnershipValidator.validateAppAccess(appId); + return deploymentService.getById(deploymentId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); @@ -70,7 +66,7 @@ public class DeploymentController { public ResponseEntity stop( @PathVariable UUID appId, Authentication authentication) { - tenantOwnershipValidator.validateAppAccess(appId); + try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.stop(appId, actorId); @@ -87,7 +83,7 @@ public class DeploymentController { public ResponseEntity restart( @PathVariable UUID appId, Authentication authentication) { - tenantOwnershipValidator.validateAppAccess(appId); + try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.restart(appId, actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java index ce95cc1..c64124d 100644 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java +++ b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java @@ -1,7 +1,6 @@ package net.siegeln.cameleer.saas.environment; import jakarta.validation.Valid; -import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest; import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse; import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest; @@ -26,12 +25,9 @@ import java.util.UUID; public class EnvironmentController { private final EnvironmentService environmentService; - private final TenantOwnershipValidator tenantOwnershipValidator; - public EnvironmentController(EnvironmentService environmentService, - TenantOwnershipValidator tenantOwnershipValidator) { + public EnvironmentController(EnvironmentService environmentService) { this.environmentService = environmentService; - this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping @@ -64,7 +60,7 @@ public class EnvironmentController { public ResponseEntity getById( @PathVariable UUID tenantId, @PathVariable UUID environmentId) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + return environmentService.getById(environmentId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); @@ -77,7 +73,7 @@ public class EnvironmentController { @PathVariable UUID environmentId, @Valid @RequestBody UpdateEnvironmentRequest request, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { UUID actorId = resolveActorId(authentication); var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId); @@ -93,7 +89,7 @@ public class EnvironmentController { @PathVariable UUID tenantId, @PathVariable UUID environmentId, Authentication authentication) { - tenantOwnershipValidator.validateEnvironmentAccess(environmentId); + try { UUID actorId = resolveActorId(authentication); environmentService.delete(environmentId, actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/log/LogController.java b/src/main/java/net/siegeln/cameleer/saas/log/LogController.java index ff8fab1..0431551 100644 --- a/src/main/java/net/siegeln/cameleer/saas/log/LogController.java +++ b/src/main/java/net/siegeln/cameleer/saas/log/LogController.java @@ -1,6 +1,5 @@ package net.siegeln.cameleer.saas.log; -import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.log.dto.LogEntry; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; @@ -20,12 +19,9 @@ import java.util.UUID; public class LogController { private final ContainerLogService containerLogService; - private final TenantOwnershipValidator tenantOwnershipValidator; - public LogController(ContainerLogService containerLogService, - TenantOwnershipValidator tenantOwnershipValidator) { + public LogController(ContainerLogService containerLogService) { this.containerLogService = containerLogService; - this.tenantOwnershipValidator = tenantOwnershipValidator; } @GetMapping @@ -36,7 +32,6 @@ public class LogController { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until, @RequestParam(defaultValue = "500") int limit, @RequestParam(defaultValue = "both") String stream) { - tenantOwnershipValidator.validateAppAccess(appId); List entries = containerLogService.query(appId, since, until, limit, stream); return ResponseEntity.ok(entries); } diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java index 039b02e..934e068 100644 --- a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java @@ -1,6 +1,5 @@ package net.siegeln.cameleer.saas.observability; -import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse; import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse; import org.springframework.http.ResponseEntity; @@ -14,18 +13,15 @@ import java.util.UUID; public class AgentStatusController { private final AgentStatusService agentStatusService; - private final TenantOwnershipValidator tenantOwnershipValidator; - public AgentStatusController(AgentStatusService agentStatusService, - TenantOwnershipValidator tenantOwnershipValidator) { + public AgentStatusController(AgentStatusService agentStatusService) { this.agentStatusService = agentStatusService; - this.tenantOwnershipValidator = tenantOwnershipValidator; } @GetMapping("/agent-status") @PreAuthorize("hasAuthority('SCOPE_observe:read')") public ResponseEntity getAgentStatus(@PathVariable UUID appId) { - tenantOwnershipValidator.validateAppAccess(appId); + try { return ResponseEntity.ok(agentStatusService.getAgentStatus(appId)); } catch (IllegalArgumentException e) { @@ -36,7 +32,7 @@ public class AgentStatusController { @GetMapping("/observability-status") @PreAuthorize("hasAuthority('SCOPE_observe:read')") public ResponseEntity getObservabilityStatus(@PathVariable UUID appId) { - tenantOwnershipValidator.validateAppAccess(appId); + try { return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId)); } catch (IllegalArgumentException e) { diff --git a/ui/src/auth/OrgResolver.tsx b/ui/src/auth/OrgResolver.tsx index 7f21b32..7fded57 100644 --- a/ui/src/auth/OrgResolver.tsx +++ b/ui/src/auth/OrgResolver.tsx @@ -38,9 +38,8 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { useEffect(() => { if (!me) return; - // Read scopes from access tokens: - // - org-scoped resource token → tenant-level scopes (apps:manage, observe:read, etc.) - // - global resource token → platform-level scopes (platform:admin) + // Read scopes from a single access token (org-scoped when an org is selected, + // global otherwise). Logto merges all applicable scopes into either token. fetchConfig().then(async (config) => { if (!config.logtoResource) return; @@ -55,18 +54,12 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { }; try { - const [orgToken, globalToken] = await Promise.all([ - currentOrgId - ? getAccessToken(config.logtoResource, currentOrgId).catch(() => undefined) - : Promise.resolve(undefined), - getAccessToken(config.logtoResource).catch(() => undefined), - ]); + const token = await (currentOrgId + ? getAccessToken(config.logtoResource, currentOrgId) + : getAccessToken(config.logtoResource) + ).catch(() => undefined); - const merged = new Set([ - ...extractScopes(orgToken), - ...extractScopes(globalToken), - ]); - setScopes(merged); + setScopes(new Set(extractScopes(token))); } catch { setScopes(new Set()); }