feat: add Group, Role, and RBAC stats admin controllers

GroupAdminController with cycle detection, RoleAdminController
with system role protection, RbacStatsController for dashboard.
Rewrite UserAdminController to use RbacService.
This commit is contained in:
hsiegeln
2026-03-17 17:47:26 +01:00
parent eb0cc8c141
commit 01295c84d8
4 changed files with 390 additions and 46 deletions

View File

@@ -0,0 +1,167 @@
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.GroupDetail;
import com.cameleer3.server.core.rbac.GroupRepository;
import com.cameleer3.server.core.rbac.GroupSummary;
import com.cameleer3.server.core.rbac.RbacService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Admin endpoints for group management.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/groups")
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class GroupAdminController {
private final GroupRepository groupRepository;
private final RbacService rbacService;
private final AuditService auditService;
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
AuditService auditService) {
this.groupRepository = groupRepository;
this.rbacService = rbacService;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all groups with hierarchy and effective roles")
@ApiResponse(responseCode = "200", description = "Group list returned")
public ResponseEntity<List<GroupDetail>> listGroups() {
List<GroupSummary> summaries = groupRepository.findAll();
List<GroupDetail> details = new ArrayList<>();
for (GroupSummary summary : summaries) {
groupRepository.findById(summary.id()).ifPresent(details::add);
}
return ResponseEntity.ok(details);
}
@GetMapping("/{id}")
@Operation(summary = "Get group by ID with effective roles")
@ApiResponse(responseCode = "200", description = "Group found")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
return groupRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create a new group")
@ApiResponse(responseCode = "200", description = "Group created")
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
HttpServletRequest httpRequest) {
UUID id = groupRepository.create(request.name(), request.parentGroupId());
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update group name or parent")
@ApiResponse(responseCode = "200", description = "Group updated")
@ApiResponse(responseCode = "404", description = "Group not found")
@ApiResponse(responseCode = "409", description = "Cycle detected in group hierarchy")
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
@RequestBody UpdateGroupRequest request,
HttpServletRequest httpRequest) {
Optional<GroupDetail> existing = groupRepository.findById(id);
if (existing.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Cycle detection: walk ancestor chain of proposed parent and check if it includes 'id'
if (request.parentGroupId() != null) {
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
for (GroupSummary ancestor : ancestors) {
if (ancestor.id().equals(id)) {
return ResponseEntity.status(409).build();
}
}
// Also check that the proposed parent itself is not the group being updated
if (request.parentGroupId().equals(id)) {
return ResponseEntity.status(409).build();
}
}
groupRepository.update(id, request.name(), request.parentGroupId());
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete group")
@ApiResponse(responseCode = "204", description = "Group deleted")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
groupRepository.delete(id);
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/roles/{roleId}")
@Operation(summary = "Assign a role to a group")
@ApiResponse(responseCode = "200", description = "Role assigned to group")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> assignRoleToGroup(@PathVariable UUID id,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
groupRepository.addRole(id, roleId);
auditService.log("assign_role_to_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}/roles/{roleId}")
@Operation(summary = "Remove a role from a group")
@ApiResponse(responseCode = "204", description = "Role removed from group")
@ApiResponse(responseCode = "404", description = "Group not found")
public ResponseEntity<Void> removeRoleFromGroup(@PathVariable UUID id,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
groupRepository.removeRole(id, roleId);
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateGroupRequest(String name, UUID parentGroupId) {}
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
}

View File

@@ -0,0 +1,36 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.RbacStats;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Admin endpoint for RBAC statistics.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/rbac")
@Tag(name = "RBAC Stats", description = "RBAC statistics (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class RbacStatsController {
private final RbacService rbacService;
public RbacStatsController(RbacService rbacService) {
this.rbacService = rbacService;
}
@GetMapping("/stats")
@Operation(summary = "Get RBAC statistics for the dashboard")
@ApiResponse(responseCode = "200", description = "RBAC stats returned")
public ResponseEntity<RbacStats> getStats() {
return ResponseEntity.ok(rbacService.getStats());
}
}

View File

@@ -0,0 +1,123 @@
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.RoleDetail;
import com.cameleer3.server.core.rbac.RoleRepository;
import com.cameleer3.server.core.rbac.SystemRole;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Admin endpoints for role management.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/roles")
@Tag(name = "Role Admin", description = "Role management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class RoleAdminController {
private final RoleRepository roleRepository;
private final RbacService rbacService;
private final AuditService auditService;
public RoleAdminController(RoleRepository roleRepository, RbacService rbacService,
AuditService auditService) {
this.roleRepository = roleRepository;
this.rbacService = rbacService;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all roles (system and custom)")
@ApiResponse(responseCode = "200", description = "Role list returned")
public ResponseEntity<List<RoleDetail>> listRoles() {
return ResponseEntity.ok(roleRepository.findAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get role by ID with effective principals")
@ApiResponse(responseCode = "200", description = "Role found")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<RoleDetail> getRole(@PathVariable UUID id) {
return roleRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create a custom role")
@ApiResponse(responseCode = "200", description = "Role created")
public ResponseEntity<Map<String, UUID>> createRole(@RequestBody CreateRoleRequest request,
HttpServletRequest httpRequest) {
UUID id = roleRepository.create(request.name(), request.description(), request.scope());
auditService.log("create_role", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update a custom role")
@ApiResponse(responseCode = "200", description = "Role updated")
@ApiResponse(responseCode = "403", description = "Cannot modify system role")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<Void> updateRole(@PathVariable UUID id,
@RequestBody UpdateRoleRequest request,
HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(403).build();
}
if (roleRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
roleRepository.update(id, request.name(), request.description(), request.scope());
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a custom role")
@ApiResponse(responseCode = "204", description = "Role deleted")
@ApiResponse(responseCode = "403", description = "Cannot delete system role")
@ApiResponse(responseCode = "404", description = "Role not found")
public ResponseEntity<Void> deleteRole(@PathVariable UUID id,
HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(403).build();
}
if (roleRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
roleRepository.delete(id);
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateRoleRequest(String name, String description, String scope) {}
public record UpdateRoleRequest(String name, String description, String scope) {}
}

View File

@@ -4,20 +4,18 @@ 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.rbac.UserDetail;
import com.cameleer3.server.core.security.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -27,7 +25,7 @@ import java.util.UUID;
/**
* Admin endpoints for user management.
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns.
* Protected by {@code ROLE_ADMIN}.
*/
@RestController
@RequestMapping("/api/v1/admin/users")
@@ -35,63 +33,85 @@ import java.util.UUID;
@PreAuthorize("hasRole('ADMIN')")
public class UserAdminController {
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
private final RbacService rbacService;
public UserAdminController(UserRepository userRepository, AuditService auditService,
RbacService rbacService) {
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
@GetMapping
@Operation(summary = "List all users")
@Operation(summary = "List all users with RBAC detail")
@ApiResponse(responseCode = "200", description = "User list returned")
public ResponseEntity<List<UserInfo>> listUsers() {
return ResponseEntity.ok(userRepository.findAll());
public ResponseEntity<List<UserDetail>> listUsers() {
return ResponseEntity.ok(rbacService.listUsers());
}
@GetMapping("/{userId}")
@Operation(summary = "Get user by ID")
@Operation(summary = "Get user by ID with RBAC detail")
@ApiResponse(responseCode = "200", description = "User found")
@ApiResponse(responseCode = "404", description = "User not found")
public ResponseEntity<UserInfo> getUser(@PathVariable String userId) {
return userRepository.findById(userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{userId}/roles")
@Operation(summary = "Update user roles")
@ApiResponse(responseCode = "200", description = "Roles updated")
@ApiResponse(responseCode = "404", description = "User not found")
public ResponseEntity<Void> updateRoles(@PathVariable String userId,
@RequestBody RolesRequest request,
HttpServletRequest httpRequest) {
if (userRepository.findById(userId).isEmpty()) {
public ResponseEntity<UserDetail> getUser(@PathVariable String userId) {
UserDetail detail = rbacService.getUser(userId);
if (detail == null) {
return ResponseEntity.notFound().build();
}
// 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(detail);
}
@PostMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Assign a role to a user")
@ApiResponse(responseCode = "200", description = "Role assigned")
@ApiResponse(responseCode = "404", description = "User or role not found")
public ResponseEntity<Void> assignRoleToUser(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.assignRoleToUser(userId, roleId);
auditService.log("assign_role_to_user", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Remove a role from a user")
@ApiResponse(responseCode = "204", description = "Role removed")
public ResponseEntity<Void> removeRoleFromUser(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.removeRoleFromUser(userId, roleId);
auditService.log("remove_role_from_user", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Add a user to a group")
@ApiResponse(responseCode = "200", description = "User added to group")
public ResponseEntity<Void> addUserToGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.addUserToGroup(userId, groupId);
auditService.log("add_user_to_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Remove a user from a group")
@ApiResponse(responseCode = "204", description = "User removed from group")
public ResponseEntity<Void> removeUserFromGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.removeUserFromGroup(userId, groupId);
auditService.log("remove_user_from_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{userId}")
@Operation(summary = "Delete user")
@ApiResponse(responseCode = "204", description = "User deleted")
@@ -102,6 +122,4 @@ public class UserAdminController {
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record RolesRequest(List<String> roles) {}
}