Add RBAC with role-based endpoint authorization and OIDC support
Implement three-phase security upgrade: Phase 1 - RBAC: Extend JWT with roles claim, populate Spring GrantedAuthority in filter, enforce role-based access (AGENT for data/heartbeat/SSE, VIEWER+ for search/diagrams, OPERATOR+ for commands, ADMIN for user management). Configurable JWT secret via CAMELEER_JWT_SECRET env var for token persistence across restarts. Phase 2 - User persistence: ClickHouse users table with ReplacingMergeTree, UserRepository interface + ClickHouse impl, UserAdminController for CRUD at /api/v1/admin/users. Local login upserts user on each authentication. Phase 3 - OIDC: Token exchange flow where SPA sends auth code, server exchanges it server-side (keeping client_secret secure), validates id_token via JWKS, resolves roles (DB override > OIDC claim > default), issues internal JWT. Conditional on CAMELEER_OIDC_ENABLED=true. Uses oauth2-oidc-sdk for standards compliance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user