Add RBAC with role-based endpoint authorization and OIDC support
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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user