feat(group): 1st implementation of Groups

this implements first version (manageable only by admin right now)

    routes:

        GET /api/groups
        List subject groups (paginated). Admin-only.

        POST /api/groups
        Create a new ReBAC subject group. Admin-only. The name must match the RFC 5321 local-part shape and be globally unique (case-insensitive).

        GET /api/groups/search
        Search non-virtual groups by name substring. Authenticated only (no admin role required) — backs the share-dialog recipient autocomplete.

        GET /api/groups/{id}
        Fetch a single group's details. Admin-only.

        DELETE /api/groups/{id}
        Delete a group. Cascades to `subject_group_members` (FK) and to `access_grants` rows referencing this group as a subject. Admin-only.

        PATCH /api/groups/{id}
        Update a group's metadata. Admin-only. v1 only persists name renames.

        GET /api/groups/{id}/effective-members
        List every user transitively reached through this group (members of members of members, etc.). Used by admin / audit tooling. Admin-only.

        GET /api/groups/{id}/members
        List the *direct* members of a group (one level only). Admin-only.

        POST /api/groups/{id}/members
        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.

        DELETE /api/groups/{id}/members/group/{gid}
        Remove a nested group-member from a group. Admin-only.

        DELETE /api/groups/{id}/members/user/{uid}
        Remove a user-member from a group. Admin-only.

fix hurl

groups

round

groups
This commit is contained in:
Edouard Vanbelle
2026-05-30 23:35:47 +02:00
parent 41356b6490
commit 09985f8a95
54 changed files with 6421 additions and 145 deletions
+7 -37
View File
@@ -1,7 +1,7 @@
use axum::{
Router,
extract::{Json, Path, Query, State},
http::{HeaderMap, StatusCode, header},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{delete, get, post, put},
};
@@ -12,9 +12,9 @@ use crate::application::dtos::settings_dto::{
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto, VerifyMigrationDto,
};
use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
use std::sync::Arc;
use uuid::Uuid;
@@ -61,42 +61,12 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
}
/// Validate JWT and require admin role. Returns (user_id, role).
///
/// Thin wrapper over the shared `require_admin` middleware helper so this
/// handler keeps a stable signature while the implementation lives next to
/// the new `subject_group_handler` that also needs it.
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string()))
.or_else(|| {
crate::interfaces::api::cookie_auth::extract_cookie_value(
headers,
crate::interfaces::api::cookie_auth::ACCESS_COOKIE,
)
})
.ok_or_else(|| AppError::unauthorized("Authorization token required"))?;
let claims = auth
.token_service
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
if claims.role != "admin" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
));
}
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role,
))
require_admin(state, headers).await
}
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
+1
View File
@@ -18,6 +18,7 @@ pub mod photos_handler;
pub mod recent_handler;
pub mod search_handler;
pub mod share_handler;
pub mod subject_group_handler;
pub mod trash_handler;
pub mod webdav_handler;
pub mod wopi_handler;
@@ -0,0 +1,550 @@
//! 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 user can discover groups for the share dialog —
// membership lists remain admin-only via list_members.
let (_caller_id, role) = require_authenticated(&state, &headers).await?;
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))
}
+20
View File
@@ -230,6 +230,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::grant_handler::list_shared_with_me,
handlers::grant_handler::list_outgoing,
handlers::grant_handler::list_on_resource,
// Subject-group handlers (ReBAC named groups) — free functions
handlers::subject_group_handler::create_group,
handlers::subject_group_handler::list_groups,
handlers::subject_group_handler::search_groups,
handlers::subject_group_handler::get_group,
handlers::subject_group_handler::update_group,
handlers::subject_group_handler::delete_group,
handlers::subject_group_handler::list_members,
handlers::subject_group_handler::add_member,
handlers::subject_group_handler::remove_user_member,
handlers::subject_group_handler::remove_group_member,
handlers::subject_group_handler::list_effective_members,
),
components(
schemas(
@@ -319,6 +331,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
GrantDto,
SharedWithMeDto,
SharedWithMeItemDto,
// Subject-group (ReBAC named groups) schemas
handlers::subject_group_handler::CreateGroupRequest,
handlers::subject_group_handler::UpdateGroupRequest,
handlers::subject_group_handler::AddSubjectGroupMemberRequest,
handlers::subject_group_handler::GroupDto,
handlers::subject_group_handler::GroupListDto,
handlers::subject_group_handler::GroupMemberDto,
)
),
tags(
@@ -339,6 +358,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
(name = "admin", description = "Admin management endpoints"),
(name = "grants", description = "ReBAC grant management endpoints"),
(name = "groups", description = "ReBAC subject-group management endpoints (named, nestable, root-owned)"),
),
info(
title = "OxiCloud API",
+8
View File
@@ -563,6 +563,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
router = router.nest("/admin", admin_router);
// ReBAC subject-group management. All mutating routes are admin-gated;
// /api/groups/search is authenticated-only so the share dialog can list
// groups as recipients.
let group_router =
crate::interfaces::api::handlers::subject_group_handler::subject_group_routes()
.with_state(app_state.clone());
router = router.nest("/groups", group_router);
// Transparent compression (gzip + brotli) for all API responses.
// tower-http negotiates via Accept-Encoding and skips already-compressed
// content types automatically. No manual compression in handlers.