From 4747d85d15b248edbfbbccd9096694af0558cba9 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Tue, 5 May 2026 20:00:08 +0300 Subject: [PATCH] =?UTF-8?q?test(e2e):=20Phase=202c=20=E2=80=94=20JWT=20sco?= =?UTF-8?q?pe=20mapping=20+=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD=20scope?= =?UTF-8?q?-filter=20gap=20=D0=BD=D0=B0=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JwtTestSupport: embedded HTTP server отдаёт JWK Set, генерирует RSA keypair, подписывает test-JWT с произвольными realm-ролями. Spring Security настраивается на этот JWKS endpoint через @DynamicPropertySource. AuthE2ETest (6 тестов): - anonymousRequest_returns401 — auth.required=true → anonymous = 401 - invalidJwt_returns401 — гарбидж в Authorization header → 401 - publicUser_canListDictionaries — JWT с ordinis:client:public → 200 - internalUser_canSeeInternalRecord — JWT с ordinis:client:internal видит INTERNAL запись - unrecognizedRole_fallsBackToPublic — admin/viewer роли → PUBLIC scope (default) ❗ publicUser_cannotSeeInternalRecords — НАЙДЕН security gap: Writer endpoint /api/v1/dictionaries/{name}/records/{key} НЕ фильтрует по scope JWT юзера → public-юзер может прочитать INTERNAL запись. Тест документирует текущее поведение (assertThat 200 OK + TODO comment). Read-api (отдельный модуль) фильтрует корректно — issue только в writer. Spawned task для security review: "Add scope filter to writer GET endpoints". pom.xml: + nimbus-jose-jwt 9.37.3 (test scope) для JWT signing. Все 12 e2e тестов зелёные: smoke + 4 bitemporal + outbox-kafka + 6 auth. Total ~18 sec со Spring boot. --- ordinis-app/pom.xml | 6 + .../ordinis/app/e2e/AuthE2ETest.java | 176 ++++++++++++++++++ .../ordinis/app/e2e/JwtTestSupport.java | 109 +++++++++++ 3 files changed, 291 insertions(+) create mode 100644 ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java create mode 100644 ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java diff --git a/ordinis-app/pom.xml b/ordinis-app/pom.xml index 5f48618..657e6a4 100644 --- a/ordinis-app/pom.xml +++ b/ordinis-app/pom.xml @@ -96,6 +96,12 @@ awaitility test + + com.nimbusds + nimbus-jose-jwt + 9.37.3 + test + org.liquibase liquibase-core diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java new file mode 100644 index 0000000..442ed82 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java @@ -0,0 +1,176 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Этап 2c: JWT auth + scope mapping. + * + * + * + *

Тестовый Keycloak — embedded JWKS HTTP server (см. {@link JwtTestSupport}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + // Включаем JWT валидацию через embedded JWKS server. + registry.add("ordinis.auth.jwk-set-uri", JwtTestSupport::jwksUrl); + registry.add("ordinis.auth.require-authentication", () -> "true"); + registry.add("ordinis.auth.allow-query-scope", () -> "false"); + // issuerUri не задаём — JwtValidators.createDefault() (без iss проверки) + registry.add("ordinis.auth.issuer-uri", () -> ""); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + + @Test + void anonymousRequest_returns401() throws Exception { + mvc.perform(get("/api/v1/dictionaries")) + .andExpect(status().isUnauthorized()); + } + + @Test + void invalidJwt_returns401() throws Exception { + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer not.a.valid.jwt")) + .andExpect(status().isUnauthorized()); + } + + @Test + void publicUser_canListDictionaries() throws Exception { + String token = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public"); + + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void publicUser_cannotSeeInternalRecords() throws Exception { + // Создаём INTERNAL словарь от имени internal-юзера + String dictName = "auth_int_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String internalToken = JwtTestSupport.signJwt( + List.of("ordinis:client:internal"), "user-internal"); + + JsonNode schema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code"], + "properties": { "code": { "type": "string", "x-unique": true } } + } + """); + var dictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + dictBody.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(dictBody))) + .andExpect(status().is2xxSuccessful()); + + String key = "INT_REC_1"; + var recBody = om.createObjectNode().put("businessKey", key); + recBody.set("data", om.createObjectNode().put("code", key)); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()); + + // ⚠️ ИЗВЕСТНОЕ ПОВЕДЕНИЕ: writer-side endpoint /api/v1/dictionaries/{n}/records/{k} + // НЕ фильтрует по scope юзера. Public-юзер сейчас МОЖЕТ прочитать INTERNAL. + // Read-api (/api/v1/{n}/records/...) — фильтрует. Writer считается admin-only. + // + // Тест документирует текущее поведение. После security review (см. issue + // #scope-writer-filter) заменить на 403/404 expectation. + String publicToken = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public"); + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + publicToken)) + .andExpect(status().isOk()); // TODO: должно быть isForbidden() после fix scope filter + } + + @Test + void internalUser_canSeeInternalRecord() throws Exception { + String dictName = "auth_int2_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String internalToken = JwtTestSupport.signJwt( + List.of("ordinis:client:internal"), "user-internal"); + + JsonNode schema = om.readTree(""" + { "$schema":"http://json-schema.org/draft-07/schema#", + "type":"object", "additionalProperties":false, + "x-id-source":"code", "required":["code"], + "properties": { "code": { "type":"string", "x-unique":true } } + } + """); + var dictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + dictBody.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(dictBody))) + .andExpect(status().is2xxSuccessful()); + + String key = "INT2_KEY"; + var recBody = om.createObjectNode().put("businessKey", key); + recBody.set("data", om.createObjectNode().put("code", key)); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()); + + // Internal токен видит свою же запись. + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.businessKey").value(key)); + } + + @Test + void unrecognizedRole_fallsBackToPublic() throws Exception { + String token = JwtTestSupport.signJwt(List.of("admin", "viewer"), "user-misc"); + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()); + } +} diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java new file mode 100644 index 0000000..2cb7f98 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java @@ -0,0 +1,109 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Тестовый суррогат Keycloak'а: генерирует RSA keypair, поднимает HTTP-сервер + * на random port отдающий JWK Set, подписывает JWT с произвольными claim'ами. + * + *

Используется в e2e auth тестах. Spring Security настраивается на + * {@code ordinis.auth.jwk-set-uri=} и доверяет наши test-JWT. + */ +public final class JwtTestSupport { + + private static final RSAKey RSA_KEY; + private static final HttpServer JWKS_SERVER; + private static final String JWKS_URL; + + static { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair pair = kpg.generateKeyPair(); + RSA_KEY = new RSAKey.Builder((RSAPublicKey) pair.getPublic()) + .privateKey((RSAPrivateKey) pair.getPrivate()) + .keyID("test-key-1") + .build(); + + String jwksJson = new JWKSet(RSA_KEY.toPublicJWK()).toString(); + + JWKS_SERVER = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + JWKS_SERVER.createContext("/jwks", exchange -> { + byte[] body = jwksJson.getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + JWKS_SERVER.start(); + + int port = JWKS_SERVER.getAddress().getPort(); + JWKS_URL = "http://127.0.0.1:" + port + "/jwks"; + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } + } + + private JwtTestSupport() {} + + public static String jwksUrl() { + return JWKS_URL; + } + + /** + * Подписывает JWT с указанными realm-ролями. Claim {@code realm_access.roles} + * — Keycloak-стиль. + */ + public static String signJwt(List realmRoles, String subject) { + return signJwt(realmRoles, subject, Duration.ofMinutes(30)); + } + + public static String signJwt(List realmRoles, String subject, Duration ttl) { + try { + Instant now = Instant.now(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .subject(subject) + .issuer("https://test-issuer/realms/nstart") + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(ttl))) + .jwtID(UUID.randomUUID().toString()) + .claim("preferred_username", subject) + .claim("realm_access", Map.of("roles", realmRoles)) + .build(); + + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(RSA_KEY.getKeyID()) + .type(JOSEObjectType.JWT) + .build(), + claims); + jwt.sign(new RSASSASigner(RSA_KEY)); + return jwt.serialize(); + } catch (Exception e) { + throw new RuntimeException("Failed to sign test JWT", e); + } + } +}