Add RBAC with role-based endpoint authorization and OIDC support
Some checks failed
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 1m38s
CI / deploy (push) Has been cancelled

Implement three-phase security upgrade:

Phase 1 - RBAC: Extend JWT with roles claim, populate Spring
GrantedAuthority in filter, enforce role-based access (AGENT for
data/heartbeat/SSE, VIEWER+ for search/diagrams, OPERATOR+ for
commands, ADMIN for user management). Configurable JWT secret via
CAMELEER_JWT_SECRET env var for token persistence across restarts.

Phase 2 - User persistence: ClickHouse users table with
ReplacingMergeTree, UserRepository interface + ClickHouse impl,
UserAdminController for CRUD at /api/v1/admin/users. Local login
upserts user on each authentication.

Phase 3 - OIDC: Token exchange flow where SPA sends auth code,
server exchanges it server-side (keeping client_secret secure),
validates id_token via JWKS, resolves roles (DB override > OIDC
claim > default), issues internal JWT. Conditional on
CAMELEER_OIDC_ENABLED=true. Uses oauth2-oidc-sdk for standards
compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 12:35:45 +01:00
parent 484c5887c3
commit a4de2a7b79
21 changed files with 839 additions and 123 deletions

View File

@@ -56,7 +56,7 @@ public abstract class AbstractClickHouseIT {
}
// Load all schema files in order
String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql"};
String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql", "03-users.sql"};
try (Connection conn = DriverManager.getConnection(
CLICKHOUSE.getJdbcUrl(),

View File

@@ -5,6 +5,8 @@ import com.cameleer3.server.core.security.JwtService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -50,8 +52,8 @@ class JwtServiceTest {
@Test
void createRefreshToken_canBeValidatedWithRefreshMethod() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
String agentId = jwtService.validateRefreshToken(token);
assertEquals("agent-2", agentId);
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-2", result.subject());
}
@Test
@@ -70,6 +72,50 @@ class JwtServiceTest {
"Refresh validation should reject access tokens");
}
@Test
void accessToken_rolesRoundTrip() {
List<String> roles = List.of("ADMIN", "OPERATOR");
String token = jwtService.createAccessToken("ui:admin", "ui", roles);
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals("ui:admin", result.subject());
assertEquals("ui", result.group());
assertEquals(roles, result.roles());
}
@Test
void refreshToken_rolesRoundTrip() {
List<String> roles = List.of("AGENT");
String token = jwtService.createRefreshToken("agent-1", "default", roles);
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-1", result.subject());
assertEquals("default", result.group());
assertEquals(roles, result.roles());
}
@Test
void legacyToken_emptyRoles() {
// Backward compat: tokens without explicit roles get empty list
String token = jwtService.createAccessToken("agent-1", "default");
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals(List.of(), result.roles());
}
@Test
void configuredJwtSecret_producesStableTokens() {
SecurityProperties props = new SecurityProperties();
props.setAccessTokenExpiryMs(3_600_000);
props.setRefreshTokenExpiryMs(604_800_000);
props.setJwtSecret("my-test-secret-that-is-at-least-32-bytes");
JwtService svc1 = new JwtServiceImpl(props);
JwtService svc2 = new JwtServiceImpl(props);
String token = svc1.createAccessToken("agent-1", "default", List.of("AGENT"));
// Token created by svc1 should be validatable by svc2 (same secret)
JwtService.JwtValidationResult result = svc2.validateAccessToken(token);
assertEquals("agent-1", result.subject());
assertEquals(List.of("AGENT"), result.roles());
}
@Test
void validateAndExtractAgentId_rejectsExpiredToken() {
// Create a service with 0ms expiry to produce already-expired tokens