feat: auth hardening — scope enforcement, tenant isolation, and docs
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 39s

Add @PreAuthorize annotations to all API controllers (14 endpoints
across 6 controllers) enforcing OAuth2 scopes: apps:manage, apps:deploy,
billing:manage, observe:read, platform:admin.

Enforce tenant isolation: TenantResolutionFilter now rejects cross-tenant
access on /api/tenants/{id}/* paths. New TenantOwnershipValidator checks
environment/app ownership for paths without tenantId. Platform admins
bypass both layers.

Fix frontend: OrgResolver split into two useEffect hooks so scopes
refresh on org switch. Scopes now served from /api/config (single source
of truth). Bootstrap cleaned — standalone org permissions removed.

Update docs/architecture.md, docs/user-manual.md, and CLAUDE.md to
reflect all auth hardening changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 15:32:53 +02:00
parent b459a69083
commit 051f7fdae9
21 changed files with 408 additions and 136 deletions

View File

@@ -3,11 +3,13 @@ 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;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -34,24 +36,29 @@ 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) {
TenantRepository tenantRepository,
TenantOwnershipValidator tenantOwnershipValidator) {
this.appService = appService;
this.objectMapper = objectMapper;
this.environmentService = environmentService;
this.runtimeConfig = runtimeConfig;
this.tenantRepository = tenantRepository;
this.tenantOwnershipValidator = tenantOwnershipValidator;
}
@PostMapping(consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<AppResponse> create(
@PathVariable UUID environmentId,
@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);
@@ -72,6 +79,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)
@@ -83,17 +91,20 @@ 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());
}
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<AppResponse> reuploadJar(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try {
UUID actorId = resolveActorId(authentication);
var entity = appService.reuploadJar(appId, file, actorId);
@@ -104,10 +115,12 @@ public class AppController {
}
@DeleteMapping("/{appId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<Void> delete(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try {
UUID actorId = resolveActorId(authentication);
appService.delete(appId, actorId);
@@ -118,11 +131,13 @@ public class AppController {
}
@PatchMapping("/{appId}/routing")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<AppResponse> updateRouting(
@PathVariable UUID environmentId,
@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

@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.util.List;
import java.util.Map;
@RestController
@@ -25,8 +26,21 @@ public class PublicConfigController {
private final ObjectMapper objectMapper = new ObjectMapper();
private static final List<String> SCOPES = List.of(
"platform:admin",
"tenant:manage",
"billing:manage",
"team:manage",
"apps:manage",
"apps:deploy",
"secrets:manage",
"observe:read",
"observe:debug",
"settings:manage"
);
@GetMapping("/api/config")
public Map<String, String> config() {
public Map<String, Object> config() {
JsonNode bootstrap = readBootstrapFile();
String clientId = spaClientId;
@@ -47,7 +61,8 @@ public class PublicConfigController {
return Map.of(
"logtoEndpoint", endpoint,
"logtoClientId", clientId != null ? clientId : "",
"logtoResource", apiResource
"logtoResource", apiResource,
"scopes", SCOPES
);
}

View File

@@ -0,0 +1,42 @@
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

@@ -12,6 +12,7 @@ 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 {
@@ -37,6 +38,30 @@ public class TenantResolutionFilter extends OncePerRequestFilter {
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);

View File

@@ -1,8 +1,10 @@
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;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -18,15 +20,20 @@ import java.util.UUID;
public class DeploymentController {
private final DeploymentService deploymentService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public DeploymentController(DeploymentService deploymentService) {
public DeploymentController(DeploymentService deploymentService,
TenantOwnershipValidator tenantOwnershipValidator) {
this.deploymentService = deploymentService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
}
@PostMapping("/deploy")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<DeploymentResponse> deploy(
@PathVariable UUID appId,
Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.deploy(appId, actorId);
@@ -40,6 +47,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)
@@ -51,15 +59,18 @@ 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());
}
@PostMapping("/stop")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<DeploymentResponse> stop(
@PathVariable UUID appId,
Authentication authentication) {
tenantOwnershipValidator.validateAppAccess(appId);
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.stop(appId, actorId);
@@ -72,9 +83,11 @@ public class DeploymentController {
}
@PostMapping("/restart")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
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,11 +1,13 @@
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;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -24,12 +26,16 @@ import java.util.UUID;
public class EnvironmentController {
private final EnvironmentService environmentService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public EnvironmentController(EnvironmentService environmentService) {
public EnvironmentController(EnvironmentService environmentService,
TenantOwnershipValidator tenantOwnershipValidator) {
this.environmentService = environmentService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<EnvironmentResponse> create(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateEnvironmentRequest request,
@@ -58,17 +64,20 @@ 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());
}
@PatchMapping("/{environmentId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<EnvironmentResponse> update(
@PathVariable UUID tenantId,
@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);
@@ -79,10 +88,12 @@ public class EnvironmentController {
}
@DeleteMapping("/{environmentId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<Void> delete(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
Authentication authentication) {
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
try {
UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId);

View File

@@ -4,6 +4,7 @@ import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -27,6 +28,7 @@ public class LicenseController {
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_billing:manage')")
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
Authentication authentication) {
var tenant = tenantService.getById(tenantId).orElse(null);

View File

@@ -1,8 +1,10 @@
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;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,18 +20,23 @@ import java.util.UUID;
public class LogController {
private final ContainerLogService containerLogService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public LogController(ContainerLogService containerLogService) {
public LogController(ContainerLogService containerLogService,
TenantOwnershipValidator tenantOwnershipValidator) {
this.containerLogService = containerLogService;
this.tenantOwnershipValidator = tenantOwnershipValidator;
}
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_observe:read')")
public ResponseEntity<List<LogEntry>> query(
@PathVariable UUID appId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
@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,8 +1,10 @@
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;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@@ -12,13 +14,18 @@ import java.util.UUID;
public class AgentStatusController {
private final AgentStatusService agentStatusService;
private final TenantOwnershipValidator tenantOwnershipValidator;
public AgentStatusController(AgentStatusService agentStatusService) {
public AgentStatusController(AgentStatusService agentStatusService,
TenantOwnershipValidator tenantOwnershipValidator) {
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) {
@@ -27,7 +34,9 @@ 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) {