From eb0cc8c141a82bec6accf5faf4476a57ec09ed1d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:44:32 +0100 Subject: [PATCH] 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) --- .../AgentRegistrationController.java | 10 +- .../app/controller/UserAdminController.java | 23 +- .../server/app/rbac/RbacServiceImpl.java | 253 ++++++++++++++++++ .../app/security/OidcAuthController.java | 37 ++- .../server/app/security/UiAuthController.java | 18 +- .../app/storage/PostgresGroupRepository.java | 113 ++++++++ .../app/storage/PostgresRoleRepository.java | 85 ++++++ .../app/storage/PostgresUserRepository.java | 25 +- .../server/core/admin/AuditCategory.java | 2 +- .../server/core/rbac/GroupDetail.java | 9 + .../server/core/rbac/GroupRepository.java | 17 ++ .../server/core/rbac/GroupSummary.java | 5 + .../server/core/rbac/RbacService.java | 19 ++ .../cameleer3/server/core/rbac/RbacStats.java | 3 + .../server/core/rbac/RoleDetail.java | 9 + .../server/core/rbac/RoleRepository.java | 13 + .../server/core/rbac/RoleSummary.java | 5 + .../server/core/rbac/SystemRole.java | 21 ++ .../server/core/rbac/UserDetail.java | 8 + .../server/core/rbac/UserSummary.java | 3 + .../server/core/security/UserInfo.java | 3 - .../server/core/security/UserRepository.java | 2 - 22 files changed, 639 insertions(+), 44 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 6419d4e0..64aacfe4 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -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 roles = List.of("AGENT"); String accessToken = jwtService.createAccessToken(request.agentId(), group, roles); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index 2f837c86..1bdbc75a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -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 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(); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java new file mode 100644 index 00000000..ba99bf87 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java @@ -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 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 directRoles = getDirectRolesForUser(user.userId()); + List directGroups = getDirectGroupsForUser(user.userId()); + List effectiveRoles = getEffectiveRolesForUser(user.userId()); + List 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 getEffectiveRolesForUser(String userId) { + List direct = getDirectRolesForUser(userId); + + List effectiveGroups = getEffectiveGroupsForUser(userId); + Map roleMap = new LinkedHashMap<>(); + for (RoleSummary r : direct) { + roleMap.put(r.id(), r); + } + for (GroupSummary group : effectiveGroups) { + List 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 getEffectiveGroupsForUser(String userId) { + List directGroups = getDirectGroupsForUser(userId); + Set visited = new LinkedHashSet<>(); + List all = new ArrayList<>(); + for (GroupSummary g : directGroups) { + collectAncestors(g.id(), visited, all); + } + return all; + } + + private void collectAncestors(UUID groupId, Set visited, List 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 getEffectiveRolesForGroup(UUID groupId) { + List 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 roleMap = new LinkedHashMap<>(); + for (RoleSummary r : direct) roleMap.put(r.id(), r); + + List ancestors = groupRepository.findAncestorChain(groupId); + for (GroupSummary ancestor : ancestors) { + if (ancestor.id().equals(groupId)) continue; + List 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 getEffectivePrincipalsForRole(UUID roleId) { + Set seen = new LinkedHashSet<>(); + List result = new ArrayList<>(); + + List 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 groupsWithRole = jdbc.query( + "SELECT group_id FROM group_roles WHERE role_id = ?", + (rs, rowNum) -> rs.getObject("group_id", UUID.class), roleId); + + Set allGroups = new LinkedHashSet<>(groupsWithRole); + for (UUID gid : groupsWithRole) { + collectDescendants(gid, allGroups); + } + for (UUID gid : allGroups) { + List 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 result) { + List 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 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 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 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 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 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); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index db7e0a73..7d5f1e0d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -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. *

- * 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 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 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 resolveRoles(Optional existing, List oidcRoles, OidcConfig config) { - if (existing.isPresent() && !existing.get().roles().isEmpty()) { - return existing.get().roles(); + private void assignRolesForNewUser(String userId, List oidcRoles, OidcConfig config) { + List 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) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 6fd1805d..1ffcfa6d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -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 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 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); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java new file mode 100644 index 00000000..78e5cf21 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java @@ -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 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 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 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 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 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 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 findAncestorChain(UUID groupId) { + List chain = new ArrayList<>(); + UUID current = groupId; + Set 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; + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java new file mode 100644 index 00000000..f6c0eb9b --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java @@ -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 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 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 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 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); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java index 6985b2a3..64d77f5b 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java @@ -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 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 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 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); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java index 39854a79..221963f1 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java @@ -1,5 +1,5 @@ package com.cameleer3.server.core.admin; public enum AuditCategory { - INFRA, AUTH, USER_MGMT, CONFIG + INFRA, AUTH, USER_MGMT, CONFIG, RBAC } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java new file mode 100644 index 00000000..1a446ce4 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java @@ -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 directRoles, List effectiveRoles, + List members, List childGroups) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java new file mode 100644 index 00000000..afd94464 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java @@ -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 findAll(); + Optional 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 findChildGroups(UUID parentId); + List findAncestorChain(UUID groupId); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java new file mode 100644 index 00000000..36bc31c9 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java @@ -0,0 +1,5 @@ +package com.cameleer3.server.core.rbac; + +import java.util.UUID; + +public record GroupSummary(UUID id, String name) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java new file mode 100644 index 00000000..b01bf564 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java @@ -0,0 +1,19 @@ +package com.cameleer3.server.core.rbac; + +import java.util.List; +import java.util.UUID; + +public interface RbacService { + List 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 getEffectiveRolesForUser(String userId); + List getEffectiveGroupsForUser(String userId); + List getEffectiveRolesForGroup(UUID groupId); + List getEffectivePrincipalsForRole(UUID roleId); + List getSystemRoleNames(String userId); + RbacStats getStats(); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java new file mode 100644 index 00000000..463f3b4d --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java @@ -0,0 +1,3 @@ +package com.cameleer3.server.core.rbac; + +public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java new file mode 100644 index 00000000..6145870a --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java @@ -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 assignedGroups, List directUsers, + List effectivePrincipals) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java new file mode 100644 index 00000000..c5bfdbf6 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java @@ -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 findAll(); + Optional 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); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java new file mode 100644 index 00000000..6a332d83 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java @@ -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) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java new file mode 100644 index 00000000..ac439424 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java @@ -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 IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID); + + public static final Map 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); } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java new file mode 100644 index 00000000..5b1553dc --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java @@ -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 directRoles, List directGroups, + List effectiveRoles, List effectiveGroups) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java new file mode 100644 index 00000000..7ff00180 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java @@ -0,0 +1,3 @@ +package com.cameleer3.server.core.rbac; + +public record UserSummary(String userId, String displayName, String provider) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java index 2af614e9..2a3f3613 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java @@ -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:"}) * @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 roles, Instant createdAt ) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java index 70d7bb4b..b3b128fc 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java @@ -14,7 +14,5 @@ public interface UserRepository { void upsert(UserInfo user); - void updateRoles(String userId, List roles); - void delete(String userId); }