refactor: merge tenant isolation into single HandlerInterceptor
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:
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user