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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.cameleer3.server.core.admin;
|
||||
|
||||
public enum AuditCategory {
|
||||
INFRA, AUTH, USER_MGMT, CONFIG
|
||||
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record GroupSummary(UUID id, String name) {}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}
|
||||
@@ -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) {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
public record UserSummary(String userId, String displayName, String provider) {}
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -14,7 +14,5 @@ public interface UserRepository {
|
||||
|
||||
void upsert(UserInfo user);
|
||||
|
||||
void updateRoles(String userId, List<String> roles);
|
||||
|
||||
void delete(String userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user