feat: replace flat users.roles with relational RBAC model

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List;
/**
* Represents a persisted user in the system.
@@ -10,7 +9,6 @@ import java.util.List;
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
* @param email user email (may be empty)
* @param displayName display name (may be empty)
* @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]})
* @param createdAt first creation timestamp
*/
public record UserInfo(
@@ -18,6 +16,5 @@ public record UserInfo(
String provider,
String email,
String displayName,
List<String> roles,
Instant createdAt
) {}

View File

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