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 @@ + + +
+ + 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