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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user