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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user