feat: replace flat users.roles with relational RBAC model

New package com.cameleer3.server.core.rbac with SystemRole constants,
detail/summary records, GroupRepository, RoleRepository, RbacService.
Remove roles field from UserInfo. Implement PostgresGroupRepository,
PostgresRoleRepository, RbacServiceImpl with inheritance computation.
Update UiAuthController, OidcAuthController, AgentRegistrationController
to assign roles via user_roles table. JWT populated from effective system roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 17:44:32 +01:00
parent b06b3f52a8
commit eb0cc8c141
22 changed files with 639 additions and 44 deletions

View File

@@ -6,6 +6,8 @@ import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository;
@@ -33,11 +35,12 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* OIDC authentication endpoints for the UI.
* <p>
* Always registered returns 404 when OIDC is not configured or disabled.
* Always registered -- returns 404 when OIDC is not configured or disabled.
* Configuration is read from the database (managed via admin UI).
*/
@RestController
@@ -52,17 +55,20 @@ public class OidcAuthController {
private final JwtService jwtService;
private final UserRepository userRepository;
private final AuditService auditService;
private final RbacService rbacService;
public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository,
JwtService jwtService,
UserRepository userRepository,
AuditService auditService) {
AuditService auditService,
RbacService rbacService) {
this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository;
this.jwtService = jwtService;
this.userRepository = userRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
/**
@@ -130,11 +136,16 @@ public class OidcAuthController {
"Account not provisioned. Contact your administrator.");
}
// Resolve roles: DB override > OIDC claim > default
List<String> roles = resolveRoles(existingUser, oidcUser.roles(), config.get());
// Upsert user (without roles -- roles are in user_roles table)
userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
// Assign roles if new user
if (existingUser.isEmpty()) {
assignRolesForNewUser(userId, oidcUser.roles(), config.get());
}
List<String> roles = rbacService.getSystemRoleNames(userId);
String accessToken = jwtService.createAccessToken(userId, "user", roles);
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
@@ -153,14 +164,14 @@ public class OidcAuthController {
}
}
private List<String> resolveRoles(Optional<UserInfo> existing, List<String> oidcRoles, OidcConfig config) {
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
return existing.get().roles();
private void assignRolesForNewUser(String userId, List<String> oidcRoles, OidcConfig config) {
List<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles();
for (String roleName : roleNames) {
UUID roleId = SystemRole.BY_NAME.get(roleName.toUpperCase());
if (roleId != null) {
rbacService.assignRoleToUser(userId, roleId);
}
}
if (!oidcRoles.isEmpty()) {
return oidcRoles;
}
return config.defaultRoles();
}
public record CallbackRequest(String code, String redirectUri) {}

View File

@@ -5,6 +5,8 @@ import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService;
import jakarta.servlet.http.HttpServletRequest;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
@@ -47,13 +49,16 @@ public class UiAuthController {
private final SecurityProperties properties;
private final UserRepository userRepository;
private final AuditService auditService;
private final RbacService rbacService;
public UiAuthController(JwtService jwtService, SecurityProperties properties,
UserRepository userRepository, AuditService auditService) {
UserRepository userRepository, AuditService auditService,
RbacService rbacService) {
this.jwtService = jwtService;
this.properties = properties;
this.userRepository = userRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
@PostMapping("/login")
@@ -83,16 +88,21 @@ public class UiAuthController {
}
String subject = "user:" + request.username();
List<String> roles = List.of("ADMIN");
// Upsert local user into store
// Upsert local user into store (without roles — roles are in user_roles table)
try {
userRepository.upsert(new UserInfo(
subject, "local", "", request.username(), roles, Instant.now()));
subject, "local", "", request.username(), Instant.now()));
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
} catch (Exception e) {
log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
}
List<String> roles = rbacService.getSystemRoleNames(subject);
if (roles.isEmpty()) {
roles = List.of("ADMIN");
}
String accessToken = jwtService.createAccessToken(subject, "user", roles);
String refreshToken = jwtService.createRefreshToken(subject, "user", roles);