feat: auth hardening — scope enforcement, tenant isolation, and docs
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user