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

@@ -24,6 +24,8 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -99,7 +101,8 @@ class AppControllerTest {
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("order-svc"))
.andExpect(jsonPath("$.displayName").value("Order Service"));
@@ -117,7 +120,8 @@ class AppControllerTest {
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(txt)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isBadRequest());
}
@@ -133,7 +137,8 @@ class AppControllerTest {
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
@@ -154,7 +159,8 @@ class AppControllerTest {
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated())
.andReturn();
@@ -162,7 +168,8 @@ class AppControllerTest {
.get("id").asText();
mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isNoContent());
}
}

View File

@@ -25,6 +25,8 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -83,7 +85,9 @@ class EnvironmentControllerTest {
var request = new CreateEnvironmentRequest("prod", "Production");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
@@ -97,13 +101,17 @@ class EnvironmentControllerTest {
var request = new CreateEnvironmentRequest("staging", "Staging");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
@@ -114,13 +122,16 @@ class EnvironmentControllerTest {
var request = new CreateEnvironmentRequest("dev", "Development");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("dev"));
}
@@ -130,7 +141,9 @@ class EnvironmentControllerTest {
var createRequest = new CreateEnvironmentRequest("qa", "QA");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createRequest)))
.andExpect(status().isCreated())
@@ -142,7 +155,9 @@ class EnvironmentControllerTest {
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateRequest)))
.andExpect(status().isOk())
@@ -154,7 +169,9 @@ class EnvironmentControllerTest {
var request = new CreateEnvironmentRequest("default", "Default");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
@@ -164,7 +181,9 @@ class EnvironmentControllerTest {
.get("id").asText();
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isForbidden());
}

View File

@@ -55,7 +55,9 @@ class LicenseControllerTest {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_billing:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.tier").value("MID"))
@@ -67,11 +69,14 @@ class LicenseControllerTest {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_billing:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tier").value("MID"));
}
@@ -81,7 +86,8 @@ class LicenseControllerTest {
String tenantId = createTenantAndGetId();
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isNotFound());
}
}

View File

@@ -103,7 +103,8 @@ class TenantControllerTest {
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(get("/api/tenants/" + id)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value(slug));
}