feat(drive): add readonly policy

permmit admin to freeze a drive, trash janitor background job is also disabled for this drive
This commit is contained in:
Edouard Vanbelle
2026-07-15 22:21:15 +02:00
parent 346e2e879c
commit a6427fc028
33 changed files with 1094 additions and 95 deletions
+17
View File
@@ -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
+1
View File
@@ -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
+28 -2
View File
@@ -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)