Merge pull request #598 from EdouardVanbelle/feat/readonly-drive

This commit is contained in:
Dionisio Pozo
2026-07-16 07:40:29 +02:00
committed by GitHub
33 changed files with 1094 additions and 95 deletions
@@ -488,6 +488,16 @@ impl DriveManagementService {
),
})?;
// Flush the cached typed policy view so the very next mutating
// authz check on any resource in this drive sees the fresh
// `read_only` value (and every other policy field). Without this,
// a policy change would take up to `DRIVE_POLICIES_CACHE_TTL` (30 s)
// to take effect on the hot path — unacceptable for the read_only
// freeze, which admins expect to be effective immediately.
self.authz
.invalidate_drive_policies_cache_for_drive(drive_id)
.await;
tracing::info!(
target: "audit",
event = "drive.policy_changed",
@@ -500,6 +510,7 @@ impl DriveManagementService {
forbid_owner_role_change = merged.forbid_owner_role_change,
include_in_photo_index = merged.include_in_photo_index,
include_in_music_index = merged.include_in_music_index,
read_only = merged.read_only,
"📜 drive policies updated",
);
Ok(merged)
+20
View File
@@ -201,6 +201,26 @@ pub struct DrivePolicies {
/// scrutiny (voicemail MP3s in a work drive shouldn't bleed into
/// the personal library). See §15.
pub include_in_music_index: bool,
/// **Full freeze / legal-hold.** When `true`, every mutation on
/// resources in this drive is refused — user-initiated and
/// background alike. Compliance-grade guarantee:
///
/// - User-initiated: enforced at `PgAclEngine::check_inner`, which
/// short-circuits `Create` / `Update` / `Delete` / `Share`
/// permissions on any resource in a read-only drive. Read still
/// passes. Manage-on-Drive still passes so admins can un-freeze.
/// - Background jobs: the periodic trash-retention purge and
/// orphan-upload sweep filter out read-only drives at SELECT
/// time (SQL-side `JOIN storage.drives … WHERE (policies->>
/// 'read_only')::boolean IS NOT TRUE`). Retention clock keeps
/// ticking; on unfreeze, the next sweep tick catches up.
///
/// Applies to both personal and shared drives — a user winding
/// down their account, freezing a secondary personal archive, or
/// putting a shared drive on legal hold all use the same knob.
/// Mutation is admin-only via `PATCH /api/drives/{id}/policies`
/// (per §8 — same carve-out as every other policy).
pub read_only: bool,
}
impl DrivePolicies {
@@ -252,15 +252,35 @@ impl TrashRepository for TrashDbRepository {
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
// The `read_only` policy on a drive is a compliance-grade freeze:
// NO state on the drive changes while the policy is on, including
// background retention. The `JOIN storage.drives d ... AND
// (d.policies->>'read_only')::boolean IS NOT TRUE` filter excludes
// frozen drives at SELECT time. Retention clock keeps ticking; on
// unfreeze, the next sweep tick catches up on anything past its
// TTL. Legal-hold guarantee documented in `docs/plan/drive.md` §8
// and `docs/guide/trash.md`.
//
// `(policies->>'read_only')::boolean IS NOT TRUE` semantics:
// - key missing → NULL::boolean → IS NOT TRUE → included
// - explicit `false` → FALSE → IS NOT TRUE → included
// - explicit `true` → TRUE → IS TRUE → excluded
// Correct for both current data (most drives omit the key) and
// freshly-frozen drives.
// 1. Bulk-delete expired trashed files in batches.
// The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_count for every deleted row.
let files_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.files
WHERE id IN (SELECT id FROM storage.files
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
WHERE id IN (SELECT f.id
FROM storage.files f
JOIN storage.drives d ON d.id = f.drive_id
WHERE f.is_trashed = TRUE
AND f.trashed_at < $1
AND (d.policies->>'read_only')::boolean IS NOT TRUE
ORDER BY f.trashed_at
LIMIT $2)",
cutoff,
1_000,
@@ -270,13 +290,19 @@ impl TrashRepository for TrashDbRepository {
// 2. Bulk-delete expired trashed folders in batches.
// FK ON DELETE CASCADE handles descendant folders and their
// files, so each row can fan out to an entire subtree — hence
// the smaller batch size.
// the smaller batch size. Same read_only exclusion applies:
// a subtree rooted in a frozen drive isn't purged even if the
// folder's own trashed_at is past retention.
let folders_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.folders
WHERE id IN (SELECT id FROM storage.folders
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
WHERE id IN (SELECT f.id
FROM storage.folders f
JOIN storage.drives d ON d.id = f.drive_id
WHERE f.is_trashed = TRUE
AND f.trashed_at < $1
AND (d.policies->>'read_only')::boolean IS NOT TRUE
ORDER BY f.trashed_at
LIMIT $2)",
cutoff,
100,
@@ -40,6 +40,7 @@ use sqlx::PgPool;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::entities::drive::DrivePolicies;
use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
use crate::domain::services::authorization::{
@@ -91,6 +92,17 @@ const DRIVE_ROLE_CACHE_CAPACITY: u64 = 100_000;
/// enough that any oversight self-heals in <1 minute.
const DRIVE_ROLE_CACHE_TTL: Duration = Duration::from_secs(30);
/// `drive_policies_cache` bound: entries are `(Uuid, DrivePolicies)` — a
/// handful of bools per drive. 100k is generous headroom for the drive
/// population of any realistic deployment.
const DRIVE_POLICIES_CACHE_CAPACITY: u64 = 100_000;
/// `drive_policies_cache` TTL. Policy mutations explicitly invalidate
/// (see `invalidate_drive_policies_cache_for_drive`) so the TTL is the
/// self-heal net for edge cases (direct SQL PATCH by an operator, migration
/// backfill). Short enough that a manually-flipped `read_only` becomes
/// effective within a minute on the hot path.
const DRIVE_POLICIES_CACHE_TTL: Duration = Duration::from_secs(30);
pub struct PgAclEngine {
pool: Arc<PgPool>,
folder_repo: Arc<FolderDbRepository>,
@@ -132,6 +144,22 @@ pub struct PgAclEngine {
/// `DriveManagementService`, the grant handler's revoke path) hit the
/// invalidator inline.
drive_role_cache: Cache<(Subject, Uuid), Option<Role>>,
/// Memoise `drive_id → DrivePolicies` (the typed view of the JSONB
/// `storage.drives.policies` column). Read on every mutating authz
/// check on a resource that lives in a drive (File/Folder/Drive) to
/// gate the `read_only` freeze.
///
/// Subject-independent — policies are the same for every caller, so a
/// single entry per drive covers the whole tenant. Kept separate from
/// `drive_role_cache` (subject-keyed) so policy changes only flush this
/// cache, and membership changes only flush that one.
///
/// **Invalidation**: explicit on every `DriveManagementService::update_policies`
/// call — a policy PATCH invalidates the entry before the response
/// returns, so the next check sees the fresh values. Short 30 s TTL
/// as the self-heal net for direct-SQL edits and migration backfills.
drive_policies_cache: Cache<Uuid, DrivePolicies>,
}
impl PgAclEngine {
@@ -165,6 +193,10 @@ impl PgAclEngine {
.max_capacity(DRIVE_ROLE_CACHE_CAPACITY)
.time_to_live(DRIVE_ROLE_CACHE_TTL)
.build(),
drive_policies_cache: Cache::builder()
.max_capacity(DRIVE_POLICIES_CACHE_CAPACITY)
.time_to_live(DRIVE_POLICIES_CACHE_TTL)
.build(),
}
}
@@ -231,6 +263,10 @@ impl PgAclEngine {
.max_capacity(1)
.time_to_live(Duration::from_secs(1))
.build(),
drive_policies_cache: Cache::builder()
.max_capacity(1)
.time_to_live(Duration::from_secs(1))
.build(),
}
}
@@ -257,6 +293,15 @@ impl PgAclEngine {
/// `drive_role_cache` initialiser above), otherwise moka returns
/// `InvalidationClosuresDisabled` and the mutation silently leaves
/// stale role rows in cache for the full TTL.
/// Drop the cached `DrivePolicies` entry for one drive. Called by
/// `DriveManagementService::update_policies` after every JSONB PATCH so
/// the next mutating authz check sees the fresh `read_only` flag and
/// other policy values without waiting for the TTL. Single-entry
/// invalidate is a cheap concurrent-map op.
pub async fn invalidate_drive_policies_cache_for_drive(&self, drive_id: Uuid) {
self.drive_policies_cache.invalidate(&drive_id).await;
}
pub async fn invalidate_drive_role_cache_for_drive(&self, drive_id: Uuid) {
// `invalidate_entries_if` rejects predicates returning errors —
// simple Fn(K, V) -> bool. We capture `drive_id` by value (Copy)
@@ -711,6 +756,65 @@ impl PgAclEngine {
Ok(role)
}
/// Fetch a drive's typed `DrivePolicies`, going through `drive_policies_cache`
/// (30 s TTL, explicit invalidation on policy PATCH). Malformed JSONB
/// falls back to the all-false default — consistent with
/// `DrivePolicies::from_value` — so enforcement can't panic on legacy
/// or partial data.
async fn drive_policies_cached(
&self,
drive_id: Uuid,
counters: &QueryCounters,
) -> Result<DrivePolicies, DomainError> {
if let Some(cached) = self.drive_policies_cache.get(&drive_id).await {
counters.cache_hit.fetch_add(1, Ordering::Relaxed);
return Ok(cached);
}
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let row: Option<(serde_json::Value,)> =
sqlx::query_as("SELECT policies FROM storage.drives WHERE id = $1")
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("policies lookup: {e}"))
})?;
// Missing drive: cache the default (all-false). Anti-enum handled by
// the caller — a missing drive returns NotFound at the resource-resolve
// step upstream; here we just make sure the cache doesn't panic-loop
// if the read happens post-drive-delete.
let policies = row
.map(|(v,)| DrivePolicies::from_value(&v))
.unwrap_or_default();
self.drive_policies_cache
.insert(drive_id, policies.clone())
.await;
Ok(policies)
}
/// Every permission except `Read` mutates persistent state on a
/// drive-scoped resource and is therefore refused when the drive is
/// `read_only=true`:
///
/// - `Create` / `Update` / `Delete` — the obvious file/folder mutations.
/// - `Share` — persists a new `role_grants` row.
/// - `Comment` — adds user-generated content (reserved feature).
/// - `Manage` — mutates drive-level membership (add/remove/promote
/// members) on `Resource::Drive`.
///
/// **Admin escape hatch does NOT rely on this gate.** Un-freezing a
/// drive goes through `PATCH /api/drives/{id}/policies`, which is
/// admin-only via `admin_guard` at the handler layer — it never
/// enters `authz.require`. So blocking `Manage` here doesn't lock
/// admins out; it locks OWNERS out of membership mutation while the
/// freeze holds, which is exactly the legal-hold guarantee.
///
/// Only `Read` passes: members can still list, download, and PROPFIND
/// the drive's contents.
fn read_only_gate_applies(p: Permission) -> bool {
!matches!(p, Permission::Read)
}
/// Look up a single role grant by id, returning the actors a revoke /
/// notify handler needs to make a decision without a second round-trip.
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
@@ -826,6 +930,36 @@ impl PgAclEngine {
}
Err(e) => return Err(e),
};
// Read-only drive freeze — every mutating permission on any
// resource in this drive is refused, regardless of the caller's
// role. Compliance-grade guarantee: paired with the background-
// job SQL filters, no state on this drive changes until the
// policy is flipped. See `docs/plan/drive.md` §8 (`read_only`).
//
// Anti-enumeration: emit an audit line with the specific
// `drive_read_only` reason, then return `false`. The generic
// `authz.denied` line at `require` also fires — operators
// filter on the specific event to find freeze-caused denials.
if Self::read_only_gate_applies(permission)
&& self
.drive_policies_cached(drive_id, counters)
.await?
.read_only
{
tracing::info!(
target: "audit",
event = "authz.denied",
reason = "drive_read_only",
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
drive_id = %drive_id,
"🧊 mutation refused: drive is read-only",
);
return Ok(false);
}
if let Some(role) = self
.caller_role_on_drive_cached(subject, drive_id, counters)
.await?
@@ -866,6 +1000,28 @@ impl PgAclEngine {
.await
}
Resource::Drive(id) => {
// Same read_only gate as the File/Folder branch: a frozen
// drive refuses every mutating permission (Create / Update /
// Delete / Share) targeting the drive resource itself.
// Manage stays permitted so admins can toggle the policy
// back off; Read stays permitted so members can still list.
if Self::read_only_gate_applies(permission)
&& self.drive_policies_cached(id, counters).await?.read_only
{
tracing::info!(
target: "audit",
event = "authz.denied",
reason = "drive_read_only",
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = "drive",
resource_id = %id,
drive_id = %id,
"🧊 mutation refused: drive is read-only",
);
return Ok(false);
}
// Same cache-aware path the precheck uses — keeps the
// single-source-of-truth for drive role resolution and
// benefits identically from `drive_role_cache`.
@@ -404,6 +404,8 @@ pub struct UpdateDrivePoliciesDto {
pub include_in_photo_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_in_music_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<bool>,
}
/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy
@@ -488,6 +490,9 @@ pub async fn update_drive_policies(
if let Some(v) = dto.include_in_music_index {
partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.read_only {
partial_obj.insert("read_only".into(), serde_json::Value::Bool(v));
}
// Pass the raw JSON straight through so the JSONB `||` merge in
// the repo only touches keys the caller supplied. Round-tripping
// via `DrivePolicies` (which has `#[serde(default)]`) would
+24 -68
View File
@@ -97,7 +97,7 @@ pub async fn move_file_to_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
let user_id = auth_user.id;
debug!(
"Request to move file to trash: id={}, user={}",
@@ -112,7 +112,8 @@ pub async fn move_file_to_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
@@ -129,15 +130,11 @@ pub async fn move_file_to_trash(
"message": "File moved to trash successfully"
})),
)
.into_response()
}
Err(e) => {
error!("Error moving file to trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error moving file to trash"
})),
)
warn!("move_file_to_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -159,7 +156,7 @@ pub async fn move_folder_to_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
let user_id = auth_user.id;
debug!(
"Request to move folder to trash: id={}, user={}",
@@ -174,7 +171,8 @@ pub async fn move_folder_to_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
@@ -193,15 +191,11 @@ pub async fn move_folder_to_trash(
"message": "Folder moved to trash successfully"
})),
)
.into_response()
}
Err(e) => {
error!("Error moving folder to trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error moving folder to trash"
})),
)
warn!("move_folder_to_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -223,7 +217,7 @@ pub async fn restore_from_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
debug!("Request to restore item {} from trash", trash_id);
let trash_service = match state.trash_service.as_ref() {
@@ -234,7 +228,8 @@ pub async fn restore_from_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
let result = trash_service.restore_item(&trash_id, auth_user.id).await;
@@ -249,31 +244,11 @@ pub async fn restore_from_trash(
"message": "Item restored successfully"
})),
)
.into_response()
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already restored or removed)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item restored (or was already removed from trash)"
})),
);
}
error!("Error restoring item from trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error restoring item from trash"
})),
)
warn!("restore_from_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -295,7 +270,7 @@ pub async fn delete_permanently(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
debug!("Request to permanently delete item {}", trash_id);
let trash_service = match state.trash_service.as_ref() {
@@ -306,7 +281,8 @@ pub async fn delete_permanently(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
let result = trash_service
@@ -323,31 +299,11 @@ pub async fn delete_permanently(
"message": "Item deleted permanently"
})),
)
.into_response()
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already deleted)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item deleted (or was already removed from trash)"
})),
);
}
error!("Error permanently deleting item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error deleting item permanently"
})),
)
warn!("delete_permanently failed: {:?}", e);
AppError::from(e).into_response()
}
}
}