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):
- 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

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) |

View File

@@ -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<AppResponse>> 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<AppResponse> 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);

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

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;
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<DeploymentResponse> 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<List<DeploymentResponse>> 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<DeploymentResponse> 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<DeploymentResponse> 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<DeploymentResponse> restart(
@PathVariable UUID appId,
Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.restart(appId, actorId);

View File

@@ -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<EnvironmentResponse> 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);

View File

@@ -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<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
return ResponseEntity.ok(entries);
}

View File

@@ -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<AgentStatusResponse> 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<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
tenantOwnershipValidator.validateAppAccess(appId);
try {
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
} catch (IllegalArgumentException e) {

View File

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