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):
|
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
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user