feat(drive): add quota update handler
per today: admin only can update quota
shared drive can have quota updated (personal drive's quota belong to user's quota)
This commit is contained in:
@@ -533,6 +533,116 @@ impl DriveManagementService {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/quota`. OxiCloud-admin only.
|
||||
///
|
||||
/// `quota_bytes = None` (or ≤ 0 from the wire, normalised to None
|
||||
/// here) means unlimited — matches the DB convention where a NULL
|
||||
/// `drives.quota_bytes` row is treated as no cap by
|
||||
/// `storage_usage_service`.
|
||||
///
|
||||
/// **Refuses personal drives** with `InvalidInput`. Personal
|
||||
/// drives carry `NULL` on the row by design (memory
|
||||
/// `project_user_envelope_quota_model`) — the effective cap comes
|
||||
/// from the owner user's `storage_quota_bytes`, editable via
|
||||
/// `PUT /api/admin/users/{id}/quota`. Allowing a per-personal-drive
|
||||
/// quota here would fork the model into two competing enforcement
|
||||
/// paths; keep the envelope model intact.
|
||||
///
|
||||
/// **Soft-quota semantic on reduction.** A newly-lowered quota
|
||||
/// can land BELOW the drive's current `used_bytes` — this method
|
||||
/// accepts that without failing. `storage_usage_service` gates
|
||||
/// new writes on `used + delta ≤ quota`, so a shared drive
|
||||
/// already over its freshly-reduced cap can only shrink (delete)
|
||||
/// until it comes back under; no existing content is retroactively
|
||||
/// touched. Ed's call: intentional design, matches how filesystems
|
||||
/// treat quota shrink (Linux xfs quota tools do the same).
|
||||
///
|
||||
/// Follows the same handler-gates-admin deviation from AGENTS.md
|
||||
/// as `update_policies` — see memory
|
||||
/// `feedback_drive_policies_admin_at_handler`. The handler
|
||||
/// refuses non-admin callers with 404 anti-enumeration; this
|
||||
/// method trusts that gate and writes unconditionally on
|
||||
/// shared-kind drives.
|
||||
///
|
||||
/// Emits `drive.quota_changed` for steady-state observability.
|
||||
/// Returns the persisted post-mutation quota so the handler can
|
||||
/// echo it in the API response.
|
||||
pub async fn update_quota(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DomainError> {
|
||||
// Normalise sentinel values: `0` and negative numbers on the
|
||||
// wire all mean "unlimited" — same convention the storage
|
||||
// service uses on the query side (see `check_drive_quota`).
|
||||
// Doing this once here (rather than in every caller) keeps the
|
||||
// audit line + DB row consistent.
|
||||
let quota_bytes = quota_bytes.filter(|&q| q > 0);
|
||||
|
||||
let drive = self
|
||||
.drive_repo
|
||||
.get_by_id(drive_id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("Failed to fetch drive: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
// Personal drives are refused with `InvalidInput` — a 400 that
|
||||
// the handler doesn't need to translate specially. Audit line
|
||||
// captures the attempt so an operator can see if someone is
|
||||
// trying to circumvent the envelope model.
|
||||
if drive.drive.kind == crate::domain::entities::drive::DriveKind::Personal {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_change_rejected",
|
||||
reason = "personal_drive_uses_user_envelope",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused quota edit on personal drive {drive_id} — use PUT /api/admin/users/{{id}}/quota",
|
||||
);
|
||||
return Err(DomainError::validation_error(
|
||||
"Personal drive quota is not editable here — set the owner user's storage envelope via PUT /api/admin/users/{id}/quota instead.",
|
||||
));
|
||||
}
|
||||
|
||||
let persisted = self
|
||||
.drive_repo
|
||||
.update_quota(drive_id, quota_bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => {
|
||||
DomainError::internal_error("Drive", format!("update_quota failed: {other:?}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Under-usage note in the audit line: an admin should be able
|
||||
// to spot from `grep audit drive.quota_changed` whether the
|
||||
// new cap put the drive into the "over quota, delete-only"
|
||||
// state, so the numbers (used, new quota) are both present.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_changed",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
new_quota_bytes = ?persisted,
|
||||
used_bytes = drive.drive.used_bytes,
|
||||
over_quota = persisted.map(|q| drive.drive.used_bytes > q).unwrap_or(false),
|
||||
"💾 drive quota updated",
|
||||
);
|
||||
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
/// D5 `forbid_external_sharing` for `set_member_role`. Fetches the
|
||||
/// data this surface has but grant_handler doesn't (drive policies +
|
||||
/// user flags), then defers the decision + audit + canonical error
|
||||
|
||||
@@ -296,6 +296,39 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
drive_id: Uuid,
|
||||
partial: &serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Set the drive-level storage quota on a **shared** drive.
|
||||
///
|
||||
/// `quota_bytes = None` means unlimited (matches the wire and DB
|
||||
/// convention — `drives.quota_bytes` is nullable; a NULL row → the
|
||||
/// storage-usage service treats it as no cap).
|
||||
///
|
||||
/// **Personal drives are refused at the service layer** — their
|
||||
/// effective cap comes from the owner user's
|
||||
/// `users.storage_quota_bytes` envelope (see the memory
|
||||
/// `project_user_envelope_quota_model`). This method does not
|
||||
/// re-check the kind; the service does, and only calls the repo
|
||||
/// with a validated shared-drive id.
|
||||
///
|
||||
/// A newly-lowered quota can be **under** the drive's current
|
||||
/// `used_bytes` — that's a deliberate soft-quota semantic. The
|
||||
/// `storage_usage_service` gates NEW writes on
|
||||
/// `used + delta <= quota`, so a shared drive already over its
|
||||
/// freshly-reduced cap can only shrink (delete) until it comes back
|
||||
/// under the limit; no existing content is retroactively touched.
|
||||
///
|
||||
/// Cache invalidation mirrors `update_policies` — the user-keyed
|
||||
/// readable-drive-list caches carry the quota alongside the row so
|
||||
/// they'd serve stale numbers otherwise; the default-drive cache
|
||||
/// carries the DriveWithRootName which also includes the quota.
|
||||
///
|
||||
/// Returns the persisted post-mutation value so the caller can
|
||||
/// echo it back in the audit log and API response.
|
||||
async fn update_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||
|
||||
@@ -839,4 +839,38 @@ impl DriveRepository for DrivePgRepository {
|
||||
&raw,
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DriveRepositoryError> {
|
||||
// RETURNING gives the persisted value so the caller (service
|
||||
// layer) has authoritative data for the audit line + API
|
||||
// response without a second read.
|
||||
let row: Option<(Option<i64>,)> = sqlx::query_as(
|
||||
"UPDATE storage.drives \
|
||||
SET quota_bytes = $2, \
|
||||
updated_at = now() \
|
||||
WHERE id = $1 \
|
||||
RETURNING quota_bytes",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(quota_bytes)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("update_quota", e))?;
|
||||
let persisted = row
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?
|
||||
.0;
|
||||
// Same invalidation strategy as `update_policies` — both
|
||||
// user-keyed caches (`default_drive_cache`, the readable-drive
|
||||
// list) carry the whole DriveWithRootName / DriveDto rows and
|
||||
// would serve a stale quota otherwise. Admin-rare mutation,
|
||||
// so blowing the whole cache is fine (no per-user pinpointing
|
||||
// needed).
|
||||
self.default_drive_cache.invalidate_all();
|
||||
self.invalidate_readable_all();
|
||||
Ok(persisted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,3 +509,102 @@ pub async fn update_drive_policies(
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for `PATCH /api/drives/{id}/quota` (D4).
|
||||
///
|
||||
/// `quota_bytes = null` (or ≤ 0) means unlimited — matches the DB
|
||||
/// convention where NULL on the row is treated as "no cap" by
|
||||
/// `storage_usage_service::check_drive_quota`. The service
|
||||
/// normalises 0/negative to None before writing.
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateDriveQuotaDto {
|
||||
/// New quota in bytes. `null` (or omitted) or ≤ 0 → unlimited.
|
||||
/// A value below the drive's current `used_bytes` is accepted
|
||||
/// intentionally (soft-quota semantic — new writes gated,
|
||||
/// existing content untouched; owners recover by deleting
|
||||
/// until the drive comes back under the cap).
|
||||
#[serde(default)]
|
||||
pub quota_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/quota` — **OxiCloud-admin only** storage-cap
|
||||
/// mutation for **shared** drives (D4).
|
||||
///
|
||||
/// Personal drives are refused with `400 InvalidInput` — their
|
||||
/// effective cap comes from the owner user's
|
||||
/// `users.storage_quota_bytes` envelope (memory
|
||||
/// `project_user_envelope_quota_model`); use
|
||||
/// `PUT /api/admin/users/{id}/quota` instead. Allowing a per-personal-
|
||||
/// drive quota here would fork the model into two competing paths.
|
||||
///
|
||||
/// Non-admin callers receive `404` (anti-enumeration — same shape as
|
||||
/// "no such drive", so a probe can't distinguish "drive doesn't
|
||||
/// exist" from "quota edit is admin-only"). Matches the pattern
|
||||
/// established by `update_drive_policies` above.
|
||||
///
|
||||
/// **Soft-quota semantic on reduction.** A newly-lowered quota may
|
||||
/// land BELOW the drive's current `used_bytes`. The write succeeds;
|
||||
/// `storage_usage_service` then blocks new writes on
|
||||
/// `used + delta > quota`, so owners of a shared drive that's now
|
||||
/// over its freshly-reduced cap can only shrink (delete) until they
|
||||
/// come back under. Existing content is never retroactively touched
|
||||
/// — matches xfs `xfs_quota` / ext4 `edquota` behaviour on quota
|
||||
/// shrink.
|
||||
///
|
||||
/// Cache invalidation: the repo drops `readable_cache` +
|
||||
/// `default_drive_cache` (both embed the whole drive row incl.
|
||||
/// `quota_bytes`), matching the `update_policies` pattern.
|
||||
///
|
||||
/// Audit: emits `drive.quota_changed` with `new_quota_bytes`,
|
||||
/// `used_bytes`, and `over_quota` — so an operator grepping
|
||||
/// `audit drive.quota_changed` can spot a shrink that landed the
|
||||
/// drive in the over-quota delete-only state.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/drives/{id}/quota",
|
||||
params(("id" = Uuid, Path, description = "Drive UUID")),
|
||||
request_body = UpdateDriveQuotaDto,
|
||||
responses(
|
||||
(status = 200, description = "Quota updated"),
|
||||
(status = 400, description = "Personal drive — quota is envelope-managed via the owner user"),
|
||||
(status = 404, description = "Drive not found OR caller is not OxiCloud admin"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "drives"
|
||||
)]
|
||||
pub async fn update_drive_quota(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(drive_id): Path<Uuid>,
|
||||
axum::Json(dto): axum::Json<UpdateDriveQuotaDto>,
|
||||
) -> impl IntoResponse {
|
||||
// Same admin gate + anti-enum shape as `update_drive_policies`.
|
||||
// Refusing with 404 (rather than 403) means an unauthorised
|
||||
// caller can't distinguish "no such drive" from "you're not
|
||||
// admin" — the endpoint's existence isn't probable by error
|
||||
// shape.
|
||||
if auth_user.role != "admin" {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_change_rejected",
|
||||
reason = "not_admin",
|
||||
caller_id = %auth_user.id,
|
||||
drive_id = %drive_id,
|
||||
"👮🏻♂️ quota mutation refused: caller is not OxiCloud admin",
|
||||
);
|
||||
return AppError::not_found(format!("Drive {drive_id} not found")).into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.drive_management_service
|
||||
.update_quota(auth_user.id, drive_id, dto.quota_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(persisted) => (
|
||||
StatusCode::OK,
|
||||
axum::Json(serde_json::json!({ "quota_bytes": persisted })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +460,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
"/{id}/policies",
|
||||
patch(drive_handler::update_drive_policies),
|
||||
)
|
||||
.route("/{id}/quota", patch(drive_handler::update_drive_quota))
|
||||
.route(
|
||||
"/{id}/members",
|
||||
get(drive_handler::list_drive_members).post(drive_handler::add_drive_member),
|
||||
|
||||
Reference in New Issue
Block a user