Files
Oxicloud/src/interfaces/api/handlers/subject_group_handler.rs
T

570 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateGroupRequest {
pub name: Option<String>,
/// Reserved for future use; v1 only persists the name in `rename`.
pub description: Option<String>,
}
#[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<String>,
}
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<Uuid>,
pub group_id: Option<Uuid>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GroupDto {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
/// 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<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
/// 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<GroupDto>,
pub total: u64,
}
#[derive(Debug, Serialize, ToSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum GroupMemberDto {
User { id: Uuid },
Group { id: Uuid },
}
impl From<GroupMember> 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<Arc<AppState>> {
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<crate::application::services::subject_group_service::SubjectGroupService>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<CreateGroupRequest>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<ListGroupsQuery>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<SearchGroupsQuery>,
) -> Result<impl IntoResponse, AppError> {
// 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::<Vec<_>>(),
))
}
/// 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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
Json(req): Json<UpdateGroupRequest>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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::<Vec<_>>(),
))
}
/// 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<Arc<AppState>>,
headers: HeaderMap,
Path(group_id): Path<Uuid>,
Json(req): Json<AddSubjectGroupMemberRequest>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path((group_id, uid)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path((group_id, gid)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, AppError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_admin(&state, &headers).await?;
let svc = service(&state)?;
let users = svc
.list_transitive_users(id)
.await
.map_err(AppError::from)?;
Ok(Json(users))
}