feat(drive): add drive deletion

- conditions: drive must be empty
    - deletion forbidden on main personal drive
This commit is contained in:
Edouard Vanbelle
2026-06-24 21:17:24 +02:00
parent 33cfa876d0
commit 7d24015fc4
15 changed files with 673 additions and 2 deletions
@@ -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| {