feat(drive): UI: add drive edition for admin

This commit is contained in:
Edouard Vanbelle
2026-06-24 01:20:44 +02:00
parent a5b24a7453
commit d77846119f
15 changed files with 1448 additions and 45 deletions
+1
View File
@@ -100,3 +100,4 @@ impl From<DriveWithRootName> for DriveDto {
}
}
}
@@ -173,54 +173,105 @@ impl DriveManagementService {
///
/// `set_role` is idempotent — `(subject, resource)` is unique — so the
/// two HTTP shapes share one service method. Returns the resulting grant.
///
/// `caller_is_admin = true` skips the per-drive `Manage` check
/// (used by `/api/admin/drives/{id}/members` so an admin who
/// created the drive for someone else can still edit owners).
/// Personal-drive guard and last-owner protection still apply —
/// admin bypass is about *access*, not invariants. The audit log
/// flags admin-driven changes via `via_admin = true` so a reader
/// can tell what fired the mutation. The caller (HTTP handler) is
/// authoritative for `caller_is_admin`; the route gate is the
/// source of truth and the service trusts the flag.
pub async fn set_member_role(
&self,
caller_id: Uuid,
caller_is_admin: bool,
drive_id: Uuid,
subject: Subject,
role: Role,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError> {
let resource = Resource::Drive(drive_id);
self.authz
.require(Subject::User(caller_id), Permission::Manage, resource)
.await?;
if !caller_is_admin {
self.authz
.require(Subject::User(caller_id), Permission::Manage, resource)
.await?;
}
self.refuse_if_personal(drive_id, "set_member_role").await?;
// Demotion of the last owner = last-owner protection trips. A fresh
// owner-role write or any non-owner subject is fine; only the case
// "this subject is currently the only owner AND the new role is not
// owner" is refused.
// owner" is refused. Applies to admin-bypass too: orphaning a
// shared drive is the same category error regardless of who fires
// the request.
if !matches!(role, Role::Owner) {
self.refuse_if_last_owner_change(drive_id, subject, caller_id)
.await?;
}
self.authz
let grant = self
.authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
.await?;
if caller_is_admin {
tracing::info!(
target: "audit",
event = "drive_membership.set_via_admin",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
role = role.as_str(),
by = %caller_id,
"👮🏻‍♂️ admin set drive member role bypassing Manage check",
);
}
Ok(grant)
}
/// `DELETE /api/drives/{id}/members/{subject_id}`. Idempotent — removing
/// a subject with no current grant succeeds (matches `clear_role`).
///
/// `caller_is_admin` mirrors `set_member_role`: skips the per-drive
/// `Manage` check but keeps personal-drive guard + last-owner
/// protection. Audit emits `drive_membership.removed_via_admin`
/// when the bypass fires.
pub async fn remove_member(
&self,
caller_id: Uuid,
caller_is_admin: bool,
drive_id: Uuid,
subject: Subject,
) -> Result<(), DomainError> {
let resource = Resource::Drive(drive_id);
self.authz
.require(Subject::User(caller_id), Permission::Manage, resource)
.await?;
if !caller_is_admin {
self.authz
.require(Subject::User(caller_id), Permission::Manage, resource)
.await?;
}
self.refuse_if_personal(drive_id, "remove_member").await?;
self.refuse_if_last_owner_change(drive_id, subject, caller_id)
.await?;
self.authz.clear_role(subject, resource).await
self.authz.clear_role(subject, resource).await?;
if caller_is_admin {
tracing::info!(
target: "audit",
event = "drive_membership.removed_via_admin",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
by = %caller_id,
"👮🏻‍♂️ admin removed drive member bypassing Manage check",
);
}
Ok(())
}
// ── Business rules ──────────────────────────────────────────────────────
@@ -176,6 +176,21 @@ pub trait DriveRepository: Send + Sync + 'static {
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
/// List every drive on the system, regardless of caller membership.
///
/// Used by the admin panel's `GET /api/admin/drives`. Distinct from
/// `list_for_subjects` (which filters by `role_grants`) because an
/// admin who creates a shared drive for someone else has no grant
/// on it — but still needs to see, audit, and manage it. The HTTP
/// gate (admin-only middleware) is what makes the unrestricted
/// listing safe; no role-based filtering happens here.
///
/// Returns rows ordered by display name. `caller_role` is left
/// unset on the returned `DriveWithRootName` — the admin is not
/// necessarily a member, so the per-drive role would be misleading
/// here.
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
}
/// Convenience: convert the canonical kind discriminator from its SQL
@@ -431,4 +431,28 @@ impl DriveRepository for DrivePgRepository {
.map(Self::row_to_drive_with_name_and_role)
.collect()
}
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
// No subject filter: every drive on the system. The HTTP layer
// (admin guard on `/api/admin/drives`) is the access control —
// adding a role filter here would defeat the point of the
// endpoint (an admin without explicit membership wouldn't see
// the drives they created for other users).
let rows = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
ORDER BY LOWER(f.name) ASC
"#,
)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_all", e))?;
rows.iter().map(Self::row_to_drive_with_name).collect()
}
}
@@ -19,8 +19,13 @@ use crate::application::dtos::settings_dto::{
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
};
use crate::application::dtos::drive_dto::DriveDto;
use crate::application::dtos::grant_dto::{GrantDto, RoleDto, SubjectDto, SubjectTypeDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Resource, Subject};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
use std::sync::Arc;
@@ -90,6 +95,17 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
// can route the path freely without leaking inboxes.
.route("/smtp/test/captured", get(get_captured_email))
// Drives — admin-wide view (distinct from `/api/drives` which
// is filtered to the caller's role grants).
.route("/drives", get(list_all_drives))
.route(
"/drives/{id}/members",
get(list_drive_members_admin).post(add_drive_member_admin),
)
.route(
"/drives/{id}/members/{kind}/{sid}",
axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin),
)
}
/// Validate JWT and require admin role. Returns (user_id, role).
@@ -1706,3 +1722,238 @@ pub async fn set_plugin_retention(
Ok((StatusCode::OK, Json(dto)))
}
/// GET /api/admin/drives — list every drive on the system, admin-only.
///
/// Distinct from `GET /api/drives`, which is the caller's own listing
/// filtered through `role_grants`. An admin who creates a shared drive
/// for someone else has no grant on it — but the admin panel still
/// needs to see the drive (to audit, to manage, to delete). The admin
/// guard at the handler edge is the access control; no role filtering
/// happens in the repo (see `drive_repository::list_all`).
///
/// Returns rows ordered by display name. `caller_role` is omitted —
/// the admin is not necessarily a drive member, so the field would be
/// misleading here.
#[utoipa::path(
get,
path = "/api/admin/drives",
responses(
(status = 200, description = "Every drive on the system", body = Vec<DriveDto>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_all_drives(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let drives = state
.drive_repo
.list_all()
.await
.map_err(|e| AppError::internal_error(format!("Failed to list drives: {e}")))?;
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
Ok((StatusCode::OK, Json(dtos)))
}
/// GET /api/admin/drives/{id}/members — list every role grant on a drive,
/// admin-only.
///
/// Distinct from `GET /api/drives/{id}/members` which goes through
/// `DriveManagementService::list_members` and requires `Permission::Read`
/// on the drive. The admin who created the drive for someone else has
/// no role on it, so the user-facing endpoint would 404 for them.
///
/// This endpoint reuses the engine's `list_grants_on_resource` directly
/// — same query, same shape, just gated by the admin middleware instead
/// of by `authz.require`. Returns the same `Vec<GrantDto>` so the
/// frontend renders it through the existing grant types.
#[utoipa::path(
get,
path = "/api/admin/drives/{id}/members",
params(("id" = Uuid, Path, description = "Drive UUID")),
responses(
(status = 200, description = "Role grants on the drive", body = Vec<GrantDto>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_drive_members_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let grants = state
.authorization
.list_grants_on_resource(Resource::Drive(drive_id))
.await
.map_err(AppError::from)?;
let dtos: Vec<GrantDto> = grants.into_iter().map(GrantDto::from).collect();
Ok((StatusCode::OK, Json(dtos)))
}
/// Body for `POST /api/admin/drives/{id}/members` and
/// `PATCH /api/admin/drives/{id}/members/{kind}/{sid}` — same wire shape
/// as the user-facing endpoints, kept here so this handler doesn't pull
/// in the regular drive-handler module's DTOs (which would create a
/// circular feel between admin and user-facing surfaces).
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct AdminAddDriveMemberDto {
pub subject: SubjectDto,
pub role: RoleDto,
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct AdminUpdateDriveMemberDto {
pub role: RoleDto,
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
match kind {
SubjectTypeDto::User => Subject::User(id),
SubjectTypeDto::Group => Subject::Group(id),
SubjectTypeDto::Token => Subject::Token(id),
}
}
/// POST /api/admin/drives/{id}/members — add or refresh a member's role
/// without holding `Manage` on the drive. Admin-only; bypasses the
/// per-drive authz check via the `caller_is_admin = true` argument on
/// `DriveManagementService::set_member_role`. Personal-drive guard and
/// last-owner protection still apply.
#[utoipa::path(
post,
path = "/api/admin/drives/{id}/members",
params(("id" = Uuid, Path, description = "Drive UUID")),
request_body = AdminAddDriveMemberDto,
responses(
(status = 201, description = "Member added", body = GrantDto),
(status = 400, description = "Validation error (e.g. last-owner constraint)"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 405, description = "Personal drive — membership is immutable"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn add_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
Json(dto): Json<AdminAddDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let subject = admin_parse_subject(dto.subject.kind, dto.subject.id);
let grant = state
.drive_management_service
.set_member_role(
admin_id,
true,
drive_id,
subject,
dto.role.into(),
dto.expires_at,
)
.await
.map_err(AppError::from)?;
Ok((StatusCode::CREATED, Json(GrantDto::from(grant))))
}
/// PATCH /api/admin/drives/{id}/members/{kind}/{sid} — change a member's
/// role / expiry as an admin. Same admin-bypass shape as
/// `add_drive_member_admin`.
#[utoipa::path(
patch,
path = "/api/admin/drives/{id}/members/{kind}/{sid}",
params(
("id" = Uuid, Path, description = "Drive UUID"),
("kind" = String, Path, description = "Subject kind: user|group|token"),
("sid" = Uuid, Path, description = "Subject UUID"),
),
request_body = AdminUpdateDriveMemberDto,
responses(
(status = 200, description = "Member role updated", body = GrantDto),
(status = 400, description = "Validation error (e.g. last-owner demotion)"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 405, description = "Personal drive — membership is immutable"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn update_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
Uuid,
)>,
Json(dto): Json<AdminUpdateDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let subject = admin_parse_subject(kind, subject_id);
let grant = state
.drive_management_service
.set_member_role(
admin_id,
true,
drive_id,
subject,
dto.role.into(),
dto.expires_at,
)
.await
.map_err(AppError::from)?;
Ok((StatusCode::OK, Json(GrantDto::from(grant))))
}
/// DELETE /api/admin/drives/{id}/members/{kind}/{sid} — remove a
/// member as an admin. Bypasses `Manage`; keeps last-owner protection.
#[utoipa::path(
delete,
path = "/api/admin/drives/{id}/members/{kind}/{sid}",
params(
("id" = Uuid, Path, description = "Drive UUID"),
("kind" = String, Path, description = "Subject kind: user|group|token"),
("sid" = Uuid, Path, description = "Subject UUID"),
),
responses(
(status = 204, description = "Member removed (or wasn't a member — idempotent)"),
(status = 400, description = "Last-owner protection — promote another member first"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 405, description = "Personal drive — membership is immutable"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn remove_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
Uuid,
)>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let subject = admin_parse_subject(kind, subject_id);
state
.drive_management_service
.remove_member(admin_id, true, drive_id, subject)
.await
.map_err(AppError::from)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -127,6 +127,13 @@ pub struct AddMemberRequest {
pub struct ListQuery {
limit: Option<i64>,
offset: Option<i64>,
/// System address book only — when `true`, includes the calling user
/// in the returned contacts. Default `false` matches the share-modal
/// "you can't share with yourself" semantics; the admin drive-owner
/// surface flips it on so an admin can add themselves as Owner.
/// Ignored for non-system books.
#[serde(default)]
include_self: bool,
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -491,9 +498,10 @@ pub async fn list_contacts(
.await
{
Ok(users) => {
let include_self = params.include_self;
let contacts: Vec<ContactDto> = users
.into_iter()
.filter(|u| u.id != caller_id)
.filter(|u| include_self || u.id != caller_id)
.map(user_to_contact)
.collect();
(StatusCode::OK, Json(contacts)).into_response()
+3 -1
View File
@@ -269,6 +269,7 @@ pub async fn add_drive_member(
.drive_management_service
.set_member_role(
auth_user.id,
false, // caller_is_admin — user-facing route, always require Manage
drive_id,
subject,
dto.role.into(),
@@ -310,6 +311,7 @@ pub async fn update_drive_member(
.drive_management_service
.set_member_role(
auth_user.id,
false, // caller_is_admin — user-facing route, always require Manage
drive_id,
subject,
dto.role.into(),
@@ -347,7 +349,7 @@ pub async fn remove_drive_member(
let subject = parse_subject(kind, subject_id);
match state
.drive_management_service
.remove_member(auth_user.id, drive_id, subject)
.remove_member(auth_user.id, false, drive_id, subject)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
+3 -3
View File
@@ -164,7 +164,7 @@ pub async fn create_grant(
let grant = if let Resource::Drive(drive_id) = resource {
match state
.drive_management_service
.set_member_role(caller_id, drive_id, subject, role, expires_at)
.set_member_role(caller_id, false, drive_id, subject, role, expires_at)
.await
{
Ok(g) => g,
@@ -322,7 +322,7 @@ pub async fn revoke_grant(
}
if let Err(e) = state
.drive_management_service
.remove_member(caller_id, drive_id, subject)
.remove_member(caller_id, false, drive_id, subject)
.await
{
return AppError::from(e).into_response();
@@ -609,7 +609,7 @@ pub async fn set_role(
let grant = if let Resource::Drive(drive_id) = resource {
match state
.drive_management_service
.set_member_role(caller_id, drive_id, subject, role, expires_at)
.set_member_role(caller_id, false, drive_id, subject, role, expires_at)
.await
{
Ok(g) => g,