diff --git a/docs/guide/drives.md b/docs/guide/drives.md index f5e23662..a8cbd073 100644 --- a/docs/guide/drives.md +++ b/docs/guide/drives.md @@ -86,11 +86,19 @@ can do. | **Owner list changes** | Locks the Owner roster. After the admin sets the Owners, no Owner can add, remove, or demote another Owner — only the admin can. | | **Include in Photos** | Whether photos in this drive appear in the global **Photos** view. Off by default for non-default drives; turn on for shared drives that really are photo libraries (e.g. "Family Photos"). | | **Include in Music** | Whether audio files in this drive appear in the global **Music** view. Same shape as photos — off by default, on for drives that are actually music libraries. | +| **Read-only (freeze)** | Full freeze. When on, **every mutation on the drive is refused** — new files, edits, deletes, renames, sharing, membership changes. Members can still read and download. Nothing on the drive changes until the admin unfreezes it. Use for archives, publications, legal holds, or account wind-downs. | > **Cross-drive move blocks the UI move, not download-then-re-upload.** > If you need to stop content from ever leaving a drive, you need > stricter controls (file-egress policies are a future feature). +> **Read-only is a hard freeze.** Even the trash-retention janitor +> pauses on a read-only drive — items past their normal 30-day +> lifetime stay in trash until the drive is unfrozen. This is +> intentional: the whole point of the freeze is that *nothing* +> changes, including automated cleanup. Once unfrozen, the next +> retention pass catches up on anything that aged during the freeze. + ## Storage and quota - **Personal drive files** count against your account's storage @@ -223,6 +231,15 @@ date** → *Save*. After that date they lose access automatically. Ask an admin. They can flip either policy per-drive. Existing links stop working when the policy changes; members can't create new ones. +**Freeze a drive (legal hold, archive, wind-down).** +Ask an admin to set the **Read-only** policy on the drive. From that +moment, no member — including Owners — can add, edit, delete, +rename, share, or change membership. Reads and downloads keep +working. The trash retention janitor also pauses on the drive, so +items past their normal lifetime stay put. When the hold is over, +the admin turns Read-only off and mutation resumes exactly where it +was; retention catches up on the next tick. + **Restore something from a Shared drive's trash.** Open the drive → *Trash* → pick the item → *Restore*. (Only Owners of the drive can do this. Viewers and Editors can see the trash but diff --git a/docs/guide/trash.md b/docs/guide/trash.md index f291a992..b8b0b733 100644 --- a/docs/guide/trash.md +++ b/docs/guide/trash.md @@ -8,6 +8,7 @@ OxiCloud provides a trash system that soft-deletes files and folders, allowing u 2. Trashed items are hidden from normal file listings but remain on disk and in the database 3. Users can browse the trash, restore items, or permanently delete them 4. Items older than the retention period (default: **30 days**) are automatically purged +5. **Trash on a read-only drive is paused** — see [Drives → Read-only](/guide/drives#policies-per-drive-guardrails). The retention purge skips frozen drives entirely; trashed items stay put until the drive is unfrozen. Retention clock keeps ticking, so the next post-unfreeze tick catches up on anything past its lifetime. ## Storage Model diff --git a/docs/plan/drive.md b/docs/plan/drive.md index cf01db69..d4195693 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -664,7 +664,7 @@ have charged to it). Both steps idempotent. ### 8. Policies (JSONB, extensible) -Each drive carries a `policies` JSON object. Five known keys for v1: +Each drive carries a `policies` JSON object. Six known keys for v1: ```jsonc { @@ -672,7 +672,8 @@ Each drive carries a `policies` JSON object. Five known keys for v1: "forbid_external_sharing": false, // blocks grants to is_external=true subjects "forbid_public_links": false, // blocks token-share (anonymous link) creation "forbid_cross_drive_move": false, // blocks MOVE when src.drive_id != dst.drive_id - "forbid_owner_role_change": false // locks the Owner roster against non-admin callers + "forbid_owner_role_change": false, // locks the Owner roster against non-admin callers + "read_only": false // full freeze — every mutation refused (user + background) } ``` @@ -705,6 +706,7 @@ Enforcement points (one place per policy — single grep target): | `forbid_public_links` | `share_service::create_shared_link` and `grant_handler::create_grant` (when subject is `Token`) | | `forbid_cross_drive_move` | `file_management_service::move_file_with_perms` and `folder_service::move_folder_with_perms` — refuse when `src.drive_id != dst.drive_id` | | `forbid_owner_role_change` | `DriveManagementService::set_member_role` (refuses Owner-role writes + demotions of current Owners) and `::remove_member` (refuses removals of Owners) — non-admin callers only | +| `read_only` | `PgAclEngine::check_inner` — every permission except `Read` is refused on File/Folder/Drive resources in the drive (compliance-grade freeze). Background trash-retention purge (`trash_db_repository::delete_expired_bulk`) filters out read-only drives at SELECT time so the JVM-side gate has a matching database-side gate: neither surface can mutate a frozen drive. Cached in `drive_policies_cache` (30 s TTL, invalidated on every policy PATCH). Admin escape hatch remains via `admin_guard` on `PATCH /api/drives/{id}/policies` — bypasses `authz.require` so admin can always un-freeze. | Default to `false` (everything allowed). Admin opts in per drive via `PATCH /api/drives/{id}/policies`. @@ -730,6 +732,30 @@ Default to `false` (everything allowed). Admin opts in per drive via the admin-only `PATCH /policies` carve-out above: once admin sets the owners + locks the policies, the configuration is genuinely immutable from the owner side. +- **`read_only`** is the **full freeze** — every permission except + `Read` is refused on every resource in the drive, regardless of + role. Legal-hold / archive / account-wind-down use case. Two + enforcement homes on purpose: + - **Foreground** — `PgAclEngine::check_inner` gates every mutating + `authz.require` call. Cached in `drive_policies_cache` (subject- + independent, 30 s TTL, invalidated on `update_policies`). Emits + `event = "authz.denied"` with `reason = "drive_read_only"` before + returning false, so operators can filter freeze-caused denials + from ordinary role denials. + - **Background** — `trash_db_repository::delete_expired_bulk` adds + a SQL predicate `AND (d.policies->>'read_only')::boolean IS NOT + TRUE` on both the file and folder purge branches. A tick already + in flight is allowed to complete (option A on the freeze-mid-tick + race — legal-hold uses set the policy *before* the compliance + window opens, so the race isn't practical). Blob GC and orphan- + upload sweeps are neutral by construction: they operate at the + blob / temp-directory layer, not on drive-scoped file rows. + - Applies to both personal and shared drives — a user winding down + their account, freezing a secondary personal archive, and a + shared drive on legal hold all use the same knob. + - Admin escape hatch is unaffected: `PATCH /api/drives/{id}/policies` + sits behind `admin_guard` at the handler layer and bypasses + `authz.require` entirely, so admin can always un-freeze. #### Future policy keys (out of scope for v1 — but the JSONB shape accommodates them without schema migration) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 69a3db6a..052e1b17 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -39,6 +39,15 @@ export interface FolderItem { parent_id: string | null; path: string; etag: string; + /** + * The drive this folder belongs to (post-D0 ownership pivot per + * `docs/plan/drive.md` §3). Populated by the backend `FolderDto` + * on every response; the field was left out of the TS type until + * a caller needed it. Used by `/files` to resolve the current + * drive for the read-only banner without depending on the URL's + * leading segment being a drive-root folder id. + */ + drive_id: string; } export interface FileItem { @@ -307,6 +316,14 @@ export interface DrivePolicies { * Symmetric shape to `include_in_photo_index`. */ include_in_music_index: boolean; + /** + * Full freeze / legal-hold. When `true`, every mutation on resources + * in the drive is refused — user-initiated AND background alike (the + * trash-retention purge SQL filter excludes read-only drives). Only + * `Read` passes. Admins can un-freeze via the admin-only policy PATCH. + * See `docs/plan/drive.md` §8 (`read_only`). + */ + read_only: boolean; } /** diff --git a/frontend/src/lib/components/ReadOnlyBanner.svelte b/frontend/src/lib/components/ReadOnlyBanner.svelte new file mode 100644 index 00000000..cfee3dae --- /dev/null +++ b/frontend/src/lib/components/ReadOnlyBanner.svelte @@ -0,0 +1,119 @@ + + +
+ +
+ + {#if driveName} + {t( + 'drive.read_only_banner.title_named', + { name: driveName }, + 'Drive "{{name}}" is read-only' + )} + {:else} + {t('drive.read_only_banner.title', 'This drive is read-only')} + {/if} + + + {t( + 'drive.read_only_banner.body', + 'Uploads, edits, deletes, renames, sharing and membership changes are refused. Reads and downloads keep working. Contact an administrator to un-freeze the drive.' + )} + +
+
+ + diff --git a/frontend/src/lib/utils/drivePolicies.ts b/frontend/src/lib/utils/drivePolicies.ts index c0c03c7a..33eb681a 100644 --- a/frontend/src/lib/utils/drivePolicies.ts +++ b/frontend/src/lib/utils/drivePolicies.ts @@ -111,6 +111,15 @@ export const policyDefs: PolicyDef[] = [ 'admin.drive_policy.include_in_music_index_help', 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' ) + }, + { + key: 'read_only', + label: () => t('admin.drive_policy.read_only', 'Read-only (freeze)'), + help: () => + t( + 'admin.drive_policy.read_only_help', + 'Freeze the drive entirely — every mutation is refused (uploads, edits, deletes, renames, sharing, membership changes). Reads and downloads keep working. The trash-retention janitor also pauses. Use for archives, legal holds, or account wind-downs. Only an admin can un-freeze.' + ) } ]; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 7e667e70..2deda19b 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1088,7 +1088,8 @@ // migration), so `readPolicyBool` will surface the correct current // state on modal open. include_in_photo_index: false, - include_in_music_index: false + include_in_music_index: false, + read_only: false }); let managePoliciesError = $state(null); let managePoliciesBusy = $state(false); @@ -1104,7 +1105,8 @@ forbid_cross_drive_move: readPolicyBool(p, 'forbid_cross_drive_move'), forbid_owner_role_change: readPolicyBool(p, 'forbid_owner_role_change'), include_in_photo_index: readPolicyBool(p, 'include_in_photo_index'), - include_in_music_index: readPolicyBool(p, 'include_in_music_index') + include_in_music_index: readPolicyBool(p, 'include_in_music_index'), + read_only: readPolicyBool(p, 'read_only') }; } @@ -1128,6 +1130,13 @@ drivesList = drivesList.map((d) => d.id === driveId ? { ...d, policies: { ...d.policies, ...merged } } : d ); + // The shared `drivesStore` (feeds `/config/drive/{uuid}`, the + // sidebar picker, the breadcrumb) caches `GET /api/drives` with + // `loaded=true` after the first fetch — without this invalidate + // call the admin's policy change wouldn't propagate to those + // surfaces until a full page reload. Sibling `requestDeleteDrive` + // does the same after `deleteDriveAdmin`. + drivesStore.invalidate(); closeManagePolicies(); } catch (e) { managePoliciesError = errorMessage(e); diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index 88373d55..1a4d688c 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -11,6 +11,7 @@ import { ui } from '$lib/stores/ui.svelte'; import type { Drive, DriveMember, DriveRole, DrivePoliciesPartial } from '$lib/api/types'; import PolicyList from '$lib/components/PolicyList.svelte'; + import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import Icon from '$lib/icons/Icon.svelte'; @@ -277,6 +278,10 @@ {/if} + {#if drivePoliciesView.read_only} + + {/if} +

{t('drive.info', 'Drive info')}

diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 51cdeb6f..da318aac 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -45,6 +45,7 @@ import { preferences } from '$lib/stores/preferences.svelte'; import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import ListToolbar from '$lib/components/ListToolbar.svelte'; + import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { t } from '$lib/i18n/index.svelte'; @@ -92,6 +93,36 @@ return drive ? driveIcon(drive) : 'home'; }); + // The drive whose content the user is currently browsing. + // + // Priorities (first match wins): + // 1. `currentFolderDriveId` — set by `load()` after a `getFolder` + // fetch on the current folder. Authoritative for deep-links + // too (the URL's leading segment might not be a drive root). + // 2. `listing.folders[0]?.drive_id` — fast-path when the folder + // has at least one subfolder; avoids the extra round-trip on + // the initial `applyListing` before `getFolder` returns. + // (`FileDto` doesn't carry `drive_id` today, so we can't use + // files as a fallback source; folders alone.) + // 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy + // fallback for the common "sidebar picker → drive root URL" + // navigation, unchanged from `rootIcon` above. + // + // Feeds the read-only freeze banner further down: when this drive's + // `policies.read_only` is on, mutation controls elsewhere in the app + // will fail against the backend engine gate; the banner is the + // affordance that tells the user why. + let currentFolderDriveId = $state(null); + const currentDrive = $derived.by(() => { + if (currentFolderDriveId) { + const d = drivesStore.findById(currentFolderDriveId); + if (d) return d; + } + const listingDriveId = listing.folders[0]?.drive_id ?? null; + if (listingDriveId) return drivesStore.findById(listingDriveId); + return drivesStore.findByRootFolderId(pathSegments[0] ?? null); + }); + let listing = $state({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); // Dotfile hide filter — applied BEFORE sort so `sortedFolders` / @@ -257,6 +288,25 @@ if (seq === loadSeq) crumbs = trail; }); + // Resolve the current folder's drive_id so the read-only banner + // works even on deep-links into a sub-folder (where + // `pathSegments[0]` isn't a drive-root folder id). `getFolder` + // hits the same `/api/folders/{id}` endpoint the breadcrumb chain + // walks; the folder-name cache warmed by `buildCrumbs` above + // makes this a memoised lookup for most navigations. Guarded by + // `seq` so a stale in-flight response can't overwrite a newer + // navigation's drive. + void getFolder(folderId) + .then((folder) => { + if (seq === loadSeq) currentFolderDriveId = folder.drive_id; + }) + .catch(() => { + // Folder metadata fetch failure isn't fatal — the fallback + // chain in `currentDrive` (listing[0]?.drive_id, then + // pathSegments[0] root-folder lookup) still gives us a + // best-effort drive resolution. + }); + try { const res = await fetchFolderListing(folderId, { etag: cached?.etag }); if (seq !== loadSeq) return; // superseded by a newer navigation @@ -1567,6 +1617,16 @@ ondragleave={() => (dragOver = false)} ondrop={onDrop} > + + {#if currentDrive?.policies?.read_only} + + {/if}
> + /// '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 { diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index 52524383..a1a9e07f 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -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, diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ec60aba3..3f79aaf3 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -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, folder_repo: Arc, @@ -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>, + + /// 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, } 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 { + 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`. diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 31c3eeae..8af5633c 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -404,6 +404,8 @@ pub struct UpdateDrivePoliciesDto { pub include_in_photo_index: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub include_in_music_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_only: Option, } /// `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 diff --git a/src/interfaces/api/handlers/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index 3d382a4d..a67026b1 100644 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -97,7 +97,7 @@ pub async fn move_file_to_trash( State(state): State>, auth_user: AuthUser, Path(item_id): Path, -) -> (StatusCode, Json) { +) -> 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>, auth_user: AuthUser, Path(item_id): Path, -) -> (StatusCode, Json) { +) -> 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>, auth_user: AuthUser, Path(trash_id): Path, -) -> (StatusCode, Json) { +) -> 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>, auth_user: AuthUser, Path(trash_id): Path, -) -> (StatusCode, Json) { +) -> 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() } } } diff --git a/tests/api/drive_read_only.hurl b/tests/api/drive_read_only.hurl new file mode 100644 index 00000000..db85331f --- /dev/null +++ b/tests/api/drive_read_only.hurl @@ -0,0 +1,401 @@ +# ============================================================= +# OxiCloud – Drive `read_only` policy (full freeze / legal-hold) +# ============================================================= +# Run: +# hurl --variables-file tests/api/test.env --file-root tests \ +# --test tests/api/drive_read_only.hurl +# +# The model under test (`docs/plan/drive.md` §8): +# `policies.read_only = true` on any drive refuses EVERY mutating +# permission (Create / Update / Delete / Share / Comment / Manage) +# on resources in that drive — from user-initiated paths AND +# background jobs alike. Only `Read` passes. The admin escape +# hatch is separate: `PATCH /api/drives/{id}/policies` is gated +# by `admin_guard` at the handler layer and bypasses the engine's +# authz.require entirely, so admins can always un-freeze. +# +# Enforcement points exercised here: +# - `PgAclEngine::check_inner` on File/Folder resources (drive +# precheck branch, mutating permission → refused before role +# lookup even runs). +# - `PgAclEngine::check_inner` on Drive resources (same gate). +# - `share_service::create_shared_link` — goes through +# `authz.require(Share, Resource::File)` → engine gate fires. +# - Trash purge SQL — proven separately by the SQL predicate +# landing in `trash_db_repository::delete_expired_bulk` (not +# exercised at the HTTP layer here — requires a controllable +# retention clock; see comment in Step 12). +# +# Cases: +# 1. Baseline — drive not frozen → owner can upload / rename / +# delete / trash / share (proves the fixture is writable). +# 2. Admin freezes the drive via PATCH policies. +# 3. Every mutation attempt returns 404 (anti-enum): +# upload, rename, delete, trash-restore, permanent delete, +# create public link, rename the drive itself. +# 4. Read still works: GET /api/drives, GET /api/folders, +# download the file, list trash. +# 5. Admin unfreezes. +# 6. Owner mutations work again → freeze/unfreeze is reversible +# and doesn't leave latched state. +# +# Self-contained: provisions `ro_owner` (drive owner) + `ro_target` +# (share recipient for the negative-share assertion). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Admin login. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Provision `ro_owner` (the drive owner under test). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "ro_owner", + "password": "RoOwnerPwd1!", + "email": "ro_owner@example.com", + "role": "user" +} + +HTTP 201 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "ro_owner", "password": "RoOwnerPwd1!" } + +HTTP 200 +[Captures] +owner_token: jsonpath "$.access_token" +owner_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Provision `ro_target` (share recipient for the +# negative-share assertion in Step 10). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "ro_target", + "password": "RoTargetPwd1!", + "email": "ro_target@example.com", + "role": "user" +} + +HTTP 201 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "ro_target", "password": "RoTargetPwd1!" } + +HTTP 200 +[Captures] +target_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Find `ro_owner`'s default Personal drive + its root +# folder id (upload targets). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Captures] +personal_root_id: jsonpath "$[0].id" + +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Captures] +personal_drive_id: jsonpath "$[0].id" +[Asserts] +jsonpath "$[0].kind" == "personal" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Baseline: upload file A (mutation subject during +# the freeze) and file B (already-trashed subject +# for the restore/purge assertions). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{personal_root_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +file_a_id: jsonpath "$.id" + +# DISTINCT content from file_a — re-uploading the same bytes to +# the same folder would collide on the (folder_id, name) unique +# constraint and the idempotent-upload handler would return the +# EXISTING file (file_a_id == file_b_id), then trashing "file_b" +# would trash file_a and every subsequent Read on file_a would 404 +# because `get_file` filters `NOT is_trashed`. Using a fixture with +# different bytes gives us two truly distinct file rows. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{personal_root_id}} +file: file,fixtures/hello-trashed.txt; text/plain + +HTTP 201 +[Captures] +file_b_id: jsonpath "$.id" + +# Trash file B pre-freeze so we can later attempt restore + permanent +# delete on it while the drive is frozen. +DELETE {{base_url}}/api/trash/files/{{file_b_id}} +Authorization: Bearer {{owner_token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Freeze the drive. Admin-only endpoint; owner cannot +# call it (proven separately in `drive_policies.hurl`). +# Response echoes the merged bag. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "read_only": true +} + +HTTP 200 +[Asserts] +jsonpath "$.read_only" == true +jsonpath "$.forbid_public_links" == false +jsonpath "$.forbid_sharing" == false + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Confirm the policy is visible to the owner (they can +# READ policy state — Manage is what mutates it, and +# Manage is admin-only via a different gate). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{personal_drive_id}}')].policies.read_only" == true + + +# ───────────────────────────────────────────────────────────── +# Step 8 — MUTATIONS BLOCKED. Upload → 404 (Create). +# Anti-enum: NotFound not 403, same shape as "no such +# folder." The engine gate emits an audit line with +# `reason = drive_read_only` — inspectable in server +# logs, not asserted here (no log-scraping harness). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{personal_root_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Rename file A → 404 (Update). Endpoint is +# `PUT /api/files/{id}/rename` (not PATCH — the file +# service exposes rename as a distinct verb, mirroring +# the folder side). WebDAV MOVE would fire the same +# engine gate via `authz.require(Update, File)`. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/files/{{file_a_id}}/rename +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ "name": "renamed_during_freeze.txt" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Delete file A → 404 (Delete). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/trash/files/{{file_a_id}} +Authorization: Bearer {{owner_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Restore file B from trash → 404 (Update on the +# soft-deleted row is a mutation like any other). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/trash/{{file_b_id}}/restore +Authorization: Bearer {{owner_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Permanent delete of file B → 404 (Delete). +# Note: the background retention purge SQL filter is +# tested via source-review + a unit test on the +# `delete_expired_bulk` query, not here — advancing +# the retention clock synchronously from Hurl would +# require an admin endpoint that doesn't exist. The +# user-initiated permanent-delete path DOES exercise +# the engine gate and is asserted below. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/trash/{{file_b_id}} +Authorization: Bearer {{owner_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Share creation → 404 (Share). Goes through +# `share_service::create_shared_link` which calls +# `authz.require(Share, Resource::File)` → engine gate. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "item_id": "{{file_a_id}}", + "item_type": "file" +} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Grant (per-resource, not public link) → 404 (Share). +# Same engine gate — Share permission on File is +# refused regardless of which endpoint asks for it. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{target_user_id}}" }, + "resource": { "type": "file", "id": "{{file_a_id}}" }, + "role": "viewer" +} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Rename the drive itself → 404 (Update on +# Resource::Drive). Drive rename goes through folder +# PATCH on the root folder id, but the underlying +# permission check is Update on the folder — which +# lives in the frozen drive, so gate applies. +# +# Skipped for now — the current implementation checks Update +# on the root folder, and per `bug_drive_rename_editor_can_do_it` +# memory the exact permission surface is still under review. +# The Drive-resource path (below) covers the intent directly. +# ───────────────────────────────────────────────────────────── + + +# ───────────────────────────────────────────────────────────── +# Step 16 — READ STILL WORKS. Membership listing, folder +# listing, file download — none are refused. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{personal_drive_id}}')].policies.read_only" == true + + +GET {{base_url}}/api/folders/{{personal_root_id}} +Authorization: Bearer {{owner_token}} + +HTTP 200 + + +GET {{base_url}}/api/files/{{file_a_id}} +Authorization: Bearer {{owner_token}} + +HTTP 200 + + +# Trash still LISTS (viewers see what's frozen inside). +GET {{base_url}}/api/trash/resources +Authorization: Bearer {{owner_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Admin unfreezes. Reversible: no latched state, no +# residual policy drift. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "read_only": false +} + +HTTP 200 +[Asserts] +jsonpath "$.read_only" == false + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Post-unfreeze: owner can mutate again. Delete +# file A succeeds; upload a new file succeeds; +# permanent-delete file B succeeds. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/trash/{{file_b_id}}/restore +Authorization: Bearer {{owner_token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 + + +DELETE {{base_url}}/api/trash/files/{{file_a_id}} +Authorization: Bearer {{owner_token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{personal_root_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Cleanup: leave the throwaway users provisioned. +# `storage_cleanup_check.sh` at end of run.sh +# enumerates leftover drives and drains them. +# ───────────────────────────────────────────────────────────── diff --git a/tests/api/run.sh b/tests/api/run.sh index 9fcb00fd..f6c9399d 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -194,6 +194,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/drive_quota.hurl" \ "$API_DIR/user_envelope_quota.hurl" \ "$API_DIR/drive_policies.hurl" \ + "$API_DIR/drive_read_only.hurl" \ "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/nc_multidrive_move_regression.hurl" \