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:
@@ -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());
|
||||
}
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user