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

@@ -75,6 +75,11 @@
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.47</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>11.23.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -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;

View File

@@ -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<String> roles = java.util.List.of("AGENT");
String accessToken = jwtService.createAccessToken(agentId, group, roles);
String refreshToken = jwtService.createRefreshToken(agentId, group, roles);
Map<String, Object> 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<String> roles = result.roles().isEmpty()
? java.util.List.of("AGENT") : result.roles();
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
Map<String, Object> response = new LinkedHashMap<>();
response.put("accessToken", newAccessToken);

View File

@@ -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<List<UserInfo>> 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<UserInfo> 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<Void> 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<Void> deleteUser(@PathVariable String userId) {
userRepository.delete(userId);
return ResponseEntity.noContent().build();
}
public record RolesRequest(List<String> roles) {}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<GrantedAuthority> 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<String> roles = result.roles();
if (roles.isEmpty()) {
roles = List.of("AGENT");
}
List<GrantedAuthority> 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<GrantedAuthority> toAuthorities(List<String> 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");
}
}

View File

@@ -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.
* <p>
* 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).
* <p>
* 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;
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<String> 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<String> 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<String> 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<String> 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;
}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<String> 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<String> resolveRoles(String userId, List<String> oidcRoles) {
// 1. Check for admin-assigned override in user store
Optional<UserInfo> 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) {}
}

View File

@@ -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.
* <p>
* 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<SecurityContext> jwtProcessor;
public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) {
this.oidcConfig = oidcConfig;
}
public record OidcUserInfo(String subject, String email, String name, List<String> 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<String> 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<String> 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<String, Object>) 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<SecurityContext> getJwtProcessor() throws Exception {
if (jwtProcessor == null) {
synchronized (this) {
if (jwtProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata();
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(metadata.getJWKSetURI().toURL())
.build();
Set<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256);
JWSKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(keySelector);
jwtProcessor = processor;
}
}
}
return jwtProcessor;
}
}

View File

@@ -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());
}
}

View File

@@ -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.
* <p>
* 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

View File

@@ -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<String> 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<String> getDefaultRoles() { return defaultRoles; }
public void setDefaultRoles(List<String> 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; }
}

View File

@@ -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).
* <p>
* 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<String> 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<String> 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,

View File

@@ -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}.
* <p>
* 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<UserInfo> findById(String userId) {
List<UserInfo> 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<UserInfo> 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<String> 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()
);
}
}

View File

@@ -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:

View File

@@ -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);

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

View File

@@ -1,48 +1,60 @@
package com.cameleer3.server.core.security;
import java.util.List;
/**
* Service for creating and validating JSON Web Tokens (JWT).
* <p>
* 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:<username>})
* @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<String> 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<String> 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<String> 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();
}
}

View File

@@ -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:<username>})
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
* @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<String> roles,
Instant createdAt
) {}

View File

@@ -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<UserInfo> findById(String userId);
List<UserInfo> findAll();
void upsert(UserInfo user);
void updateRoles(String userId, List<String> roles);
void delete(String userId);
}

View File

@@ -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);