feat(drive): add drive deletion
- conditions: drive must be empty
- deletion forbidden on main personal drive
This commit is contained in:
@@ -274,6 +274,105 @@ impl DriveManagementService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `DELETE /api/drives/{id}` and `DELETE /api/admin/drives/{id}`.
|
||||
///
|
||||
/// Policy (drive.md §6 + memos):
|
||||
/// - Caller must hold `Permission::Manage` on the drive — typically
|
||||
/// the Owner. `caller_is_admin = true` bypasses this check; the
|
||||
/// route gate is the access control then. Audit emits
|
||||
/// `drive.deleted_via_admin` when the bypass fires.
|
||||
/// - The user's default Personal drive (`drives.default_for_user
|
||||
/// IS NOT NULL`) is refused with `405` — deleting your home is a
|
||||
/// category error. Secondary personal drives + shared drives
|
||||
/// follow the same content-empty rule below.
|
||||
/// - The drive must be empty (no live folders other than the root,
|
||||
/// no live files). Trashed rows are excluded — owners can
|
||||
/// delete a drive whose trash bin still holds rows; the trash GC
|
||||
/// cleans them up after the retention window. Non-empty drives
|
||||
/// return `409 Conflict` so the UI can prompt the owner to
|
||||
/// move/trash content first.
|
||||
///
|
||||
/// On success the drive row, its root folder, and every
|
||||
/// `role_grants` row scoped to the drive are removed in one
|
||||
/// transaction.
|
||||
pub async fn delete_drive(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
drive_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let resource = Resource::Drive(drive_id);
|
||||
if !caller_is_admin {
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), Permission::Manage, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
|
||||
if drive.drive.default_for_user.is_some() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "default_personal_drive",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused delete on default personal drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::operation_not_supported(
|
||||
"Drive",
|
||||
"The default Personal drive cannot be deleted.",
|
||||
));
|
||||
}
|
||||
|
||||
let empty = self.drive_repo.is_empty(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to check emptiness: {e:?}"))
|
||||
})?;
|
||||
if !empty {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "drive_not_empty",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused delete on non-empty drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::Conflict,
|
||||
"Drive",
|
||||
"Drive is not empty — move or trash its contents before deleting.",
|
||||
));
|
||||
}
|
||||
|
||||
self.drive_repo
|
||||
.delete_atomic(drive_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Drive", format!("delete failed: {e:?}")))?;
|
||||
|
||||
// Drop every cached drive-role entry for this drive so the next
|
||||
// /api/drives listing for any subject doesn't show a row pointing
|
||||
// at a deleted drive_id. Single-key cache invalidations are safe
|
||||
// even when no entry matches.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if caller_is_admin {
|
||||
"drive.deleted_via_admin"
|
||||
} else {
|
||||
"drive.deleted"
|
||||
},
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"🗑 drive deleted",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Business rules ──────────────────────────────────────────────────────
|
||||
|
||||
/// Personal drives are single-user single-owner; any member mutation is
|
||||
|
||||
@@ -210,6 +210,69 @@ impl SubjectGroupService {
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse if this group is the **sole Owner** of any drive — the
|
||||
// cascade-delete below would otherwise wipe the only `owner`
|
||||
// grant on that drive and leave it orphaned (no one can ever
|
||||
// manage it again). The check is "for every drive where this
|
||||
// group holds Owner, does another Owner exist?". A single drive
|
||||
// failing the check is enough to refuse.
|
||||
//
|
||||
// Matching D3a's last-owner-protection rule on `set_member_role`
|
||||
// / `remove_member` — they catch the case where the drive's
|
||||
// last Owner is *directly* a user or group being demoted /
|
||||
// removed via the membership API. This guard catches the same
|
||||
// invariant from the group-lifecycle side.
|
||||
let orphaning: Option<(Uuid,)> = sqlx::query_as(
|
||||
r#"
|
||||
WITH group_owned AS (
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = 'group'
|
||||
AND subject_id = $1
|
||||
AND resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
)
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND resource_id IN (SELECT resource_id FROM group_owned)
|
||||
GROUP BY resource_id
|
||||
HAVING COUNT(*) = 1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("sole-owner check: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some((drive_id,)) = orphaning {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group_delete.rejected",
|
||||
reason = "sole_drive_owner",
|
||||
group_id = %id,
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused group delete — sole Owner of drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"SubjectGroup",
|
||||
"Group is the sole Owner of at least one shared drive — \
|
||||
promote another Owner first or delete the drive."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Atomically delete grants pointing at this group, then the group
|
||||
// itself. If either fails, both roll back.
|
||||
let mut tx = self.pool.begin().await.map_err(|e| {
|
||||
|
||||
@@ -33,6 +33,12 @@ pub enum ErrorKind {
|
||||
DatabaseError,
|
||||
/// Storage quota exceeded
|
||||
QuotaExceeded,
|
||||
/// State conflict — the request is well-formed and permitted, but
|
||||
/// the resource is in a state that refuses it (e.g. "drive must
|
||||
/// be empty before delete"). Maps to HTTP 409. Distinct from
|
||||
/// `AlreadyExists` (which is a uniqueness violation) so audit
|
||||
/// readers can tell them apart.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
impl Display for ErrorKind {
|
||||
@@ -48,6 +54,7 @@ impl Display for ErrorKind {
|
||||
ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"),
|
||||
ErrorKind::DatabaseError => write!(f, "Database Error"),
|
||||
ErrorKind::QuotaExceeded => write!(f, "Quota Exceeded"),
|
||||
ErrorKind::Conflict => write!(f, "Conflict"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,19 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
subject_ids: &[Uuid],
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
|
||||
/// `true` when the drive holds no live (non-trashed) folders other
|
||||
/// than its own root and no live files at all. Used by
|
||||
/// `DriveManagementService::delete_drive` to enforce the
|
||||
/// "empty-before-delete" rule — owners must clear / trash the
|
||||
/// content first so a single click can't wipe a populated drive.
|
||||
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError>;
|
||||
|
||||
/// Hard-delete a drive: its `role_grants` rows, its root folder,
|
||||
/// and the drive row itself, in one transaction. Caller is
|
||||
/// responsible for ensuring `is_empty` first; this method does
|
||||
/// **not** re-check. Returns `NotFound` if the drive id is gone.
|
||||
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError>;
|
||||
|
||||
/// List every drive on the system, regardless of caller membership.
|
||||
///
|
||||
/// Used by the admin panel's `GET /api/admin/drives`. Distinct from
|
||||
|
||||
@@ -303,6 +303,85 @@ impl DriveRepository for DrivePgRepository {
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError> {
|
||||
// A "live" non-root folder = any folder with `parent_id IS NOT
|
||||
// NULL` (root is the only NULL-parent row per drive) and not in
|
||||
// the trash. Trashed items don't count — owners can delete a
|
||||
// drive even when its trash bin still holds rows; the trash GC
|
||||
// will clean those up after the standard retention window.
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT (
|
||||
(SELECT COUNT(*) FROM storage.folders
|
||||
WHERE drive_id = $1 AND parent_id IS NOT NULL AND NOT is_trashed)
|
||||
+ (SELECT COUNT(*) FROM storage.files
|
||||
WHERE drive_id = $1 AND NOT is_trashed)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("is_empty", e))?;
|
||||
Ok(count.0 == 0)
|
||||
}
|
||||
|
||||
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError> {
|
||||
// Three-statement transaction:
|
||||
// 1. Drop every role_grants row scoped to the drive itself
|
||||
// (folder/file grants under it are gone by step 3 cascade).
|
||||
// 2. Look up the root folder id (we'll need it to delete the
|
||||
// folder row AFTER the drive row releases its FK).
|
||||
// 3. Delete the drive — release the drive→root FK first.
|
||||
// 4. Delete the root folder (drive_id FK on folders cascades
|
||||
// from this row going away; only the root remains because
|
||||
// is_empty was true).
|
||||
//
|
||||
// `drive_id` is bound once per statement; failure at any step
|
||||
// rolls back. Caller (`DriveManagementService::delete_drive`)
|
||||
// is responsible for the `is_empty` precheck.
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.begin", e))?;
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.role_grants \
|
||||
WHERE resource_type = 'drive' AND resource_id = $1",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.grants", e))?;
|
||||
|
||||
let root: (Uuid,) = sqlx::query_as(
|
||||
"SELECT root_folder_id FROM storage.drives WHERE id = $1",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.lookup_root", e))?
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?;
|
||||
|
||||
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.drive", e))?;
|
||||
|
||||
sqlx::query("DELETE FROM storage.folders WHERE id = $1")
|
||||
.bind(root.0)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.root", e))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete_atomic.commit", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -98,6 +98,7 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// 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}", delete(delete_drive_admin))
|
||||
.route(
|
||||
"/drives/{id}/members",
|
||||
get(list_drive_members_admin).post(add_drive_member_admin),
|
||||
@@ -1957,3 +1958,40 @@ pub async fn remove_drive_member_admin(
|
||||
.map_err(AppError::from)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).
|
||||
///
|
||||
/// Same shape as the user-facing `DELETE /api/drives/{id}`, but
|
||||
/// bypasses the per-drive `Manage` check (the admin guard at the
|
||||
/// route edge is the access control). The remaining invariants —
|
||||
/// default Personal drive is undeletable, drive must be empty — still
|
||||
/// apply: an admin can't accidentally wipe a populated drive or the
|
||||
/// default home folder of any user. Audit emits
|
||||
/// `drive.deleted_via_admin` on success.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/admin/drives/{id}",
|
||||
params(("id" = Uuid, Path, description = "Drive UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Drive deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 405, description = "Default Personal drive — undeletable"),
|
||||
(status = 409, description = "Drive is not empty"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn delete_drive_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
state
|
||||
.drive_management_service
|
||||
.delete_drive(admin_id, true, drive_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -356,3 +356,42 @@ pub async fn remove_drive_member(
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/drives/{id}` — Owner-only deletion (D3b).
|
||||
///
|
||||
/// Refuses (per `DriveManagementService::delete_drive`):
|
||||
/// - `404` when the caller lacks Manage on the drive (anti-enum).
|
||||
/// - `405` when the drive is the user's default Personal drive.
|
||||
/// - `409` when the drive still holds live folders/files; the caller
|
||||
/// must trash or move them first.
|
||||
///
|
||||
/// On success the drive row, its root folder, and every role grant
|
||||
/// scoped to the drive are removed in one transaction; cached drive
|
||||
/// roles are invalidated.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/drives/{id}",
|
||||
params(("id" = Uuid, Path, description = "Drive UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Drive deleted"),
|
||||
(status = 404, description = "Drive not found or caller lacks Manage"),
|
||||
(status = 405, description = "Default Personal drive — undeletable"),
|
||||
(status = 409, description = "Drive is not empty — move/trash contents first"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "drives"
|
||||
)]
|
||||
pub async fn delete_drive(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(drive_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.drive_management_service
|
||||
.delete_drive(auth_user.id, false, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,10 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
"/",
|
||||
get(drive_handler::list_drives).post(drive_handler::create_drive),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
axum::routing::delete(drive_handler::delete_drive),
|
||||
)
|
||||
.route(
|
||||
"/{id}/members",
|
||||
get(drive_handler::list_drive_members).post(drive_handler::add_drive_member),
|
||||
|
||||
@@ -125,6 +125,7 @@ impl From<DomainError> for AppError {
|
||||
ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED,
|
||||
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
|
||||
ErrorKind::Conflict => StatusCode::CONFLICT,
|
||||
};
|
||||
|
||||
Self {
|
||||
|
||||
Reference in New Issue
Block a user