From 01295c84d8f1fcfa5a5bcec40ff759bbe6810d82 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:47:26 +0100 Subject: [PATCH] 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. --- .../app/controller/GroupAdminController.java | 167 ++++++++++++++++++ .../app/controller/RbacStatsController.java | 36 ++++ .../app/controller/RoleAdminController.java | 123 +++++++++++++ .../app/controller/UserAdminController.java | 110 +++++++----- 4 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java new file mode 100644 index 00000000..3def54ba --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java @@ -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> listGroups() { + List summaries = groupRepository.findAll(); + List 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 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> 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 updateGroup(@PathVariable UUID id, + @RequestBody UpdateGroupRequest request, + HttpServletRequest httpRequest) { + Optional 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 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 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 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 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) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java new file mode 100644 index 00000000..cd325d15 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java @@ -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 getStats() { + return ResponseEntity.ok(rbacService.getStats()); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java new file mode 100644 index 00000000..612ca04b --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java @@ -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> 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 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> 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 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 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) {} +} 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 1bdbc75a..45e00b48 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 @@ -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> listUsers() { - return ResponseEntity.ok(userRepository.findAll()); + public ResponseEntity> 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 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 updateRoles(@PathVariable String userId, - @RequestBody RolesRequest request, - HttpServletRequest httpRequest) { - if (userRepository.findById(userId).isEmpty()) { + public ResponseEntity 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 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 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 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 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 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 roles) {} }