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

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