Skip to content

Commit 49b08eb

Browse files
Merge pull request #7 from UsmanovMahmudkhan/special/upgrading-services
Special/upgrading services
2 parents 52bab18 + 45fc21e commit 49b08eb

6 files changed

Lines changed: 332 additions & 5 deletions

File tree

src/main/java/com/sovereigncomm/security/ProductionSecurityHealthIndicator.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import org.springframework.beans.factory.annotation.Value;
44
import org.springframework.boot.actuate.health.Health;
55
import org.springframework.boot.actuate.health.HealthIndicator;
6+
import org.springframework.boot.actuate.health.Status;
67
import org.springframework.core.env.Environment;
78
import org.springframework.stereotype.Component;
89

@@ -43,19 +44,19 @@ public Health health() {
4344
}
4445
Health.Builder builder = Health.up().withDetail("mode", "prod");
4546
if (!verifierClient.remoteEnabled()) {
46-
builder = Health.down().withDetail("verifier", "missing app.security.verifier.base-url");
47+
builder.status(Status.DOWN).withDetail("verifier", "missing app.security.verifier.base-url");
4748
}
4849
if (tokenPepper.length() < 32) {
49-
builder = Health.down().withDetail("tokenPepper", "must be at least 32 characters in prod");
50+
builder.status(Status.DOWN).withDetail("tokenPepper", "must be at least 32 characters in prod");
5051
}
5152
if (bootstrapToken.length() < 32) {
52-
builder = Health.down().withDetail("bootstrapToken", "must be at least 32 characters in prod");
53+
builder.status(Status.DOWN).withDetail("bootstrapToken", "must be at least 32 characters in prod");
5354
}
5455
if (!requireVerifiedDevicesForSessions) {
55-
builder = Health.down().withDetail("deviceSessionTrust", "verified devices must be required in prod");
56+
builder.status(Status.DOWN).withDetail("deviceSessionTrust", "verified devices must be required in prod");
5657
}
5758
if (smalltalkGovernanceEnabled) {
58-
builder = Health.down().withDetail("smalltalkGovernance", "experimental Smalltalk evaluation must stay disabled in prod");
59+
builder.status(Status.DOWN).withDetail("smalltalkGovernance", "experimental Smalltalk evaluation must stay disabled in prod");
5960
}
6061
return builder.build();
6162
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.sovereigncomm.api;
2+
3+
import com.sovereigncomm.api.dto.CommonDtos.ErrorResponse;
4+
import com.sovereigncomm.service.AuditService;
5+
import org.junit.jupiter.api.Test;
6+
import org.mockito.Mockito;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.mock.web.MockHttpServletRequest;
9+
10+
import java.util.Map;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.mockito.ArgumentMatchers.eq;
14+
15+
class ApiExceptionHandlerTest {
16+
private final AuditService auditService = Mockito.mock(AuditService.class);
17+
private final ApiExceptionHandler handler = new ApiExceptionHandler(auditService);
18+
19+
@Test
20+
void apiErrorsUseNormalizedEnvelopeAndAuditRejection() {
21+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/messages/direct");
22+
request.setAttribute("requestId", "req-error-1");
23+
24+
ResponseEntity<ErrorResponse> response = handler.badRequest(new IllegalArgumentException("invalid ciphertext"), request);
25+
26+
assertThat(response.getStatusCode().value()).isEqualTo(400);
27+
assertThat(response.getBody())
28+
.extracting(ErrorResponse::status, ErrorResponse::error, ErrorResponse::message,
29+
ErrorResponse::path, ErrorResponse::requestId)
30+
.containsExactly(400, "Bad Request", "invalid ciphertext", "/api/v1/messages/direct", "req-error-1");
31+
Mockito.verify(auditService).appendSecurityEvent(eq("API_REQUEST_REJECTED"), Mockito.<Map<String, Object>>argThat(metadata ->
32+
metadata.get("status").equals(400)
33+
&& metadata.get("path").equals("/api/v1/messages/direct")
34+
&& metadata.get("requestId").equals("req-error-1")
35+
&& metadata.get("reason").equals("invalid ciphertext")));
36+
}
37+
38+
@Test
39+
void nonApiErrorsSkipRejectionAudit() {
40+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/actuator/health");
41+
42+
ResponseEntity<ErrorResponse> response = handler.internal(new RuntimeException("boom"), request);
43+
44+
assertThat(response.getStatusCode().value()).isEqualTo(500);
45+
assertThat(response.getBody().message()).isEqualTo("Unexpected server error");
46+
Mockito.verifyNoInteractions(auditService);
47+
}
48+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.sovereigncomm.config;
2+
3+
import jakarta.servlet.ServletException;
4+
import org.junit.jupiter.api.AfterEach;
5+
import org.junit.jupiter.api.Test;
6+
import org.slf4j.MDC;
7+
import org.springframework.mock.web.MockHttpServletRequest;
8+
import org.springframework.mock.web.MockHttpServletResponse;
9+
10+
import java.io.IOException;
11+
import java.util.UUID;
12+
import java.util.concurrent.atomic.AtomicReference;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
class RequestIdFilterTest {
17+
private final RequestIdFilter filter = new RequestIdFilter();
18+
19+
@AfterEach
20+
void clearMdc() {
21+
MDC.clear();
22+
}
23+
24+
@Test
25+
void reusesProvidedRequestIdAcrossRequestResponseAndMdc() throws ServletException, IOException {
26+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/messages/inbox");
27+
MockHttpServletResponse response = new MockHttpServletResponse();
28+
AtomicReference<String> chainRequestId = new AtomicReference<>();
29+
request.addHeader("X-Request-Id", "req-client-1");
30+
31+
filter.doFilter(request, response, (servletRequest, servletResponse) -> chainRequestId.set(MDC.get("requestId")));
32+
33+
assertThat(request.getAttribute("requestId")).isEqualTo("req-client-1");
34+
assertThat(response.getHeader("X-Request-Id")).isEqualTo("req-client-1");
35+
assertThat(chainRequestId.get()).isEqualTo("req-client-1");
36+
assertThat(MDC.get("requestId")).isNull();
37+
}
38+
39+
@Test
40+
void createsRequestIdWhenHeaderIsBlank() throws ServletException, IOException {
41+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/messages/direct");
42+
MockHttpServletResponse response = new MockHttpServletResponse();
43+
request.addHeader("X-Request-Id", " ");
44+
45+
filter.doFilter(request, response, (servletRequest, servletResponse) -> {
46+
});
47+
48+
String requestId = (String) request.getAttribute("requestId");
49+
assertThat(UUID.fromString(requestId)).isNotNull();
50+
assertThat(response.getHeader("X-Request-Id")).isEqualTo(requestId);
51+
}
52+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.sovereigncomm.config;
2+
3+
import com.sovereigncomm.security.ApiAuthenticationFilter;
4+
import com.sovereigncomm.security.RateLimitingFilter;
5+
import com.sovereigncomm.security.TokenService;
6+
import org.junit.jupiter.api.Test;
7+
import org.mockito.Mockito;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
import org.springframework.context.annotation.Import;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.jdbc.core.JdbcTemplate;
15+
import org.springframework.test.web.servlet.MockMvc;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RestController;
19+
20+
import java.util.Map;
21+
22+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
23+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
24+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
25+
26+
@WebMvcTest(controllers = SecurityConfigTest.ProbeController.class)
27+
@Import({SecurityConfig.class, SecurityConfigTest.TestFilters.class, SecurityConfigTest.ProbeController.class})
28+
class SecurityConfigTest {
29+
@Autowired
30+
MockMvc mockMvc;
31+
32+
@Test
33+
void healthEndpointIsPublic() throws Exception {
34+
mockMvc.perform(get("/actuator/health"))
35+
.andExpect(status().isOk());
36+
}
37+
38+
@Test
39+
void messageEndpointRequiresAuthentication() throws Exception {
40+
mockMvc.perform(post("/api/v1/messages/direct").contentType(MediaType.APPLICATION_JSON))
41+
.andExpect(status().isForbidden());
42+
}
43+
44+
@Test
45+
void bootstrapRoleCanAccessOrganizationCreation() throws Exception {
46+
mockMvc.perform(post("/api/v1/organizations")
47+
.header("X-Bootstrap-Token", "bootstrap-secret")
48+
.contentType(MediaType.APPLICATION_JSON))
49+
.andExpect(status().isOk());
50+
}
51+
52+
@Configuration
53+
static class TestFilters {
54+
@Bean
55+
ApiAuthenticationFilter apiAuthenticationFilter() {
56+
return new ApiAuthenticationFilter(Mockito.mock(JdbcTemplate.class), new TokenService("unit-pepper"),
57+
"bootstrap-secret", false, 15);
58+
}
59+
60+
@Bean
61+
RateLimitingFilter rateLimitingFilter() {
62+
return new RateLimitingFilter(false, 1);
63+
}
64+
}
65+
66+
@RestController
67+
public static class ProbeController {
68+
@GetMapping("/actuator/health")
69+
Map<String, String> health() {
70+
return Map.of("status", "UP");
71+
}
72+
73+
@PostMapping("/api/v1/messages/direct")
74+
Map<String, String> message() {
75+
return Map.of("status", "accepted");
76+
}
77+
78+
@PostMapping("/api/v1/organizations")
79+
Map<String, String> organization() {
80+
return Map.of("status", "created");
81+
}
82+
}
83+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.sovereigncomm.security;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.mockito.Mockito;
5+
import org.springframework.boot.actuate.health.Health;
6+
import org.springframework.boot.actuate.health.Status;
7+
import org.springframework.mock.env.MockEnvironment;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
class ProductionSecurityHealthIndicatorTest {
12+
@Test
13+
void reportsNonProdAsUpWithVerifierMode() {
14+
SecurityVerifierClient verifierClient = Mockito.mock(SecurityVerifierClient.class);
15+
Mockito.when(verifierClient.remoteEnabled()).thenReturn(false);
16+
17+
Health health = indicator(new MockEnvironment(), verifierClient, "", "", false, false).health();
18+
19+
assertThat(health.getStatus()).isEqualTo(Status.UP);
20+
assertThat(health.getDetails()).containsEntry("mode", "non-prod");
21+
assertThat(health.getDetails()).containsEntry("verifier", "local-fallback");
22+
}
23+
24+
@Test
25+
void rejectsUnsafeProductionSettings() {
26+
SecurityVerifierClient verifierClient = Mockito.mock(SecurityVerifierClient.class);
27+
MockEnvironment environment = new MockEnvironment();
28+
environment.setActiveProfiles("prod");
29+
30+
Health health = indicator(environment, verifierClient, "short", "short", false, true).health();
31+
32+
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
33+
assertThat(health.getDetails())
34+
.containsEntry("verifier", "missing app.security.verifier.base-url")
35+
.containsEntry("tokenPepper", "must be at least 32 characters in prod")
36+
.containsEntry("bootstrapToken", "must be at least 32 characters in prod")
37+
.containsEntry("deviceSessionTrust", "verified devices must be required in prod")
38+
.containsEntry("smalltalkGovernance", "experimental Smalltalk evaluation must stay disabled in prod");
39+
}
40+
41+
@Test
42+
void acceptsHardenedProductionSettings() {
43+
SecurityVerifierClient verifierClient = Mockito.mock(SecurityVerifierClient.class);
44+
Mockito.when(verifierClient.remoteEnabled()).thenReturn(true);
45+
MockEnvironment environment = new MockEnvironment();
46+
environment.setActiveProfiles("prod");
47+
48+
Health health = indicator(environment, verifierClient, "x".repeat(32), "y".repeat(32), true, false).health();
49+
50+
assertThat(health.getStatus()).isEqualTo(Status.UP);
51+
assertThat(health.getDetails()).containsEntry("mode", "prod");
52+
}
53+
54+
private ProductionSecurityHealthIndicator indicator(MockEnvironment environment, SecurityVerifierClient verifierClient,
55+
String tokenPepper, String bootstrapToken,
56+
boolean requireVerifiedDevicesForSessions,
57+
boolean smalltalkGovernanceEnabled) {
58+
return new ProductionSecurityHealthIndicator(environment, verifierClient, tokenPepper, bootstrapToken,
59+
requireVerifiedDevicesForSessions, smalltalkGovernanceEnabled);
60+
}
61+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.sovereigncomm.security;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.http.MediaType;
5+
import org.springframework.test.web.client.MockRestServiceServer;
6+
import org.springframework.web.client.RestClient;
7+
8+
import java.util.Base64;
9+
import java.util.UUID;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
13+
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
14+
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
15+
import static org.springframework.http.HttpMethod.GET;
16+
17+
class SecurityVerifierClientTest {
18+
@Test
19+
void localKeyTransparencyFallbackIsDeterministicAndChainsPreviousHead() {
20+
SecurityVerifierClient client = new SecurityVerifierClient(new TokenService("unit-pepper"), RestClient.builder(), "");
21+
String canonical = Base64.getEncoder().encodeToString(new TokenService("unit-pepper").sha256("entry"));
22+
UUID orgId = UUID.randomUUID();
23+
UUID userId = UUID.randomUUID();
24+
UUID deviceId = UUID.randomUUID();
25+
SecurityVerifierClient.KeyTransparencyAppendRequest request = new SecurityVerifierClient.KeyTransparencyAppendRequest(
26+
orgId, userId, deviceId, canonical, "", 0);
27+
28+
SecurityVerifierClient.KeyTransparencyAppendResponse first = client.appendKeyTransparencyEntry(request);
29+
SecurityVerifierClient.KeyTransparencyAppendResponse second = client.appendKeyTransparencyEntry(request);
30+
SecurityVerifierClient.KeyTransparencyAppendResponse chained = client.appendKeyTransparencyEntry(
31+
new SecurityVerifierClient.KeyTransparencyAppendRequest(orgId, userId, deviceId, canonical, first.signedTreeHeadBase64(), 1));
32+
33+
assertThat(first.signedTreeHeadBase64()).isEqualTo(second.signedTreeHeadBase64());
34+
assertThat(chained.signedTreeHeadBase64()).isNotEqualTo(first.signedTreeHeadBase64());
35+
assertThat(first.inclusionProof()).containsEntry("mode", "local_hash_chain_fallback");
36+
assertThat(first.proofVersion()).isEqualTo("verifier-proof-v1");
37+
}
38+
39+
@Test
40+
void localDeviceAttestationFallbackRejectsBlankAttestation() {
41+
SecurityVerifierClient client = new SecurityVerifierClient(new TokenService("unit-pepper"), RestClient.builder(), "");
42+
43+
SecurityVerifierClient.DeviceAttestationVerificationResponse response = client.verifyDeviceAttestation(
44+
new SecurityVerifierClient.DeviceAttestationVerificationRequest(
45+
UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "android-key", " "));
46+
47+
assertThat(response.valid()).isFalse();
48+
assertThat(response.verificationStatus()).isEqualTo("REJECTED");
49+
assertThat(response.verifiedClaims()).containsEntry("mode", "local-fallback");
50+
}
51+
52+
@Test
53+
void remoteHealthUsesVerifierEndpoint() {
54+
RestClient.Builder builder = RestClient.builder();
55+
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
56+
SecurityVerifierClient client = new SecurityVerifierClient(new TokenService("unit-pepper"), builder, "https://verifier.example");
57+
server.expect(requestTo("https://verifier.example/healthz"))
58+
.andExpect(method(GET))
59+
.andRespond(withSuccess("{\"status\":\"UP\",\"detail\":\"remote verifier\"}", MediaType.APPLICATION_JSON));
60+
61+
SecurityVerifierClient.VerifierHealth health = client.health();
62+
63+
assertThat(health.status()).isEqualTo("UP");
64+
assertThat(health.detail()).isEqualTo("remote verifier");
65+
server.verify();
66+
}
67+
68+
@Test
69+
void localSignedAdminActionFallbackRequiresSignatureField() {
70+
SecurityVerifierClient client = new SecurityVerifierClient(new TokenService("unit-pepper"), RestClient.builder(), "");
71+
72+
SecurityVerifierClient.SignedAdminActionVerificationResponse rejected = client.verifySignedAdminAction(
73+
new SecurityVerifierClient.SignedAdminActionVerificationRequest(UUID.randomUUID(), "{\"actionType\":\"LOCKDOWN\"}"));
74+
SecurityVerifierClient.SignedAdminActionVerificationResponse accepted = client.verifySignedAdminAction(
75+
new SecurityVerifierClient.SignedAdminActionVerificationRequest(UUID.randomUUID(), "{\"signatureBase64\":\"abc\"}"));
76+
77+
assertThat(rejected.valid()).isFalse();
78+
assertThat(rejected.reason()).isEqualTo("SIGNATURE_REQUIRED");
79+
assertThat(accepted.valid()).isTrue();
80+
assertThat(accepted.reason()).isEqualTo("LOCAL_SIGNATURE_FIELD_PRESENT");
81+
}
82+
}

0 commit comments

Comments
 (0)