//! REST handler for ReBAC subject groups (`/api/groups/...`). //! //! All mutating endpoints require admin role (see //! `crate::interfaces::middleware::admin::require_admin`). The read-only //! `/api/groups/search` endpoint requires only authentication so the share //! dialog can offer groups as recipients. use std::sync::Arc; use axum::{ Router, extract::{Json, Path, Query, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{delete, get, patch, post}, }; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::common::di::AppState; use crate::domain::entities::subject_group::{GroupMember, SubjectGroup}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::{require_admin, require_authenticated}; // ── DTOs ───────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, ToSchema)] pub struct CreateGroupRequest { /// RFC 5321 local-part shape (starts alnum, then alnum/dot/dash/underscore; 1–64 chars). pub name: String, pub description: Option, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateGroupRequest { pub name: Option, /// Reserved for future use; v1 only persists the name in `rename`. pub description: Option, } #[derive(Debug, Deserialize, IntoParams)] pub struct ListGroupsQuery { #[serde(default = "default_limit")] pub limit: u32, #[serde(default)] pub offset: u32, /// Optional case-insensitive substring filter on group name. pub q: Option, } fn default_limit() -> u32 { 50 } #[derive(Debug, Deserialize, IntoParams)] pub struct SearchGroupsQuery { pub q: String, #[serde(default = "default_search_limit")] pub limit: u32, } fn default_search_limit() -> u32 { 20 } #[derive(Debug, Deserialize, ToSchema)] pub struct AddSubjectGroupMemberRequest { /// Set exactly one of `user_id` or `group_id` — the other field must be /// absent or null. Adding a `group_id` triggers a write-time cycle and /// depth check; max nesting depth is 8. pub user_id: Option, pub group_id: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct GroupDto { pub id: Uuid, pub name: String, pub description: Option, /// True for system-managed groups (e.g. `Internal`). Membership and /// metadata on virtual groups are immutable. pub is_virtual: bool, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, /// True when the caller may rename, delete, or change the membership /// of this group. v1 computes this as `caller.role == "admin"`. v2 /// will compute it from per-group `Manage` grants on /// `Resource::SubjectGroup(id)` once that resource type lands in /// `access_grants`. Frontend reads this unconditionally so the v2 /// migration is backend-only. pub can_manage: bool, /// Direct-member count (users + nested groups, one level only). The /// management UI shows this as a chip on each list row; for transitive /// expansion size, see the `/effective-members` endpoint. pub member_count: i64, } impl GroupDto { /// Build the DTO for a given caller. `can_manage` is decided per call: /// v1 just delegates to the admin flag; v2 will consult the /// authorization engine here. `member_count` comes from the same query /// that fetched the group (list/search) or a dedicated `COUNT(*)` /// helper (create/get/update). pub fn from_group(g: SubjectGroup, can_manage: bool, member_count: i64) -> Self { Self { id: g.id, name: g.name, description: g.description, is_virtual: g.is_virtual, created_at: g.created_at, updated_at: g.updated_at, can_manage, member_count, } } } #[derive(Debug, Serialize, ToSchema)] pub struct GroupListDto { pub items: Vec, pub total: u64, } #[derive(Debug, Serialize, ToSchema)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum GroupMemberDto { User { id: Uuid }, Group { id: Uuid }, } impl From for GroupMemberDto { fn from(m: GroupMember) -> Self { match m { GroupMember::User(id) => GroupMemberDto::User { id }, GroupMember::Group(id) => GroupMemberDto::Group { id }, } } } // ── Routes ─────────────────────────────────────────────────────────────────── /// Routes mounted under `/api/groups`. pub fn subject_group_routes() -> Router> { Router::new() .route("/", post(create_group)) .route("/", get(list_groups)) .route("/search", get(search_groups)) .route("/{id}", get(get_group)) .route("/{id}", patch(update_group)) .route("/{id}", delete(delete_group)) .route("/{id}/members", get(list_members)) .route("/{id}/members", post(add_member)) .route("/{id}/members/user/{uid}", delete(remove_user_member)) .route("/{id}/members/group/{gid}", delete(remove_group_member)) .route("/{id}/effective-members", get(list_effective_members)) } fn service( state: &AppState, ) -> Result<&Arc, AppError> { state .subject_group_service .as_ref() .ok_or_else(|| AppError::internal_error("Subject-group service not configured")) } // ── Handlers ───────────────────────────────────────────────────────────────── /// Create a new ReBAC subject group. Admin-only. The name must match the /// RFC 5321 local-part shape and be globally unique (case-insensitive). #[utoipa::path( post, path = "/api/groups", request_body = CreateGroupRequest, responses( (status = 201, description = "Group created", body = GroupDto), (status = 400, description = "Invalid name (RFC 5321 violation)"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 409, description = "Group with this name already exists"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_create" )] pub async fn create_group( State(state): State>, headers: HeaderMap, Json(req): Json, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; let group = svc .create(&req.name, req.description, caller_id) .await .map_err(AppError::from)?; // Reached require_admin → caller is admin → can_manage is always true here. // New groups start with zero direct members. Ok(( StatusCode::CREATED, Json(GroupDto::from_group(group, true, 0)), )) } /// List subject groups (paginated). Admin-only. #[utoipa::path( get, path = "/api/groups", params(ListGroupsQuery), responses( (status = 200, description = "Paginated list of groups", body = GroupListDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_list" )] pub async fn list_groups( State(state): State>, headers: HeaderMap, Query(q): Query, ) -> Result { require_admin(&state, &headers).await?; let svc = service(&state)?; let (items, total) = svc .list_with_counts(q.limit, q.offset, q.q.as_deref()) .await .map_err(AppError::from)?; // Admin-gated handler → every row is manageable by the caller. Ok(Json(GroupListDto { items: items .into_iter() .map(|(g, member_count)| GroupDto::from_group(g, true, member_count)) .collect(), total, })) } /// Search non-virtual groups by name substring. Authenticated only (no /// admin role required) — backs the share-dialog recipient autocomplete. #[utoipa::path( get, path = "/api/groups/search", params(SearchGroupsQuery), responses( (status = 200, description = "Matching non-virtual groups", body = [GroupDto]), (status = 401, description = "Unauthorized"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_search" )] pub async fn search_groups( State(state): State>, headers: HeaderMap, Query(q): Query, ) -> Result { // Any authenticated INTERNAL user can discover groups for the // share dialog — membership lists remain admin-only via // list_members. External users have no business enumerating // groups; defence-in-depth on top of the ReBAC layer (which // already prevents them from being added to any group anyway). let (caller_id, role) = require_authenticated(&state, &headers).await?; if let Some(auth_svc) = state.auth_service.as_ref() && let Err(err) = crate::interfaces::middleware::user::require_internal_user( &auth_svc.auth_application_service, caller_id, ) .await { tracing::info!( target: "audit", event = "groups.search_rejected", reason = "external_user", caller_id = %caller_id, "👮🏻‍♂️ External user blocked from /api/groups/search" ); return Err(err); } let can_manage = role == "admin"; let svc = service(&state)?; // The share-dialog autocomplete doesn't render a member-count chip, so // emit 0 rather than spending a `COUNT(*)` per row. Frontend consumers // that need the real count fetch it via `/api/groups/{id}` instead. let items = svc .search_for_share(&q.q, q.limit) .await .map_err(AppError::from)?; Ok(Json( items .into_iter() .map(|g| GroupDto::from_group(g, can_manage, 0)) .collect::>(), )) } /// Fetch a single group's details. Admin-only. #[utoipa::path( get, path = "/api/groups/{id}", params(("id" = Uuid, Path, description = "Group ID")), responses( (status = 200, description = "Group details", body = GroupDto), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_get" )] pub async fn get_group( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { require_admin(&state, &headers).await?; let svc = service(&state)?; let group = svc.get_by_id(id).await.map_err(AppError::from)?; let member_count = svc.count_members(id).await.map_err(AppError::from)?; Ok(Json(GroupDto::from_group(group, true, member_count))) } /// Update a group's metadata. Admin-only. v1 only persists name renames. #[utoipa::path( patch, path = "/api/groups/{id}", params(("id" = Uuid, Path, description = "Group ID")), request_body = UpdateGroupRequest, responses( (status = 200, description = "Updated group", body = GroupDto), (status = 400, description = "Invalid name"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required, or group is virtual"), (status = 404, description = "Group not found"), (status = 409, description = "Name already taken"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_update" )] pub async fn update_group( State(state): State>, headers: HeaderMap, Path(id): Path, Json(req): Json, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; // v1 only supports renaming; description-only updates are silently // accepted as a no-op so the API surface is forward-compatible. let group = match req.name { Some(new_name) => svc .rename(id, &new_name, caller_id) .await .map_err(AppError::from)?, None => svc.get_by_id(id).await.map_err(AppError::from)?, }; let member_count = svc.count_members(id).await.map_err(AppError::from)?; Ok(Json(GroupDto::from_group(group, true, member_count))) } /// Delete a group. Cascades to `subject_group_members` (FK) and to /// `access_grants` rows referencing this group as a subject. Admin-only. #[utoipa::path( delete, path = "/api/groups/{id}", params(("id" = Uuid, Path, description = "Group ID")), responses( (status = 204, description = "Group deleted"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required, or group is virtual"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_delete" )] pub async fn delete_group( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; svc.delete(id, caller_id).await.map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) } /// List the *direct* members of a group (one level only). Admin-only. #[utoipa::path( get, path = "/api/groups/{id}/members", params(("id" = Uuid, Path, description = "Group ID")), responses( (status = 200, description = "Direct members", body = [GroupMemberDto]), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_list_members" )] pub async fn list_members( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { require_admin(&state, &headers).await?; let svc = service(&state)?; let members = svc.list_direct_members(id).await.map_err(AppError::from)?; Ok(Json( members .into_iter() .map(GroupMemberDto::from) .collect::>(), )) } /// Add a member to a group. Exactly one of `user_id` / `group_id` must be /// provided. Adding a group-member runs a write-time cycle check and a /// nesting-depth check (max 8). Admin-only. #[utoipa::path( post, path = "/api/groups/{id}/members", params(("id" = Uuid, Path, description = "Group ID")), request_body = AddSubjectGroupMemberRequest, responses( (status = 201, description = "Member added"), (status = 400, description = "Invalid request, cycle would be created, or depth limit exceeded"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required, or virtual group"), (status = 404, description = "Group not found"), (status = 409, description = "Member already in group"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_add_member" )] pub async fn add_member( State(state): State>, headers: HeaderMap, Path(group_id): Path, Json(req): Json, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; let member = match (req.user_id, req.group_id) { (Some(uid), None) => GroupMember::User(uid), (None, Some(gid)) => GroupMember::Group(gid), (Some(_), Some(_)) => { return Err(AppError::new( StatusCode::BAD_REQUEST, "Provide exactly one of user_id or group_id, not both", "InvalidInput", )); } (None, None) => { return Err(AppError::new( StatusCode::BAD_REQUEST, "Provide user_id or group_id", "InvalidInput", )); } }; svc.add_member(group_id, member, caller_id) .await .map_err(AppError::from)?; Ok(StatusCode::CREATED) } /// Remove a user-member from a group. Admin-only. #[utoipa::path( delete, path = "/api/groups/{id}/members/user/{uid}", params( ("id" = Uuid, Path, description = "Group ID"), ("uid" = Uuid, Path, description = "User ID to remove"), ), responses( (status = 204, description = "Member removed"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required, or virtual group"), (status = 404, description = "Group or member not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_remove_user_member" )] pub async fn remove_user_member( State(state): State>, headers: HeaderMap, Path((group_id, uid)): Path<(Uuid, Uuid)>, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; svc.remove_member(group_id, GroupMember::User(uid), caller_id) .await .map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) } /// Remove a nested group-member from a group. Admin-only. #[utoipa::path( delete, path = "/api/groups/{id}/members/group/{gid}", params( ("id" = Uuid, Path, description = "Parent group ID"), ("gid" = Uuid, Path, description = "Child group ID to remove"), ), responses( (status = 204, description = "Member removed"), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required, or virtual group"), (status = 404, description = "Group or member not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_remove_group_member" )] pub async fn remove_group_member( State(state): State>, headers: HeaderMap, Path((group_id, gid)): Path<(Uuid, Uuid)>, ) -> Result { let (caller_id, _) = require_admin(&state, &headers).await?; let svc = service(&state)?; svc.remove_member(group_id, GroupMember::Group(gid), caller_id) .await .map_err(AppError::from)?; Ok(StatusCode::NO_CONTENT) } /// List every user transitively reached through this group (members of /// members of members, etc.). Used by admin / audit tooling. Admin-only. #[utoipa::path( get, path = "/api/groups/{id}/effective-members", params(("id" = Uuid, Path, description = "Group ID")), responses( (status = 200, description = "Flat list of transitively-reached user IDs", body = [Uuid]), (status = 401, description = "Unauthorized"), (status = 403, description = "Admin required"), (status = 404, description = "Group not found"), ), security(("bearerAuth" = [])), tag = "groups", operation_id = "subject_group_effective_members" )] pub async fn list_effective_members( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> Result { require_admin(&state, &headers).await?; let svc = service(&state)?; let users = svc .list_transitive_users(id) .await .map_err(AppError::from)?; Ok(Json(users)) }