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

@@ -11,6 +11,8 @@ import com.cameleer3.server.app.security.BootstrapTokenValidator;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.Ed25519SigningService;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
@@ -50,17 +52,20 @@ public class AgentRegistrationController {
private final BootstrapTokenValidator bootstrapTokenValidator;
private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService;
private final RbacService rbacService;
public AgentRegistrationController(AgentRegistryService registryService,
AgentRegistryConfig config,
BootstrapTokenValidator bootstrapTokenValidator,
JwtService jwtService,
Ed25519SigningService ed25519SigningService) {
Ed25519SigningService ed25519SigningService,
RbacService rbacService) {
this.registryService = registryService;
this.config = config;
this.bootstrapTokenValidator = bootstrapTokenValidator;
this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService;
this.rbacService = rbacService;
}
@PostMapping("/register")
@@ -97,6 +102,9 @@ public class AgentRegistrationController {
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
// Assign AGENT role via RBAC
rbacService.assignRoleToUser(request.agentId(), SystemRole.AGENT_ID);
// Issue JWT tokens with AGENT role
List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);

View File

@@ -3,6 +3,8 @@ package com.cameleer3.server.app.controller;
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.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Admin endpoints for user management.
@@ -34,10 +37,13 @@ public class UserAdminController {
private final UserRepository userRepository;
private final AuditService auditService;
private final RbacService rbacService;
public UserAdminController(UserRepository userRepository, AuditService auditService) {
public UserAdminController(UserRepository userRepository, AuditService auditService,
RbacService rbacService) {
this.userRepository = userRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
@GetMapping
@@ -67,7 +73,20 @@ public class UserAdminController {
if (userRepository.findById(userId).isEmpty()) {
return ResponseEntity.notFound().build();
}
userRepository.updateRoles(userId, request.roles());
// Remove all existing direct roles, then assign the new ones
List<String> currentRoles = rbacService.getSystemRoleNames(userId);
for (String roleName : currentRoles) {
UUID roleId = SystemRole.BY_NAME.get(roleName);
if (roleId != null) {
rbacService.removeRoleFromUser(userId, roleId);
}
}
for (String roleName : request.roles()) {
UUID roleId = SystemRole.BY_NAME.get(roleName.toUpperCase());
if (roleId != null) {
rbacService.assignRoleToUser(userId, roleId);
}
}
auditService.log("update_roles", AuditCategory.USER_MGMT, userId,
Map.of("roles", request.roles()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();

View File

@@ -0,0 +1,253 @@
package com.cameleer3.server.app.rbac;
import com.cameleer3.server.core.rbac.*;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class RbacServiceImpl implements RbacService {
private final JdbcTemplate jdbc;
private final UserRepository userRepository;
private final GroupRepository groupRepository;
private final RoleRepository roleRepository;
public RbacServiceImpl(JdbcTemplate jdbc, UserRepository userRepository,
GroupRepository groupRepository, RoleRepository roleRepository) {
this.jdbc = jdbc;
this.userRepository = userRepository;
this.groupRepository = groupRepository;
this.roleRepository = roleRepository;
}
@Override
public List<UserDetail> listUsers() {
return userRepository.findAll().stream()
.map(this::buildUserDetail)
.toList();
}
@Override
public UserDetail getUser(String userId) {
UserInfo user = userRepository.findById(userId).orElse(null);
if (user == null) return null;
return buildUserDetail(user);
}
private UserDetail buildUserDetail(UserInfo user) {
List<RoleSummary> directRoles = getDirectRolesForUser(user.userId());
List<GroupSummary> directGroups = getDirectGroupsForUser(user.userId());
List<RoleSummary> effectiveRoles = getEffectiveRolesForUser(user.userId());
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(user.userId());
return new UserDetail(user.userId(), user.provider(), user.email(),
user.displayName(), user.createdAt(),
directRoles, directGroups, effectiveRoles, effectiveGroups);
}
@Override
public void assignRoleToUser(String userId, UUID roleId) {
jdbc.update("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, roleId);
}
@Override
public void removeRoleFromUser(String userId, UUID roleId) {
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
}
@Override
public void addUserToGroup(String userId, UUID groupId) {
jdbc.update("INSERT INTO user_groups (user_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, groupId);
}
@Override
public void removeUserFromGroup(String userId, UUID groupId) {
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?", userId, groupId);
}
@Override
public List<RoleSummary> getEffectiveRolesForUser(String userId) {
List<RoleSummary> direct = getDirectRolesForUser(userId);
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(userId);
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
for (RoleSummary r : direct) {
roleMap.put(r.id(), r);
}
for (GroupSummary group : effectiveGroups) {
List<RoleSummary> groupRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, rowNum) -> new RoleSummary(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getBoolean("system"),
group.name()
), group.id());
for (RoleSummary r : groupRoles) {
roleMap.putIfAbsent(r.id(), r);
}
}
return new ArrayList<>(roleMap.values());
}
@Override
public List<GroupSummary> getEffectiveGroupsForUser(String userId) {
List<GroupSummary> directGroups = getDirectGroupsForUser(userId);
Set<UUID> visited = new LinkedHashSet<>();
List<GroupSummary> all = new ArrayList<>();
for (GroupSummary g : directGroups) {
collectAncestors(g.id(), visited, all);
}
return all;
}
private void collectAncestors(UUID groupId, Set<UUID> visited, List<GroupSummary> result) {
if (!visited.add(groupId)) return;
var rows = jdbc.query("SELECT id, name, parent_group_id FROM groups WHERE id = ?",
(rs, rowNum) -> new Object[]{
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
rs.getObject("parent_group_id", UUID.class)
}, groupId);
if (rows.isEmpty()) return;
result.add((GroupSummary) rows.get(0)[0]);
UUID parentId = (UUID) rows.get(0)[1];
if (parentId != null) {
collectAncestors(parentId, visited, result);
}
}
@Override
public List<RoleSummary> getEffectiveRolesForGroup(UUID groupId) {
List<RoleSummary> direct = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), groupId);
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
for (RoleSummary r : direct) roleMap.put(r.id(), r);
List<GroupSummary> ancestors = groupRepository.findAncestorChain(groupId);
for (GroupSummary ancestor : ancestors) {
if (ancestor.id().equals(groupId)) continue;
List<RoleSummary> parentRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"),
ancestor.name()), ancestor.id());
for (RoleSummary r : parentRoles) roleMap.putIfAbsent(r.id(), r);
}
return new ArrayList<>(roleMap.values());
}
@Override
public List<UserSummary> getEffectivePrincipalsForRole(UUID roleId) {
Set<String> seen = new LinkedHashSet<>();
List<UserSummary> result = new ArrayList<>();
List<UserSummary> direct = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), roleId);
for (UserSummary u : direct) {
if (seen.add(u.userId())) result.add(u);
}
List<UUID> groupsWithRole = jdbc.query(
"SELECT group_id FROM group_roles WHERE role_id = ?",
(rs, rowNum) -> rs.getObject("group_id", UUID.class), roleId);
Set<UUID> allGroups = new LinkedHashSet<>(groupsWithRole);
for (UUID gid : groupsWithRole) {
collectDescendants(gid, allGroups);
}
for (UUID gid : allGroups) {
List<UserSummary> members = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), gid);
for (UserSummary u : members) {
if (seen.add(u.userId())) result.add(u);
}
}
return result;
}
private void collectDescendants(UUID groupId, Set<UUID> result) {
List<UUID> children = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id = ?",
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
for (UUID child : children) {
if (result.add(child)) {
collectDescendants(child, result);
}
}
}
@Override
public List<String> getSystemRoleNames(String userId) {
return getEffectiveRolesForUser(userId).stream()
.filter(RoleSummary::system)
.map(RoleSummary::name)
.toList();
}
@Override
public RbacStats getStats() {
int userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
int activeUserCount = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM user_roles", Integer.class);
int groupCount = jdbc.queryForObject("SELECT COUNT(*) FROM groups", Integer.class);
int roleCount = jdbc.queryForObject("SELECT COUNT(*) FROM roles", Integer.class);
int maxDepth = computeMaxGroupDepth();
return new RbacStats(userCount, activeUserCount, groupCount, maxDepth, roleCount);
}
private int computeMaxGroupDepth() {
List<UUID> roots = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id IS NULL",
(rs, rowNum) -> rs.getObject("id", UUID.class));
int max = 0;
for (UUID root : roots) {
max = Math.max(max, measureDepth(root, 1));
}
return max;
}
private int measureDepth(UUID groupId, int currentDepth) {
List<UUID> children = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id = ?",
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
if (children.isEmpty()) return currentDepth;
int max = currentDepth;
for (UUID child : children) {
max = Math.max(max, measureDepth(child, currentDepth + 1));
}
return max;
}
private List<RoleSummary> getDirectRolesForUser(String userId) {
return jdbc.query("""
SELECT r.id, r.name, r.system FROM user_roles ur
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), userId);
}
private List<GroupSummary> getDirectGroupsForUser(String userId) {
return jdbc.query("""
SELECT g.id, g.name FROM user_groups ug
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ?
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
rs.getString("name")), userId);
}
}

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

View File

@@ -0,0 +1,113 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.rbac.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
public class PostgresGroupRepository implements GroupRepository {
private final JdbcTemplate jdbc;
public PostgresGroupRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public List<GroupSummary> findAll() {
return jdbc.query("SELECT id, name FROM groups ORDER BY name",
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")));
}
@Override
public Optional<GroupDetail> findById(UUID id) {
var rows = jdbc.query(
"SELECT id, name, parent_group_id, created_at FROM groups WHERE id = ?",
(rs, rowNum) -> new GroupDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getObject("parent_group_id", UUID.class),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of(), List.of()
), id);
if (rows.isEmpty()) return Optional.empty();
var g = rows.get(0);
List<RoleSummary> directRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), id);
List<UserSummary> members = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), id);
List<GroupSummary> children = findChildGroups(id);
return Optional.of(new GroupDetail(g.id(), g.name(), g.parentGroupId(),
g.createdAt(), directRoles, List.of(), members, children));
}
@Override
public UUID create(String name, UUID parentGroupId) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO groups (id, name, parent_group_id) VALUES (?, ?, ?)",
id, name, parentGroupId);
return id;
}
@Override
public void update(UUID id, String name, UUID parentGroupId) {
jdbc.update("UPDATE groups SET name = COALESCE(?, name), parent_group_id = ? WHERE id = ?",
name, parentGroupId, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM groups WHERE id = ?", id);
}
@Override
public void addRole(UUID groupId, UUID roleId) {
jdbc.update("INSERT INTO group_roles (group_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
groupId, roleId);
}
@Override
public void removeRole(UUID groupId, UUID roleId) {
jdbc.update("DELETE FROM group_roles WHERE group_id = ? AND role_id = ?", groupId, roleId);
}
@Override
public List<GroupSummary> findChildGroups(UUID parentId) {
return jdbc.query("SELECT id, name FROM groups WHERE parent_group_id = ? ORDER BY name",
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
parentId);
}
@Override
public List<GroupSummary> findAncestorChain(UUID groupId) {
List<GroupSummary> chain = new ArrayList<>();
UUID current = groupId;
Set<UUID> visited = new HashSet<>();
while (current != null && visited.add(current)) {
UUID id = current;
var rows = jdbc.query(
"SELECT id, name, parent_group_id FROM groups WHERE id = ?",
(rs, rowNum) -> new Object[]{
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
rs.getObject("parent_group_id", UUID.class)
}, id);
if (rows.isEmpty()) break;
chain.add((GroupSummary) rows.get(0)[0]);
current = (UUID) rows.get(0)[1];
}
Collections.reverse(chain);
return chain;
}
}

View File

@@ -0,0 +1,85 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.rbac.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
public class PostgresRoleRepository implements RoleRepository {
private final JdbcTemplate jdbc;
public PostgresRoleRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public List<RoleDetail> findAll() {
return jdbc.query("""
SELECT id, name, description, scope, system, created_at FROM roles ORDER BY system DESC, name
""", (rs, rowNum) -> new RoleDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("description"),
rs.getString("scope"),
rs.getBoolean("system"),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of()
));
}
@Override
public Optional<RoleDetail> findById(UUID id) {
var rows = jdbc.query("""
SELECT id, name, description, scope, system, created_at FROM roles WHERE id = ?
""", (rs, rowNum) -> new RoleDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("description"),
rs.getString("scope"),
rs.getBoolean("system"),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of()
), id);
if (rows.isEmpty()) return Optional.empty();
var r = rows.get(0);
List<GroupSummary> assignedGroups = jdbc.query("""
SELECT g.id, g.name FROM group_roles gr
JOIN groups g ON g.id = gr.group_id WHERE gr.role_id = ?
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
rs.getString("name")), id);
List<UserSummary> directUsers = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), id);
return Optional.of(new RoleDetail(r.id(), r.name(), r.description(),
r.scope(), r.system(), r.createdAt(), assignedGroups, directUsers, List.of()));
}
@Override
public UUID create(String name, String description, String scope) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO roles (id, name, description, scope, system) VALUES (?, ?, ?, ?, false)",
id, name, description, scope);
return id;
}
@Override
public void update(UUID id, String name, String description, String scope) {
jdbc.update("""
UPDATE roles SET name = COALESCE(?, name), description = COALESCE(?, description),
scope = COALESCE(?, scope) WHERE id = ? AND system = false
""", name, description, scope, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM roles WHERE id = ? AND system = false", id);
}
}

View File

@@ -5,8 +5,6 @@ import com.cameleer3.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
@@ -22,35 +20,28 @@ public class PostgresUserRepository implements UserRepository {
@Override
public Optional<UserInfo> findById(String userId) {
var results = jdbc.query(
"SELECT * FROM users WHERE user_id = ?",
"SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
(rs, rowNum) -> mapUser(rs), userId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public List<UserInfo> findAll() {
return jdbc.query("SELECT * FROM users ORDER BY user_id",
return jdbc.query("SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
(rs, rowNum) -> mapUser(rs));
}
@Override
public void upsert(UserInfo user) {
jdbc.update("""
INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, now(), now())
INSERT INTO users (user_id, provider, email, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, now(), now())
ON CONFLICT (user_id) DO UPDATE SET
provider = EXCLUDED.provider, email = EXCLUDED.email,
display_name = EXCLUDED.display_name, roles = EXCLUDED.roles,
display_name = EXCLUDED.display_name,
updated_at = now()
""",
user.userId(), user.provider(), user.email(), user.displayName(),
user.roles().toArray(new String[0]));
}
@Override
public void updateRoles(String userId, List<String> roles) {
jdbc.update("UPDATE users SET roles = ?, updated_at = now() WHERE user_id = ?",
roles.toArray(new String[0]), userId);
user.userId(), user.provider(), user.email(), user.displayName());
}
@Override
@@ -59,13 +50,11 @@ public class PostgresUserRepository implements UserRepository {
}
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
Array rolesArray = rs.getArray("roles");
String[] roles = rolesArray != null ? (String[]) rolesArray.getArray() : new String[0];
java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
return new UserInfo(
rs.getString("user_id"), rs.getString("provider"),
rs.getString("email"), rs.getString("display_name"),
List.of(roles), createdAt);
createdAt);
}
}

View File

@@ -1,5 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
}

View File

@@ -0,0 +1,9 @@
package com.cameleer3.server.core.rbac;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record GroupDetail(UUID id, String name, UUID parentGroupId, Instant createdAt,
List<RoleSummary> directRoles, List<RoleSummary> effectiveRoles,
List<UserSummary> members, List<GroupSummary> childGroups) {}

View File

@@ -0,0 +1,17 @@
package com.cameleer3.server.core.rbac;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface GroupRepository {
List<GroupSummary> findAll();
Optional<GroupDetail> findById(UUID id);
UUID create(String name, UUID parentGroupId);
void update(UUID id, String name, UUID parentGroupId);
void delete(UUID id);
void addRole(UUID groupId, UUID roleId);
void removeRole(UUID groupId, UUID roleId);
List<GroupSummary> findChildGroups(UUID parentId);
List<GroupSummary> findAncestorChain(UUID groupId);
}

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.rbac;
import java.util.UUID;
public record GroupSummary(UUID id, String name) {}

View File

@@ -0,0 +1,19 @@
package com.cameleer3.server.core.rbac;
import java.util.List;
import java.util.UUID;
public interface RbacService {
List<UserDetail> listUsers();
UserDetail getUser(String userId);
void assignRoleToUser(String userId, UUID roleId);
void removeRoleFromUser(String userId, UUID roleId);
void addUserToGroup(String userId, UUID groupId);
void removeUserFromGroup(String userId, UUID groupId);
List<RoleSummary> getEffectiveRolesForUser(String userId);
List<GroupSummary> getEffectiveGroupsForUser(String userId);
List<RoleSummary> getEffectiveRolesForGroup(UUID groupId);
List<UserSummary> getEffectivePrincipalsForRole(UUID roleId);
List<String> getSystemRoleNames(String userId);
RbacStats getStats();
}

View File

@@ -0,0 +1,3 @@
package com.cameleer3.server.core.rbac;
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}

View File

@@ -0,0 +1,9 @@
package com.cameleer3.server.core.rbac;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record RoleDetail(UUID id, String name, String description, String scope, boolean system,
Instant createdAt, List<GroupSummary> assignedGroups, List<UserSummary> directUsers,
List<UserSummary> effectivePrincipals) {}

View File

@@ -0,0 +1,13 @@
package com.cameleer3.server.core.rbac;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface RoleRepository {
List<RoleDetail> findAll();
Optional<RoleDetail> findById(UUID id);
UUID create(String name, String description, String scope);
void update(UUID id, String name, String description, String scope);
void delete(UUID id);
}

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.rbac;
import java.util.UUID;
public record RoleSummary(UUID id, String name, boolean system, String source) {}

View File

@@ -0,0 +1,21 @@
package com.cameleer3.server.core.rbac;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
public final class SystemRole {
private SystemRole() {}
public static final UUID AGENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
public static final UUID VIEWER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
public static final UUID OPERATOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
public static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
public static final Set<UUID> IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID);
public static final Map<String, UUID> BY_NAME = Map.of(
"AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID);
public static boolean isSystem(UUID id) { return IDS.contains(id); }
}

View File

@@ -0,0 +1,8 @@
package com.cameleer3.server.core.rbac;
import java.time.Instant;
import java.util.List;
public record UserDetail(String userId, String provider, String email, String displayName,
Instant createdAt, List<RoleSummary> directRoles, List<GroupSummary> directGroups,
List<RoleSummary> effectiveRoles, List<GroupSummary> effectiveGroups) {}

View File

@@ -0,0 +1,3 @@
package com.cameleer3.server.core.rbac;
public record UserSummary(String userId, String displayName, String provider) {}

View File

@@ -1,7 +1,6 @@
package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List;
/**
* Represents a persisted user in the system.
@@ -10,7 +9,6 @@ import java.util.List;
* @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(
@@ -18,6 +16,5 @@ public record UserInfo(
String provider,
String email,
String displayName,
List<String> roles,
Instant createdAt
) {}

View File

@@ -14,7 +14,5 @@ public interface UserRepository {
void upsert(UserInfo user);
void updateRoles(String userId, List<String> roles);
void delete(String userId);
}