From a4de2a7b792b7c2161e2322e0b14f7905812aff0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:35:45 +0100 Subject: [PATCH] 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) --- cameleer3-server-app/pom.xml | 5 + .../server/app/config/ClickHouseConfig.java | 4 +- .../AgentRegistrationController.java | 18 +- .../app/controller/UserAdminController.java | 73 ++++++++ .../app/security/JwtAuthenticationFilter.java | 32 +++- .../server/app/security/JwtServiceImpl.java | 92 ++++++++-- .../app/security/OidcAuthController.java | 127 +++++++++++++ .../app/security/OidcTokenExchanger.java | 168 ++++++++++++++++++ .../app/security/SecurityBeanConfig.java | 7 + .../server/app/security/SecurityConfig.java | 35 +++- .../app/security/SecurityProperties.java | 95 +++++----- .../server/app/security/UiAuthController.java | 40 +++-- .../app/storage/ClickHouseUserRepository.java | 89 ++++++++++ .../src/main/resources/application.yml | 8 + .../main/resources/clickhouse/03-users.sql | 10 ++ .../server/app/AbstractClickHouseIT.java | 2 +- .../server/app/security/JwtServiceTest.java | 50 +++++- .../server/core/security/JwtService.java | 54 +++--- .../server/core/security/UserInfo.java | 23 +++ .../server/core/security/UserRepository.java | 20 +++ clickhouse/init/03-users.sql | 10 ++ 21 files changed, 839 insertions(+), 123 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java create mode 100644 cameleer3-server-app/src/main/resources/clickhouse/03-users.sql create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java create mode 100644 clickhouse/init/03-users.sql diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml index 2e5aedc7..891d4762 100644 --- a/cameleer3-server-app/pom.xml +++ b/cameleer3-server-app/pom.xml @@ -75,6 +75,11 @@ nimbus-jose-jwt 9.47 + + com.nimbusds + oauth2-oidc-sdk + 11.23.1 + org.springframework.boot spring-boot-starter-test diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java index c31de99b..aefa57b9 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java @@ -25,7 +25,9 @@ import java.util.stream.Collectors; public class ClickHouseConfig { private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class); - private static final String[] SCHEMA_FILES = {"clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql"}; + private static final String[] SCHEMA_FILES = { + "clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql", "clickhouse/03-users.sql" + }; private final DataSource dataSource; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index c4e06565..1d89da5b 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -116,9 +116,10 @@ public class AgentRegistrationController { AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities); log.info("Agent registered: {} (name={}, group={})", agentId, name, group); - // Issue JWT tokens - String accessToken = jwtService.createAccessToken(agentId, group); - String refreshToken = jwtService.createRefreshToken(agentId, group); + // Issue JWT tokens with AGENT role + java.util.List roles = java.util.List.of("AGENT"); + String accessToken = jwtService.createAccessToken(agentId, group, roles); + String refreshToken = jwtService.createRefreshToken(agentId, group, roles); Map response = new LinkedHashMap<>(); response.put("agentId", agent.id()); @@ -147,14 +148,16 @@ public class AgentRegistrationController { } // Validate refresh token - String agentId; + com.cameleer3.server.core.security.JwtService.JwtValidationResult result; try { - agentId = jwtService.validateRefreshToken(refreshToken); + result = jwtService.validateRefreshToken(refreshToken); } catch (InvalidTokenException e) { log.debug("Refresh token validation failed: {}", e.getMessage()); return ResponseEntity.status(401).build(); } + String agentId = result.subject(); + // Verify agent ID in path matches token if (!id.equals(agentId)) { log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId); @@ -167,7 +170,10 @@ public class AgentRegistrationController { return ResponseEntity.notFound().build(); } - String newAccessToken = jwtService.createAccessToken(agentId, agent.group()); + // Preserve roles from refresh token + java.util.List roles = result.roles().isEmpty() + ? java.util.List.of("AGENT") : result.roles(); + String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles); Map response = new LinkedHashMap<>(); response.put("accessToken", newAccessToken); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java new file mode 100644 index 00000000..e0cfd1b3 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -0,0 +1,73 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.core.security.UserInfo; +import com.cameleer3.server.core.security.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Admin endpoints for user management. + * Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns. + */ +@RestController +@RequestMapping("/api/v1/admin/users") +@Tag(name = "User Admin", description = "User management (ADMIN only)") +public class UserAdminController { + + private final UserRepository userRepository; + + public UserAdminController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping + @Operation(summary = "List all users") + @ApiResponse(responseCode = "200", description = "User list returned") + public ResponseEntity> listUsers() { + return ResponseEntity.ok(userRepository.findAll()); + } + + @GetMapping("/{userId}") + @Operation(summary = "Get user by ID") + @ApiResponse(responseCode = "200", description = "User found") + @ApiResponse(responseCode = "404", description = "User not found") + public ResponseEntity getUser(@PathVariable String userId) { + return userRepository.findById(userId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PutMapping("/{userId}/roles") + @Operation(summary = "Update user roles") + @ApiResponse(responseCode = "200", description = "Roles updated") + @ApiResponse(responseCode = "404", description = "User not found") + public ResponseEntity updateRoles(@PathVariable String userId, + @RequestBody RolesRequest request) { + if (userRepository.findById(userId).isEmpty()) { + return ResponseEntity.notFound().build(); + } + userRepository.updateRoles(userId, request.roles()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{userId}") + @Operation(summary = "Delete user") + @ApiResponse(responseCode = "204", description = "User deleted") + public ResponseEntity deleteUser(@PathVariable String userId) { + userRepository.delete(userId); + return ResponseEntity.noContent().build(); + } + + public record RolesRequest(List roles) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java index 777885ff..37393f70 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -2,6 +2,7 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.security.JwtService; +import com.cameleer3.server.core.security.JwtService.JwtValidationResult; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -9,6 +10,8 @@ import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -19,6 +22,9 @@ import java.util.List; * JWT authentication filter that extracts and validates JWT tokens from * the {@code Authorization: Bearer} header or the {@code token} query parameter. *

+ * Populates Spring Security {@code GrantedAuthority} from the JWT {@code roles} claim. + * Agent tokens without roles get {@code ROLE_AGENT}; UI tokens get authorities from the claim. + *

* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig} * to avoid double filter registration. */ @@ -43,15 +49,24 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (token != null) { try { - String subject = jwtService.validateAndExtractAgentId(token); + JwtValidationResult result = jwtService.validateAccessToken(token); + String subject = result.subject(); + if (subject.startsWith("ui:")) { - // UI user token — authenticate directly without agent registry lookup + // UI user token — authenticate with roles from JWT + List authorities = toAuthorities(result.roles()); UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(subject, null, List.of()); + new UsernamePasswordAuthenticationToken(subject, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } else if (agentRegistryService.findById(subject) != null) { + // Agent token — use roles from JWT, default to AGENT if empty + List roles = result.roles(); + if (roles.isEmpty()) { + roles = List.of("AGENT"); + } + List authorities = toAuthorities(roles); UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(subject, null, List.of()); + new UsernamePasswordAuthenticationToken(subject, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } else { log.debug("JWT valid but agent not found: {}", subject); @@ -64,14 +79,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { chain.doFilter(request, response); } + private List toAuthorities(List roles) { + return roles.stream() + .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + } + private String extractToken(HttpServletRequest request) { - // Check Authorization header first String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { return authHeader.substring(BEARER_PREFIX.length()); } - - // Fall back to query parameter (for SSE EventSource API) return request.getParameter("token"); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java index 86310484..40cb29a6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java @@ -11,21 +11,28 @@ import com.nimbusds.jose.crypto.MACSigner; import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.security.SecureRandom; import java.text.ParseException; import java.time.Instant; +import java.util.Base64; import java.util.Date; +import java.util.List; /** * HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT. *

- * Generates a random 256-bit secret in the constructor (ephemeral per server instance). - * Creates access tokens (1h default) and refresh tokens (7d default) with type claims - * to distinguish between the two. + * If {@code security.jwt-secret} is configured, uses that secret (Base64-decoded or raw bytes + * if not valid Base64). Otherwise generates a random 256-bit secret (ephemeral per server instance). + *

+ * Embeds a {@code roles} array claim in every token for RBAC. */ public class JwtServiceImpl implements JwtService { + private static final Logger log = LoggerFactory.getLogger(JwtServiceImpl.class); + private final byte[] secret; private final JWSSigner signer; private final JWSVerifier verifier; @@ -33,8 +40,17 @@ public class JwtServiceImpl implements JwtService { public JwtServiceImpl(SecurityProperties properties) { this.properties = properties; - this.secret = new byte[32]; // 256-bit - new SecureRandom().nextBytes(secret); + + String configuredSecret = properties.getJwtSecret(); + if (configuredSecret != null && !configuredSecret.isBlank()) { + this.secret = decodeSecret(configuredSecret); + log.info("JWT signing secret loaded from configuration"); + } else { + this.secret = new byte[32]; // 256-bit + new SecureRandom().nextBytes(secret); + log.info("JWT signing secret generated randomly (tokens will not survive restarts)"); + } + try { this.signer = new MACSigner(secret); this.verifier = new MACVerifier(secret); @@ -44,31 +60,38 @@ public class JwtServiceImpl implements JwtService { } @Override - public String createAccessToken(String agentId, String group) { - return createToken(agentId, group, "access", properties.getAccessTokenExpiryMs()); + public String createAccessToken(String subject, String group, List roles) { + return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs()); } @Override - public String createRefreshToken(String agentId, String group) { - return createToken(agentId, group, "refresh", properties.getRefreshTokenExpiryMs()); + public String createRefreshToken(String subject, String group, List roles) { + return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs()); } @Override - public String validateAndExtractAgentId(String token) { + public JwtValidationResult validateAccessToken(String token) { return validateToken(token, "access"); } @Override - public String validateRefreshToken(String token) { + public JwtValidationResult validateRefreshToken(String token) { return validateToken(token, "refresh"); } - private String createToken(String agentId, String group, String type, long expiryMs) { + @Override + public String validateAndExtractAgentId(String token) { + return validateAccessToken(token).subject(); + } + + private String createToken(String subject, String group, List roles, + String type, long expiryMs) { Instant now = Instant.now(); JWTClaimsSet claims = new JWTClaimsSet.Builder() - .subject(agentId) + .subject(subject) .claim("group", group) .claim("type", type) + .claim("roles", roles) .issueTime(Date.from(now)) .expirationTime(Date.from(now.plusMillis(expiryMs))) .build(); @@ -82,7 +105,8 @@ public class JwtServiceImpl implements JwtService { return signedJWT.serialize(); } - private String validateToken(String token, String expectedType) { + @SuppressWarnings("unchecked") + private JwtValidationResult validateToken(String token, String expectedType) { try { SignedJWT signedJWT = SignedJWT.parse(token); @@ -92,13 +116,11 @@ public class JwtServiceImpl implements JwtService { JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); - // Check expiration Date expiration = claims.getExpirationTime(); if (expiration == null || expiration.before(new Date())) { throw new InvalidTokenException("Token has expired"); } - // Check type String type = claims.getStringClaim("type"); if (!expectedType.equals(type)) { throw new InvalidTokenException( @@ -107,14 +129,48 @@ public class JwtServiceImpl implements JwtService { String subject = claims.getSubject(); if (subject == null || subject.isBlank()) { - throw new InvalidTokenException("Token has no subject (agentId)"); + throw new InvalidTokenException("Token has no subject"); } - return subject; + String group = claims.getStringClaim("group"); + + // Extract roles — may be absent in legacy tokens + List roles; + Object rolesClaim = claims.getClaim("roles"); + if (rolesClaim instanceof List) { + roles = ((List) rolesClaim).stream() + .map(Object::toString) + .toList(); + } else { + roles = List.of(); + } + + return new JwtValidationResult(subject, group, roles); } catch (ParseException e) { throw new InvalidTokenException("Failed to parse JWT", e); } catch (JOSEException e) { throw new InvalidTokenException("Failed to verify JWT signature", e); } } + + private static byte[] decodeSecret(String secret) { + try { + byte[] decoded = Base64.getDecoder().decode(secret); + if (decoded.length >= 32) { + return decoded; + } + } catch (IllegalArgumentException ignored) { + // Not valid Base64 — use raw bytes + } + byte[] raw = secret.getBytes(java.nio.charset.StandardCharsets.UTF_8); + if (raw.length < 32) { + // Pad to 32 bytes with SHA-256 hash + try { + return java.security.MessageDigest.getInstance("SHA-256").digest(raw); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + return raw; + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java new file mode 100644 index 00000000..029b11c8 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -0,0 +1,127 @@ +package com.cameleer3.server.app.security; + +import com.cameleer3.server.core.security.JwtService; +import com.cameleer3.server.core.security.UserInfo; +import com.cameleer3.server.core.security.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * OIDC authentication endpoints for the UI. + *

+ * Only active when {@code security.oidc.enabled=true}. + * The SPA initiates the authorization code flow, then sends the code here + * for server-side token exchange (keeping client_secret secure). + */ +@RestController +@RequestMapping("/api/v1/auth/oidc") +@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true") +public class OidcAuthController { + + private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class); + + private final OidcTokenExchanger tokenExchanger; + private final JwtService jwtService; + private final UserRepository userRepository; + private final SecurityProperties properties; + + public OidcAuthController(OidcTokenExchanger tokenExchanger, + JwtService jwtService, + UserRepository userRepository, + SecurityProperties properties) { + this.tokenExchanger = tokenExchanger; + this.jwtService = jwtService; + this.userRepository = userRepository; + this.properties = properties; + } + + /** + * Returns OIDC configuration for the SPA to initiate the authorization code flow. + */ + @GetMapping("/config") + public ResponseEntity getConfig() { + try { + SecurityProperties.Oidc oidc = properties.getOidc(); + return ResponseEntity.ok(Map.of( + "issuer", oidc.getIssuerUri(), + "clientId", oidc.getClientId(), + "authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint() + )); + } catch (Exception e) { + log.error("Failed to retrieve OIDC config: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("message", "Failed to retrieve OIDC provider metadata")); + } + } + + /** + * Exchanges an OIDC authorization code for internal Cameleer JWTs. + *

+ * Role resolution priority: + * 1. ClickHouse user table (admin-assigned override) + * 2. OIDC token claim + * 3. Default roles from config + */ + @PostMapping("/callback") + public ResponseEntity callback(@RequestBody CallbackRequest request) { + try { + OidcTokenExchanger.OidcUserInfo oidcUser = + tokenExchanger.exchange(request.code(), request.redirectUri()); + + String userId = "oidc:" + oidcUser.subject(); + String issuerHost = URI.create(properties.getOidc().getIssuerUri()).getHost(); + String provider = "oidc:" + issuerHost; + + // Resolve roles: DB override > OIDC claim > default + List roles = resolveRoles(userId, oidcUser.roles()); + + // Upsert user + userRepository.upsert(new UserInfo( + userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now())); + + // Issue internal tokens + String accessToken = jwtService.createAccessToken(userId, "ui", roles); + String refreshToken = jwtService.createRefreshToken(userId, "ui", roles); + + return ResponseEntity.ok(Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + )); + } catch (Exception e) { + log.error("OIDC callback failed: {}", e.getMessage(), e); + return ResponseEntity.status(401) + .body(Map.of("message", "OIDC authentication failed: " + e.getMessage())); + } + } + + private List resolveRoles(String userId, List oidcRoles) { + // 1. Check for admin-assigned override in user store + Optional existing = userRepository.findById(userId); + if (existing.isPresent() && !existing.get().roles().isEmpty()) { + return existing.get().roles(); + } + + // 2. Roles from OIDC token + if (!oidcRoles.isEmpty()) { + return oidcRoles; + } + + // 3. Default roles + return properties.getOidc().getDefaultRoles(); + } + + public record CallbackRequest(String code, String redirectUri) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java new file mode 100644 index 00000000..48097f4c --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java @@ -0,0 +1,168 @@ +package com.cameleer3.server.app.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.TokenResponse; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Exchanges OIDC authorization codes for validated user information. + *

+ * Fetches and caches the OIDC provider discovery metadata, exchanges the auth code + * for tokens at the provider's token endpoint, and validates the id_token using JWKS. + */ +public class OidcTokenExchanger { + + private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class); + + private final SecurityProperties.Oidc oidcConfig; + private volatile OIDCProviderMetadata providerMetadata; + private volatile ConfigurableJWTProcessor jwtProcessor; + + public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) { + this.oidcConfig = oidcConfig; + } + + public record OidcUserInfo(String subject, String email, String name, List roles) {} + + /** + * Exchanges an authorization code for validated user info. + * + * @param code the authorization code from the OIDC provider + * @param redirectUri the redirect URI used in the authorization request + * @return validated user information from the id_token + */ + public OidcUserInfo exchange(String code, String redirectUri) throws Exception { + OIDCProviderMetadata metadata = getProviderMetadata(); + + // Exchange code for tokens + ClientAuthentication clientAuth = new ClientSecretBasic( + new ClientID(oidcConfig.getClientId()), + new Secret(oidcConfig.getClientSecret()) + ); + + TokenRequest tokenRequest = new TokenRequest( + metadata.getTokenEndpointURI(), + clientAuth, + new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri)) + ); + + TokenResponse tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send()); + + if (!tokenResponse.indicatesSuccess()) { + String error = tokenResponse.toErrorResponse().getErrorObject().getDescription(); + throw new IllegalStateException("OIDC token exchange failed: " + error); + } + + // Extract id_token from successful response + String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject() + .getAsString("id_token"); + if (idTokenStr == null) { + throw new IllegalStateException("OIDC response missing id_token"); + } + + // Validate id_token + JWTClaimsSet claims = getJwtProcessor().process(idTokenStr, null); + + String subject = claims.getSubject(); + String email = claims.getStringClaim("email"); + String name = claims.getStringClaim("name"); + if (name == null) { + name = claims.getStringClaim("preferred_username"); + } + + List roles = extractRoles(claims, oidcConfig.getRolesClaim()); + + log.info("OIDC user authenticated: sub={}, email={}", subject, email); + return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles); + } + + /** + * Returns the provider's authorization endpoint for the SPA to initiate the flow. + */ + public String getAuthorizationEndpoint() throws Exception { + return getProviderMetadata().getAuthorizationEndpointURI().toString(); + } + + @SuppressWarnings("unchecked") + private List extractRoles(JWTClaimsSet claims, String claimPath) { + try { + String[] parts = claimPath.split("\\."); + Object current = claims.getClaim(parts[0]); + + for (int i = 1; i < parts.length && current instanceof Map; i++) { + current = ((Map) current).get(parts[i]); + } + + if (current instanceof List) { + return ((List) current).stream() + .map(Object::toString) + .toList(); + } + } catch (Exception e) { + log.debug("Could not extract roles from claim path '{}': {}", claimPath, e.getMessage()); + } + return Collections.emptyList(); + } + + private OIDCProviderMetadata getProviderMetadata() throws Exception { + if (providerMetadata == null) { + synchronized (this) { + if (providerMetadata == null) { + Issuer issuer = new Issuer(oidcConfig.getIssuerUri()); + providerMetadata = OIDCProviderMetadata.resolve(issuer); + log.info("OIDC provider metadata loaded from {}", oidcConfig.getIssuerUri()); + } + } + } + return providerMetadata; + } + + private ConfigurableJWTProcessor getJwtProcessor() throws Exception { + if (jwtProcessor == null) { + synchronized (this) { + if (jwtProcessor == null) { + OIDCProviderMetadata metadata = getProviderMetadata(); + JWKSource jwkSource = JWKSourceBuilder + .create(metadata.getJWKSetURI().toURL()) + .build(); + + Set expectedAlgs = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256); + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(expectedAlgs, jwkSource); + + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(keySelector); + jwtProcessor = processor; + } + } + } + return jwtProcessor; + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java index 3dd05fd6..57fa8119 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java @@ -1,6 +1,7 @@ package com.cameleer3.server.app.security; import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -40,4 +41,10 @@ public class SecurityBeanConfig { } }; } + + @Bean + @ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true") + public OidcTokenExchanger oidcTokenExchanger(SecurityProperties properties) { + return new OidcTokenExchanger(properties.getOidc()); + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index f3e1711b..2a6f786b 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -4,6 +4,8 @@ import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.security.JwtService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -15,15 +17,13 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.http.HttpStatus; - import java.util.List; /** - * Spring Security configuration for JWT-based stateless authentication. + * Spring Security configuration for JWT-based stateless authentication with RBAC. *

* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources. - * All other endpoints require a valid JWT access token. + * All other endpoints require a valid JWT access token with appropriate roles. */ @Configuration @EnableWebSecurity @@ -41,6 +41,7 @@ public class SecurityConfig { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth + // Public endpoints .requestMatchers( "/api/v1/health", "/api/v1/agents/register", @@ -58,6 +59,32 @@ public class SecurityConfig { "/favicon.svg", "/assets/**" ).permitAll() + + // Agent-only endpoints + .requestMatchers("/api/v1/data/**").hasRole("AGENT") + .requestMatchers("/api/v1/agents/*/heartbeat").hasRole("AGENT") + .requestMatchers("/api/v1/agents/*/events").hasRole("AGENT") + .requestMatchers("/api/v1/agents/*/commands/*/ack").hasRole("AGENT") + + // Command endpoints — operator+ only + .requestMatchers(HttpMethod.POST, "/api/v1/agents/*/commands").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/commands").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/agents/commands").hasAnyRole("OPERATOR", "ADMIN") + + // Search endpoints + .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") + .requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + + // Read-only data endpoints — viewer+ + .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + + // Admin endpoints + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + + // Everything else requires authentication .anyRequest().authenticated() ) .exceptionHandling(ex -> ex diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java index caad27ea..e867098b 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java @@ -2,6 +2,8 @@ package com.cameleer3.server.app.security; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.List; + /** * Configuration properties for security settings. * Bound from the {@code security.*} namespace in application.yml. @@ -16,60 +18,47 @@ public class SecurityProperties { private String uiUser; private String uiPassword; private String uiOrigin; + private String jwtSecret; + private Oidc oidc = new Oidc(); - public long getAccessTokenExpiryMs() { - return accessTokenExpiryMs; + public static class Oidc { + private boolean enabled = false; + private String issuerUri; + private String clientId; + private String clientSecret; + private String rolesClaim = "realm_access.roles"; + private List defaultRoles = List.of("VIEWER"); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getIssuerUri() { return issuerUri; } + public void setIssuerUri(String issuerUri) { this.issuerUri = issuerUri; } + public String getClientId() { return clientId; } + public void setClientId(String clientId) { this.clientId = clientId; } + public String getClientSecret() { return clientSecret; } + public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; } + public String getRolesClaim() { return rolesClaim; } + public void setRolesClaim(String rolesClaim) { this.rolesClaim = rolesClaim; } + public List getDefaultRoles() { return defaultRoles; } + public void setDefaultRoles(List defaultRoles) { this.defaultRoles = defaultRoles; } } - public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { - this.accessTokenExpiryMs = accessTokenExpiryMs; - } - - public long getRefreshTokenExpiryMs() { - return refreshTokenExpiryMs; - } - - public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) { - this.refreshTokenExpiryMs = refreshTokenExpiryMs; - } - - public String getBootstrapToken() { - return bootstrapToken; - } - - public void setBootstrapToken(String bootstrapToken) { - this.bootstrapToken = bootstrapToken; - } - - public String getBootstrapTokenPrevious() { - return bootstrapTokenPrevious; - } - - public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) { - this.bootstrapTokenPrevious = bootstrapTokenPrevious; - } - - public String getUiUser() { - return uiUser; - } - - public void setUiUser(String uiUser) { - this.uiUser = uiUser; - } - - public String getUiPassword() { - return uiPassword; - } - - public void setUiPassword(String uiPassword) { - this.uiPassword = uiPassword; - } - - public String getUiOrigin() { - return uiOrigin; - } - - public void setUiOrigin(String uiOrigin) { - this.uiOrigin = uiOrigin; - } + public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; } + public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = accessTokenExpiryMs; } + public long getRefreshTokenExpiryMs() { return refreshTokenExpiryMs; } + public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) { this.refreshTokenExpiryMs = refreshTokenExpiryMs; } + public String getBootstrapToken() { return bootstrapToken; } + public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; } + public String getBootstrapTokenPrevious() { return bootstrapTokenPrevious; } + public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) { this.bootstrapTokenPrevious = bootstrapTokenPrevious; } + public String getUiUser() { return uiUser; } + public void setUiUser(String uiUser) { this.uiUser = uiUser; } + public String getUiPassword() { return uiPassword; } + public void setUiPassword(String uiPassword) { this.uiPassword = uiPassword; } + public String getUiOrigin() { return uiOrigin; } + public void setUiOrigin(String uiOrigin) { this.uiOrigin = uiOrigin; } + public String getJwtSecret() { return jwtSecret; } + public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; } + public Oidc getOidc() { return oidc; } + public void setOidc(Oidc oidc) { this.oidc = oidc; } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 7012009e..df13d3a0 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -1,6 +1,9 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.JwtService; +import com.cameleer3.server.core.security.JwtService.JwtValidationResult; +import com.cameleer3.server.core.security.UserInfo; +import com.cameleer3.server.core.security.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -9,14 +12,16 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.time.Instant; +import java.util.List; import java.util.Map; /** - * Authentication endpoints for the UI. + * Authentication endpoints for the UI (local credentials). *

* Validates credentials against environment-configured username/password, - * then issues JWTs with {@code ui:} prefixed subjects to distinguish - * UI users from agent tokens in {@link JwtAuthenticationFilter}. + * then issues JWTs with {@code ui:} prefixed subjects and ADMIN roles. + * Upserts the user into the user store on login. */ @RestController @RequestMapping("/api/v1/auth") @@ -26,10 +31,13 @@ public class UiAuthController { private final JwtService jwtService; private final SecurityProperties properties; + private final UserRepository userRepository; - public UiAuthController(JwtService jwtService, SecurityProperties properties) { + public UiAuthController(JwtService jwtService, SecurityProperties properties, + UserRepository userRepository) { this.jwtService = jwtService; this.properties = properties; + this.userRepository = userRepository; } @PostMapping("/login") @@ -50,8 +58,18 @@ public class UiAuthController { } String subject = "ui:" + request.username(); - String accessToken = jwtService.createAccessToken(subject, "ui"); - String refreshToken = jwtService.createRefreshToken(subject, "ui"); + List roles = List.of("ADMIN"); + + // Upsert local user into store + try { + userRepository.upsert(new UserInfo( + subject, "local", "", request.username(), roles, Instant.now())); + } catch (Exception e) { + log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage()); + } + + String accessToken = jwtService.createAccessToken(subject, "ui", roles); + String refreshToken = jwtService.createRefreshToken(subject, "ui", roles); log.info("UI user logged in: {}", request.username()); return ResponseEntity.ok(Map.of( @@ -63,13 +81,15 @@ public class UiAuthController { @PostMapping("/refresh") public ResponseEntity refresh(@RequestBody RefreshRequest request) { try { - String subject = jwtService.validateRefreshToken(request.refreshToken()); - if (!subject.startsWith("ui:")) { + JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken()); + if (!result.subject().startsWith("ui:")) { return ResponseEntity.status(401).body(Map.of("message", "Not a UI token")); } - String accessToken = jwtService.createAccessToken(subject, "ui"); - String refreshToken = jwtService.createRefreshToken(subject, "ui"); + // Preserve roles from the refresh token + List roles = result.roles(); + String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles); + String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles); return ResponseEntity.ok(Map.of( "accessToken", accessToken, diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java new file mode 100644 index 00000000..0d11d062 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java @@ -0,0 +1,89 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.security.UserInfo; +import com.cameleer3.server.core.security.UserRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * ClickHouse implementation of {@link UserRepository}. + *

+ * Uses ReplacingMergeTree — reads use {@code FINAL} to get the latest version. + */ +@Repository +public class ClickHouseUserRepository implements UserRepository { + + private final JdbcTemplate jdbc; + + public ClickHouseUserRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public Optional findById(String userId) { + List results = jdbc.query( + "SELECT user_id, provider, email, display_name, roles, created_at " + + "FROM users FINAL WHERE user_id = ?", + this::mapRow, + userId + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public List findAll() { + return jdbc.query( + "SELECT user_id, provider, email, display_name, roles, created_at FROM users FINAL ORDER BY user_id", + this::mapRow + ); + } + + @Override + public void upsert(UserInfo user) { + jdbc.update( + "INSERT INTO users (user_id, provider, email, display_name, roles, updated_at) VALUES (?, ?, ?, ?, ?, now64(3, 'UTC'))", + user.userId(), + user.provider(), + user.email(), + user.displayName(), + user.roles().toArray(new String[0]) + ); + } + + @Override + public void updateRoles(String userId, List roles) { + // ReplacingMergeTree: insert a new row with updated_at to supersede the old one. + // Copy existing fields, update roles. + jdbc.update( + "INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at) " + + "SELECT user_id, provider, email, display_name, ?, created_at, now64(3, 'UTC') " + + "FROM users FINAL WHERE user_id = ?", + roles.toArray(new String[0]), + userId + ); + } + + @Override + public void delete(String userId) { + jdbc.update("DELETE FROM users WHERE user_id = ?", userId); + } + + private UserInfo mapRow(ResultSet rs, int rowNum) throws SQLException { + String[] rolesArray = (String[]) rs.getArray("roles").getArray(); + return new UserInfo( + rs.getString("user_id"), + rs.getString("provider"), + rs.getString("email"), + rs.getString("display_name"), + Arrays.asList(rolesArray), + rs.getTimestamp("created_at").toInstant() + ); + } +} diff --git a/cameleer3-server-app/src/main/resources/application.yml b/cameleer3-server-app/src/main/resources/application.yml index de2b76bf..31974bae 100644 --- a/cameleer3-server-app/src/main/resources/application.yml +++ b/cameleer3-server-app/src/main/resources/application.yml @@ -40,6 +40,14 @@ security: ui-user: ${CAMELEER_UI_USER:admin} ui-password: ${CAMELEER_UI_PASSWORD:admin} ui-origin: ${CAMELEER_UI_ORIGIN:http://localhost:5173} + jwt-secret: ${CAMELEER_JWT_SECRET:} + oidc: + enabled: ${CAMELEER_OIDC_ENABLED:false} + issuer-uri: ${CAMELEER_OIDC_ISSUER:} + client-id: ${CAMELEER_OIDC_CLIENT_ID:} + client-secret: ${CAMELEER_OIDC_CLIENT_SECRET:} + roles-claim: ${CAMELEER_OIDC_ROLES_CLAIM:realm_access.roles} + default-roles: ${CAMELEER_OIDC_DEFAULT_ROLES:VIEWER} springdoc: api-docs: diff --git a/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql b/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql new file mode 100644 index 00000000..9dc7ce7a --- /dev/null +++ b/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id String, + provider LowCardinality(String), + email String DEFAULT '', + display_name String DEFAULT '', + roles Array(LowCardinality(String)), + created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), + updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') +) ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (user_id); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java index a4a27597..07389dc1 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java @@ -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(), diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java index 99784949..1fb9ba8d 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java @@ -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 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 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 diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java index dc8f5318..c3afda2a 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java @@ -1,48 +1,60 @@ package com.cameleer3.server.core.security; +import java.util.List; + /** * Service for creating and validating JSON Web Tokens (JWT). *

* Access tokens are short-lived (default 1 hour) and used for API authentication. * Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens. + * Tokens carry a {@code roles} claim for role-based access control. */ public interface JwtService { /** - * Creates a signed access JWT with the given agent ID and group. + * Validated JWT payload. * - * @param agentId the agent identifier (becomes the {@code sub} claim) - * @param group the agent group (becomes the {@code group} claim) - * @return a signed JWT string + * @param subject the {@code sub} claim (agent ID or {@code ui:}) + * @param group the {@code group} claim + * @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]}) */ - String createAccessToken(String agentId, String group); + record JwtValidationResult(String subject, String group, List roles) {} /** - * Creates a signed refresh JWT with the given agent ID and group. - * - * @param agentId the agent identifier (becomes the {@code sub} claim) - * @param group the agent group (becomes the {@code group} claim) - * @return a signed JWT string + * Creates a signed access JWT with the given subject, group, and roles. */ - String createRefreshToken(String agentId, String group); + String createAccessToken(String subject, String group, List roles); /** - * Validates an access token and extracts the agent ID. - * Rejects expired tokens and tokens that are not of type "access". + * Creates a signed refresh JWT with the given subject, group, and roles. + */ + String createRefreshToken(String subject, String group, List roles); + + /** + * Validates an access token and returns the full validation result. * - * @param token the JWT string to validate - * @return the agent ID from the {@code sub} claim * @throws InvalidTokenException if the token is invalid, expired, or not an access token */ - String validateAndExtractAgentId(String token); + JwtValidationResult validateAccessToken(String token); /** - * Validates a refresh token and extracts the agent ID. - * Rejects expired tokens and tokens that are not of type "refresh". + * Validates a refresh token and returns the full validation result. * - * @param token the JWT string to validate - * @return the agent ID from the {@code sub} claim * @throws InvalidTokenException if the token is invalid, expired, or not a refresh token */ - String validateRefreshToken(String token); + JwtValidationResult validateRefreshToken(String token); + + // --- Backward-compatible defaults (delegate to role-aware methods) --- + + default String createAccessToken(String subject, String group) { + return createAccessToken(subject, group, List.of()); + } + + default String createRefreshToken(String subject, String group) { + return createRefreshToken(subject, group, List.of()); + } + + default String validateAndExtractAgentId(String token) { + return validateAccessToken(token).subject(); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java new file mode 100644 index 00000000..ffabd508 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java @@ -0,0 +1,23 @@ +package com.cameleer3.server.core.security; + +import java.time.Instant; +import java.util.List; + +/** + * Represents a persisted user in the system. + * + * @param userId unique identifier (e.g. OIDC {@code sub} or {@code ui:}) + * @param provider authentication provider ({@code "local"}, {@code "oidc:"}) + * @param email user email (may be empty) + * @param displayName display name (may be empty) + * @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]}) + * @param createdAt first creation timestamp + */ +public record UserInfo( + String userId, + String provider, + String email, + String displayName, + List roles, + Instant createdAt +) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java new file mode 100644 index 00000000..70d7bb4b --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java @@ -0,0 +1,20 @@ +package com.cameleer3.server.core.security; + +import java.util.List; +import java.util.Optional; + +/** + * Persistence interface for user management. + */ +public interface UserRepository { + + Optional findById(String userId); + + List findAll(); + + void upsert(UserInfo user); + + void updateRoles(String userId, List roles); + + void delete(String userId); +} diff --git a/clickhouse/init/03-users.sql b/clickhouse/init/03-users.sql new file mode 100644 index 00000000..9dc7ce7a --- /dev/null +++ b/clickhouse/init/03-users.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id String, + provider LowCardinality(String), + email String DEFAULT '', + display_name String DEFAULT '', + roles Array(LowCardinality(String)), + created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), + updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') +) ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (user_id);