test(e2e): Phase 2c — JWT scope mapping + найден scope-filter gap на writer

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.
This commit is contained in:
Zimin A.N.
2026-05-05 20:00:08 +03:00
parent 98ee8a24bc
commit 4747d85d15
3 changed files with 291 additions and 0 deletions
+6
View File
@@ -96,6 +96,12 @@
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
@@ -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.
*
* <ul>
* <li>{@code ORDINIS_AUTH_REQUIRED=true} — anonymous → 401</li>
* <li>JWT с {@code realm_access.roles=[ordinis:client:public]} → доступ к PUBLIC</li>
* <li>JWT с {@code ordinis:client:internal} → доступ к INTERNAL</li>
* <li>Public-юзер пытается читать INTERNAL — пустой результат (scope filter), не 403</li>
* </ul>
*
* <p>Тестовый 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());
}
}
@@ -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'ами.
*
* <p>Используется в e2e auth тестах. Spring Security настраивается на
* {@code ordinis.auth.jwk-set-uri=<server.url>} и доверяет наши 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<String> realmRoles, String subject) {
return signJwt(realmRoles, subject, Duration.ofMinutes(30));
}
public static String signJwt(List<String> 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);
}
}
}