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

@@ -28,7 +28,7 @@ The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstr
Auth enforcement (current state): Auth enforcement (current state):
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations - 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` - 10 OAuth2 scopes defined on the Logto API resource (`https://api.cameleer.local`), served to the frontend from `GET /api/config`
## Related Conventions ## Related Conventions

View File

@@ -181,7 +181,7 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
2. Frontend obtains org-scoped access token via `getAccessToken(resource, orgId)`. 2. Frontend obtains org-scoped access token via `getAccessToken(resource, orgId)`.
3. Backend validates via Logto JWKS (Spring OAuth2 Resource Server). 3. Backend validates via Logto JWKS (Spring OAuth2 Resource Server).
4. `organization_id` claim in JWT resolves to internal tenant ID via 4. `organization_id` claim in JWT resolves to internal tenant ID via
`TenantResolutionFilter`. `TenantIsolationInterceptor`.
**SaaS platform -> cameleer3-server API (M2M):** **SaaS platform -> cameleer3-server API (M2M):**
@@ -230,9 +230,7 @@ public class SecurityConfig {
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
.addFilterAfter(tenantResolutionFilter,
BearerTokenAuthenticationFilter.class);
return http.build(); return http.build();
} }
} }
@@ -246,9 +244,11 @@ public class SecurityConfig {
3. `JwtAuthenticationConverter` maps the `scope` claim to Spring authorities: 3. `JwtAuthenticationConverter` maps the `scope` claim to Spring authorities:
`scope: "platform:admin observe:read"` becomes `SCOPE_platform:admin` and `scope: "platform:admin observe:read"` becomes `SCOPE_platform:admin` and
`SCOPE_observe:read`. `SCOPE_observe:read`.
4. `TenantResolutionFilter` reads `organization_id` from the JWT, resolves it 4. `TenantIsolationInterceptor` (registered as a `HandlerInterceptor` on
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores `/api/**` via `WebConfig`) reads `organization_id` from the JWT, resolves it
it on `TenantContext` (ThreadLocal). 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 **Authorization enforcement** -- Every mutating API endpoint uses Spring
`@PreAuthorize` annotations with `SCOPE_` authorities. Read-only list/get `@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 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. store, and auto-selects the first org if the user belongs to exactly one.
- **Effect 2: Scope fetching** (depends on `[me, currentOrgId]`) -- Fetches the - **Effect 2: Scope fetching** (depends on `[me, currentOrgId]`) -- Fetches the
API resource identifier from `/api/config`, then obtains both an org-scoped API resource identifier from `/api/config`, then obtains an org-scoped access
access token (`getAccessToken(resource, orgId)`) and a global access token token (`getAccessToken(resource, orgId)`). Scopes are decoded from the JWT
(`getAccessToken(resource)`). Scopes from both tokens are decoded from the JWT payload and written to the store via `setScopes()`. A single token fetch is
payload and merged into a single `Set<string>` via `setScopes()`. 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 The two-effect split ensures scopes are re-fetched whenever the user switches
organizations, preventing stale scope sets from a previously selected org. 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 ### 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 The interceptor's `preHandle()` reads the JWT's `organization_id` claim,
`organization_id` claim to an internal tenant UUID via `TenantService` and stores resolves it to an internal tenant UUID via `TenantService.getByLogtoOrgId()`,
it on `TenantContext` (ThreadLocal). Then, for `/api/tenants/{uuid}/**` paths, and stores it on `TenantContext` (ThreadLocal). If no organization context is
it compares the path UUID against the resolved tenant ID: 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 **Path variable validation (automatic, fail-closed):**
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.
**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`, - `{tenantId}` -- Compared directly against the resolved tenant ID.
`LogController`, `AgentStatusController`, and `EnvironmentController`. Provides - `{environmentId}` -- The environment is loaded and its `tenantId` is compared.
two methods: - `{appId}` -- The app -> environment -> tenant chain is followed and compared.
- `validateEnvironmentAccess(UUID)` -- Loads the environment by ID and confirms If any path variable is present and the resolved tenant does not own that
its `tenantId` matches `TenantContext.getTenantId()`. Throws resource, the interceptor returns **403 Forbidden**. This is **fail-closed**:
`AccessDeniedException` on mismatch. any new endpoint that uses these path variable names is automatically isolated
- `validateAppAccess(UUID)` -- Follows the app -> environment -> tenant chain without requiring manual validation calls.
and confirms tenant ownership. Throws `AccessDeniedException` on mismatch.
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:** **Additional isolation boundaries:**
@@ -815,8 +823,8 @@ Audit entries are immutable (append-only, no UPDATE/DELETE operations).
| |
v v
OrgResolver -- Effect 1 [me]: populate org store from /api/me OrgResolver -- Effect 1 [me]: populate org store from /api/me
| -- Effect 2 [me, currentOrgId]: fetch org-scoped + | -- Effect 2 [me, currentOrgId]: fetch org-scoped
| -- global access tokens, merge scopes into Set | -- access token, decode scopes into Set
| -- Re-runs Effect 2 on org switch (stale scope fix) | -- Re-runs Effect 2 on org switch (stale scope fix)
v v
Layout + pages -- Read from useOrgStore for tenant context 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-compose.yml` | Service topology and configuration |
| `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap | | `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap |
| `src/.../config/SecurityConfig.java` | Spring Security filter chain | | `src/.../config/SecurityConfig.java` | Spring Security filter chain |
| `src/.../config/TenantResolutionFilter.java` | JWT org_id -> tenant resolution + path-based tenant validation | | `src/.../config/TenantIsolationInterceptor.java` | JWT org_id -> tenant resolution + path variable ownership validation (fail-closed) |
| `src/.../config/TenantOwnershipValidator.java` | Entity-level tenant ownership checks (env, app) | | `src/.../config/WebConfig.java` | Registers `TenantIsolationInterceptor` on `/api/**` |
| `src/.../config/TenantContext.java` | ThreadLocal tenant ID holder | | `src/.../config/TenantContext.java` | ThreadLocal tenant ID holder |
| `src/.../config/MeController.java` | User identity + tenant endpoint | | `src/.../config/MeController.java` | User identity + tenant endpoint |
| `src/.../config/PublicConfigController.java` | SPA configuration endpoint (Logto config + scopes) | | `src/.../config/PublicConfigController.java` | SPA configuration endpoint (Logto config + scopes) |

View File

@@ -3,7 +3,6 @@ package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.app.dto.AppResponse; import net.siegeln.cameleer.saas.app.dto.AppResponse;
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest; 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.environment.EnvironmentService;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig; import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.TenantRepository; import net.siegeln.cameleer.saas.tenant.TenantRepository;
@@ -36,19 +35,16 @@ public class AppController {
private final EnvironmentService environmentService; private final EnvironmentService environmentService;
private final RuntimeConfig runtimeConfig; private final RuntimeConfig runtimeConfig;
private final TenantRepository tenantRepository; private final TenantRepository tenantRepository;
private final TenantOwnershipValidator tenantOwnershipValidator;
public AppController(AppService appService, ObjectMapper objectMapper, public AppController(AppService appService, ObjectMapper objectMapper,
EnvironmentService environmentService, EnvironmentService environmentService,
RuntimeConfig runtimeConfig, RuntimeConfig runtimeConfig,
TenantRepository tenantRepository, TenantRepository tenantRepository) {
TenantOwnershipValidator tenantOwnershipValidator) {
this.appService = appService; this.appService = appService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.environmentService = environmentService; this.environmentService = environmentService;
this.runtimeConfig = runtimeConfig; this.runtimeConfig = runtimeConfig;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.tenantOwnershipValidator = tenantOwnershipValidator;
} }
@PostMapping(consumes = "multipart/form-data") @PostMapping(consumes = "multipart/form-data")
@@ -58,7 +54,7 @@ public class AppController {
@RequestPart("metadata") String metadataJson, @RequestPart("metadata") String metadataJson,
@RequestPart("file") MultipartFile file, @RequestPart("file") MultipartFile file,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class); var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
@@ -79,7 +75,7 @@ public class AppController {
@GetMapping @GetMapping
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) { public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
var apps = appService.listByEnvironmentId(environmentId) var apps = appService.listByEnvironmentId(environmentId)
.stream() .stream()
.map(this::toResponse) .map(this::toResponse)
@@ -91,7 +87,7 @@ public class AppController {
public ResponseEntity<AppResponse> getById( public ResponseEntity<AppResponse> getById(
@PathVariable UUID environmentId, @PathVariable UUID environmentId,
@PathVariable UUID appId) { @PathVariable UUID appId) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
return appService.getById(appId) return appService.getById(appId)
.map(entity -> ResponseEntity.ok(toResponse(entity))) .map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
@@ -104,7 +100,7 @@ public class AppController {
@PathVariable UUID appId, @PathVariable UUID appId,
@RequestPart("file") MultipartFile file, @RequestPart("file") MultipartFile file,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
var entity = appService.reuploadJar(appId, file, actorId); var entity = appService.reuploadJar(appId, file, actorId);
@@ -120,7 +116,7 @@ public class AppController {
@PathVariable UUID environmentId, @PathVariable UUID environmentId,
@PathVariable UUID appId, @PathVariable UUID appId,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
appService.delete(appId, actorId); appService.delete(appId, actorId);
@@ -137,7 +133,7 @@ public class AppController {
@PathVariable UUID appId, @PathVariable UUID appId,
@RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request, @RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
var actorId = resolveActorId(authentication); var actorId = resolveActorId(authentication);
var app = appService.updateRouting(appId, request.exposedPort(), actorId); var app = appService.updateRouting(appId, request.exposedPort(), actorId);

View File

@@ -19,7 +19,6 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; 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 org.springframework.security.web.SecurityFilterChain;
import java.net.URL; import java.net.URL;
@@ -31,12 +30,6 @@ import java.util.List;
@EnableMethodSecurity @EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
private final TenantResolutionFilter tenantResolutionFilter;
public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) {
this.tenantResolutionFilter = tenantResolutionFilter;
}
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@@ -51,8 +44,7 @@ public class SecurityConfig {
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
return http.build(); return http.build();
} }

View File

@@ -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<String, String> pathVars = (Map<String, String>) 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();
}
}

View File

@@ -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");
}
});
});
}
}

View File

@@ -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();
}
}
}

View File

@@ -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/**");
}
}

View File

@@ -1,6 +1,5 @@
package net.siegeln.cameleer.saas.deployment; package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.config.TenantOwnershipValidator;
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse; import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -20,12 +19,9 @@ import java.util.UUID;
public class DeploymentController { public class DeploymentController {
private final DeploymentService deploymentService; private final DeploymentService deploymentService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public DeploymentController(DeploymentService deploymentService, public DeploymentController(DeploymentService deploymentService) {
TenantOwnershipValidator tenantOwnershipValidator) {
this.deploymentService = deploymentService; this.deploymentService = deploymentService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
} }
@PostMapping("/deploy") @PostMapping("/deploy")
@@ -33,7 +29,7 @@ public class DeploymentController {
public ResponseEntity<DeploymentResponse> deploy( public ResponseEntity<DeploymentResponse> deploy(
@PathVariable UUID appId, @PathVariable UUID appId,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
var entity = deploymentService.deploy(appId, actorId); var entity = deploymentService.deploy(appId, actorId);
@@ -47,7 +43,7 @@ public class DeploymentController {
@GetMapping("/deployments") @GetMapping("/deployments")
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) { public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
tenantOwnershipValidator.validateAppAccess(appId);
var deployments = deploymentService.listByAppId(appId) var deployments = deploymentService.listByAppId(appId)
.stream() .stream()
.map(this::toResponse) .map(this::toResponse)
@@ -59,7 +55,7 @@ public class DeploymentController {
public ResponseEntity<DeploymentResponse> getDeployment( public ResponseEntity<DeploymentResponse> getDeployment(
@PathVariable UUID appId, @PathVariable UUID appId,
@PathVariable UUID deploymentId) { @PathVariable UUID deploymentId) {
tenantOwnershipValidator.validateAppAccess(appId);
return deploymentService.getById(deploymentId) return deploymentService.getById(deploymentId)
.map(entity -> ResponseEntity.ok(toResponse(entity))) .map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
@@ -70,7 +66,7 @@ public class DeploymentController {
public ResponseEntity<DeploymentResponse> stop( public ResponseEntity<DeploymentResponse> stop(
@PathVariable UUID appId, @PathVariable UUID appId,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
var entity = deploymentService.stop(appId, actorId); var entity = deploymentService.stop(appId, actorId);
@@ -87,7 +83,7 @@ public class DeploymentController {
public ResponseEntity<DeploymentResponse> restart( public ResponseEntity<DeploymentResponse> restart(
@PathVariable UUID appId, @PathVariable UUID appId,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
var entity = deploymentService.restart(appId, actorId); var entity = deploymentService.restart(appId, actorId);

View File

@@ -1,7 +1,6 @@
package net.siegeln.cameleer.saas.environment; package net.siegeln.cameleer.saas.environment;
import jakarta.validation.Valid; 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.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse; import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest; import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
@@ -26,12 +25,9 @@ import java.util.UUID;
public class EnvironmentController { public class EnvironmentController {
private final EnvironmentService environmentService; private final EnvironmentService environmentService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public EnvironmentController(EnvironmentService environmentService, public EnvironmentController(EnvironmentService environmentService) {
TenantOwnershipValidator tenantOwnershipValidator) {
this.environmentService = environmentService; this.environmentService = environmentService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
} }
@PostMapping @PostMapping
@@ -64,7 +60,7 @@ public class EnvironmentController {
public ResponseEntity<EnvironmentResponse> getById( public ResponseEntity<EnvironmentResponse> getById(
@PathVariable UUID tenantId, @PathVariable UUID tenantId,
@PathVariable UUID environmentId) { @PathVariable UUID environmentId) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
return environmentService.getById(environmentId) return environmentService.getById(environmentId)
.map(entity -> ResponseEntity.ok(toResponse(entity))) .map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
@@ -77,7 +73,7 @@ public class EnvironmentController {
@PathVariable UUID environmentId, @PathVariable UUID environmentId,
@Valid @RequestBody UpdateEnvironmentRequest request, @Valid @RequestBody UpdateEnvironmentRequest request,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId); var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
@@ -93,7 +89,7 @@ public class EnvironmentController {
@PathVariable UUID tenantId, @PathVariable UUID tenantId,
@PathVariable UUID environmentId, @PathVariable UUID environmentId,
Authentication authentication) { Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try { try {
UUID actorId = resolveActorId(authentication); UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId); environmentService.delete(environmentId, actorId);

View File

@@ -1,6 +1,5 @@
package net.siegeln.cameleer.saas.log; package net.siegeln.cameleer.saas.log;
import net.siegeln.cameleer.saas.config.TenantOwnershipValidator;
import net.siegeln.cameleer.saas.log.dto.LogEntry; import net.siegeln.cameleer.saas.log.dto.LogEntry;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -20,12 +19,9 @@ import java.util.UUID;
public class LogController { public class LogController {
private final ContainerLogService containerLogService; private final ContainerLogService containerLogService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public LogController(ContainerLogService containerLogService, public LogController(ContainerLogService containerLogService) {
TenantOwnershipValidator tenantOwnershipValidator) {
this.containerLogService = containerLogService; this.containerLogService = containerLogService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
} }
@GetMapping @GetMapping
@@ -36,7 +32,6 @@ public class LogController {
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
@RequestParam(defaultValue = "500") int limit, @RequestParam(defaultValue = "500") int limit,
@RequestParam(defaultValue = "both") String stream) { @RequestParam(defaultValue = "both") String stream) {
tenantOwnershipValidator.validateAppAccess(appId);
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream); List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
return ResponseEntity.ok(entries); return ResponseEntity.ok(entries);
} }

View File

@@ -1,6 +1,5 @@
package net.siegeln.cameleer.saas.observability; 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.AgentStatusResponse;
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse; import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -14,18 +13,15 @@ import java.util.UUID;
public class AgentStatusController { public class AgentStatusController {
private final AgentStatusService agentStatusService; private final AgentStatusService agentStatusService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public AgentStatusController(AgentStatusService agentStatusService, public AgentStatusController(AgentStatusService agentStatusService) {
TenantOwnershipValidator tenantOwnershipValidator) {
this.agentStatusService = agentStatusService; this.agentStatusService = agentStatusService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
} }
@GetMapping("/agent-status") @GetMapping("/agent-status")
@PreAuthorize("hasAuthority('SCOPE_observe:read')") @PreAuthorize("hasAuthority('SCOPE_observe:read')")
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) { public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
tenantOwnershipValidator.validateAppAccess(appId);
try { try {
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId)); return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -36,7 +32,7 @@ public class AgentStatusController {
@GetMapping("/observability-status") @GetMapping("/observability-status")
@PreAuthorize("hasAuthority('SCOPE_observe:read')") @PreAuthorize("hasAuthority('SCOPE_observe:read')")
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) { public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
tenantOwnershipValidator.validateAppAccess(appId);
try { try {
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId)); return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {

View File

@@ -38,9 +38,8 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (!me) return; if (!me) return;
// Read scopes from access tokens: // Read scopes from a single access token (org-scoped when an org is selected,
// - org-scoped resource token → tenant-level scopes (apps:manage, observe:read, etc.) // global otherwise). Logto merges all applicable scopes into either token.
// - global resource token → platform-level scopes (platform:admin)
fetchConfig().then(async (config) => { fetchConfig().then(async (config) => {
if (!config.logtoResource) return; if (!config.logtoResource) return;
@@ -55,18 +54,12 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
}; };
try { try {
const [orgToken, globalToken] = await Promise.all([ const token = await (currentOrgId
currentOrgId ? getAccessToken(config.logtoResource, currentOrgId)
? getAccessToken(config.logtoResource, currentOrgId).catch(() => undefined) : getAccessToken(config.logtoResource)
: Promise.resolve(undefined), ).catch(() => undefined);
getAccessToken(config.logtoResource).catch(() => undefined),
]);
const merged = new Set([ setScopes(new Set(extractScopes(token)));
...extractScopes(orgToken),
...extractScopes(globalToken),
]);
setScopes(merged);
} catch { } catch {
setScopes(new Set()); setScopes(new Set());
} }