feat(drive): UI: add drive edition for admin
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user