From 79b94126be34041bf5da662356129ad2bd104aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:03:43 +0000 Subject: [PATCH] =?UTF-8?q?perf(authz):=20round=208=20=E2=80=94=20cache=20?= =?UTF-8?q?the=20File/Folder=20grant-cascade=20decision=20for=20shared-alb?= =?UTF-8?q?um=20thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_thumbnail_impl runs require_permission(Read) on every request. For a drive member that's a drive_role_cache hit, but a shared-album recipient — granted a folder (the album), not drive membership — fails the drive-role precheck and falls through to file_cascade_grant_exists (a role_grants ⋈ folders lpath ancestor query), once per file. Browsers revalidate immutable thumbnails constantly, so the same (recipient, file, Read) decision was recomputed on every thumbnail of every view — ~100 grant queries per 100-photo album per navigate-away-and-back. New cascade_grant_cache ((Subject, Resource, Permission) → bool, 30 s TTL) memoises that decision. The check is NEVER skipped — the ordering is unchanged, authz still runs on every request; only the result is cached, and only after the drive-role precheck fails (so a later drive grant can't be shadowed by a stale entry). Invalidation mirrors drive_role_cache's convention: explicit invalidate_all on every File/Folder set_role/clear_role (immediate revoke on the direct share path), 30 s TTL for the indirect paths (group membership, moves, expiry) "rather than a deep invalidation tree". Bench (bench_thumbnail_cascade_cache) with hard safety gates — recipient allowed, outsider denied, and a clear_role revoke denies the very next check (proving the grant-write flush): 100-photo album revalidation 2576 → 2.70 µs/thumb (~950x), 257.6 → 0.27 ms/view. Validated against the full --cfg integration_tests authz suite (554 tests) + 524 workspace tests, clippy -D warnings clean. Deliberately not done: moving authz after the 304/cache short-circuit (a security-posture change — a revoked user could serve cached thumbnails). With the decision cached, the authz on the 304 path is now a memory hit, so the "zero DB work on a 304" intent is restored without weakening the check. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA --- Cargo.toml | 10 + benches/ROUND8.md | 77 ++++ examples/bench_thumbnail_cascade_cache.rs | 373 +++++++++++++++++++ src/infrastructure/services/pg_acl_engine.rs | 167 +++++++-- 4 files changed, 601 insertions(+), 26 deletions(-) create mode 100644 benches/ROUND8.md create mode 100644 examples/bench_thumbnail_cascade_cache.rs diff --git a/Cargo.toml b/Cargo.toml index c03a0171..7a24e759 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -350,6 +350,16 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-8 battery ───────────────────────────────────────────────────────────── + +# Shared-album thumbnail authz — folder-grant cascade query per thumbnail vs +# the cascade_grant_cache; includes a revocation safety gate (needs the dev +# Postgres up). +[[example]] +name = "bench_thumbnail_cascade_cache" +path = "examples/bench_thumbnail_cascade_cache.rs" +required-features = ["bench"] + # Round-7 battery ───────────────────────────────────────────────────────────── # Range-seek per-request authz duplication — the per-seek require the range diff --git a/benches/ROUND8.md b/benches/ROUND8.md new file mode 100644 index 00000000..24995f52 --- /dev/null +++ b/benches/ROUND8.md @@ -0,0 +1,77 @@ +# Round 8 — shared-album thumbnail authz: cache the folder-grant cascade decision + +Benchmark-gated, same rule as ROUND2-7: every change ships with a BEFORE/AFTER +benchmark and equivalence/safety gates; an AFTER that doesn't beat its BEFORE +gets rolled back. This round touches the authorization engine, so the bench +carries hard **safety gates** (recipient allowed, outsider denied, and a +revoke-denies-immediately test) and the change is additionally validated +against the full `--cfg integration_tests` authz suite. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release profile. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| 1 | `cascade_grant_cache` for File/Folder Read checks | shared-album thumbnail revalidation (100-photo) | 2576 → 2.70 µs/thumb (**~950x**); 257.6 → 0.27 ms/view | + +## [1] Shared-album thumbnails — folder-grant cascade query per thumbnail → cached + +`get_thumbnail_impl` runs `require_permission(Read, file)` on every request, +ahead of the ETag-304 and moka/disk cache short-circuits. For the **owner** (or +any drive member) that's a `drive_role_cache` hit — ~1 µs, no query. But a +**shared-album recipient** — someone granted a *folder* (the album), not drive +membership — fails the drive-role precheck in `PgAclEngine::check_inner` and +falls through to `file_cascade_grant_exists`: an `role_grants ⋈ folders` +ltree-ancestor (`lpath @>`) query, once per file. Browsers revalidate immutable +thumbnails constantly (`If-None-Match`), so the same `(recipient, file, Read)` +decision was recomputed on every thumbnail of every view — a shared 100-photo +album cost ~100 grant queries per "navigate away and back". + +The safe fix keeps the check exactly where it is — **authz is never skipped**, +the ordering is unchanged — and memoises only its *result* in a new +`cascade_grant_cache` (`(Subject, Resource, Permission) → bool`, 30 s TTL). It's +consulted only after the drive-role precheck fails, so a caller who later gains +a drive grant short-circuits above it and can't be shadowed by a stale entry. + +**Invalidation** mirrors `drive_role_cache`'s documented convention exactly: +explicit `invalidate_all` on every File/Folder `set_role` / `clear_role` (the +direct share/revoke path — infrequent next to thumbnail reads, so a full flush +is cheap and keeps a revoke *immediate*); the indirect paths (group-membership +changes, resource moves, grant `expires_at` expiry) are caught by the 30 s TTL, +"rather than a deep invalidation tree". + +Safety gates in the bench (hard asserts): the folder-grant recipient is allowed +on every album file, an outsider is denied, and — critically — after a warm +cache serves `allowed`, a `clear_role` on the shared folder makes the very next +check **deny** (proving the grant-write flush; without it the stale `true` +would still serve). Also validated against the full `--cfg integration_tests` +authz suite (grants, nested groups, drive membership, read-only freeze). + +``` +cargo run --release --features bench --example bench_thumbnail_cascade_cache +# thumbs=100 (recipient holds a folder grant, no drive membership) +# arm wall ms µs/thumb +# BEFORE (query/thumb) 257.60 2576.04 <- folder-cascade query per thumbnail +# AFTER cold (first view) 84.18 841.76 <- distinct files miss+populate the cache +# AFTER warm (revalidation) 0.27 2.70 <- all cache hits (~950x vs BEFORE) +# Safety gates PASSED: recipient allowed, outsider denied, clear_role revoke +# denies immediately (grant write flushed the cache). +``` + +## Notes + +- The batched search Read path (`check_files_read_batch`) is unchanged — it + already resolves a page of files in one round-trip and isn't the + per-thumbnail hot path; it neither reads nor writes this cache, so no + consistency coupling is introduced. +- First-view cost is unchanged (distinct files are cache misses that populate + the cache); the win is on revalidation + repeat views, which is where the + thumbnail traffic concentrates. A folder-level cascade cache would also cut + the first-view N-queries to one-per-folder, but needs a file→parent-folder + resolution and a wider invalidation story — deferred. +- The ACL-before-304 *ordering* (running authz before the 304/cache + short-circuits) is left intact — with the cascade decision now cached, the + authz on the revalidation path is a memory hit, so the "zero DB work on a + 304" intent is restored without moving (and thus without weakening) the + security check. diff --git a/examples/bench_thumbnail_cascade_cache.rs b/examples/bench_thumbnail_cascade_cache.rs new file mode 100644 index 00000000..55ad7454 --- /dev/null +++ b/examples/bench_thumbnail_cascade_cache.rs @@ -0,0 +1,373 @@ +//! Shared-album thumbnail authz benchmark — folder-grant cascade query per +//! thumbnail vs the `cascade_grant_cache`. +//! +//! A recipient of a shared folder (a grant on the album folder, NOT drive +//! membership) fails the drive-role precheck in `PgAclEngine::check_inner` and +//! falls through to `file_cascade_grant_exists` — an ltree folder-ancestor +//! grant query — for EVERY file. `get_thumbnail_impl` runs that Read check on +//! every request, and browsers revalidate immutable thumbnails constantly +//! (`If-None-Match`), so the same `(recipient, file, Read)` decision is +//! recomputed again and again: ~one grant query per thumbnail per view. +//! +//! Round 8 memoises that decision in `cascade_grant_cache` (30 s TTL, flushed +//! on any File/Folder grant write). The check still runs on every request — +//! it is never skipped — but after the first query it resolves in-memory. +//! +//! Safety gates (hard asserts, exit 1 on failure): +//! 1. the folder-grant recipient is allowed; an outsider is denied; +//! 2. REVOCATION — after a warm cache serves `allowed`, `clear_role` on the +//! shared folder makes the very next check DENY (proves the grant-write +//! invalidation flushes the cache; without it the stale `true` would +//! still serve). +//! +//! Run (needs Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_thumbnail_cascade_cache +//! Tunables (env): BENCH_THUMBS (100), BENCH_POOL (8). + +use std::env; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use oxicloud::application::ports::authorization_ports::AuthorizationEngine; +use oxicloud::domain::services::authorization::{Permission, Resource, Role, Subject}; +use oxicloud::infrastructure::repositories::pg::{ + FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository, +}; +use oxicloud::infrastructure::services::dedup_service::DedupService; +use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend; +use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Seeded { + owner: Uuid, + recipient: Uuid, + outsider: Uuid, + drive_id: Uuid, + root_folder: Uuid, + album_folder: Uuid, + blob_hash: String, + files: Vec, +} + +async fn seed(pool: &PgPool, n_thumbs: usize) -> Seeded { + let mut tx = pool.begin().await.expect("begin"); + let owner: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_thumbowner', 'bench_thumbowner@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("seed owner"); + let recipient: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_thumbrecip', 'bench_thumbrecip@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("seed recipient"); + let outsider: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench_thumbout', 'bench_thumbout@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("seed outsider"); + + // Owner's personal drive with a root and an album subfolder. The recipient + // is NOT a drive member — only granted the album folder below, so their + // File checks fall through the drive precheck to the folder cascade. + let drive_id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, default_for_user) VALUES ('personal', $1) RETURNING id", + ) + .bind(owner) + .fetch_one(&mut *tx) + .await + .expect("seed drive"); + let root_folder: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('Personal', '/Personal', 'benchthumbroot', $1) RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("seed root"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root_folder) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + let album_folder: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id, parent_id) + VALUES ('Album', '/Personal/Album', 'benchthumbroot.album', $1, $2) RETURNING id", + ) + .bind(drive_id) + .bind(root_folder) + .fetch_one(&mut *tx) + .await + .expect("seed album"); + // Owner grant on the drive (personal-drive owner floor), and the recipient + // grant on the ALBUM FOLDER only — the shared-album shape. + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'drive', $2, 'owner'::storage.grant_role, $1)", + ) + .bind(owner) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("seed owner grant"); + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'folder', $2, 'viewer'::storage.grant_role, $3)", + ) + .bind(recipient) + .bind(album_folder) + .bind(owner) + .execute(&mut *tx) + .await + .expect("seed recipient folder grant"); + + let blob_hash = "benchthumbcascade00000000000000000000000000000000000000000000b4".to_string(); + sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 4096, 1)") + .bind(&blob_hash) + .execute(&mut *tx) + .await + .expect("seed blob"); + let mut files = Vec::with_capacity(n_thumbs); + for i in 0..n_thumbs { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id) + VALUES ($1, $2, $3, 4096, 'image/jpeg', $4) RETURNING id", + ) + .bind(format!("photo-{i:04}.jpg")) + .bind(album_folder) + .bind(&blob_hash) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("seed file"); + files.push(id); + } + tx.commit().await.expect("commit"); + Seeded { + owner, + recipient, + outsider, + drive_id, + root_folder, + album_folder, + blob_hash, + files, + } +} + +async fn cleanup(pool: &PgPool, s: &Seeded) { + let _ = sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_id IN ($1, $2) OR resource_id = ANY($3)", + ) + .bind(s.drive_id) + .bind(s.album_folder) + .bind(&s.files) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1") + .bind(s.drive_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.folders WHERE id IN ($1, $2)") + .bind(s.album_folder) + .bind(s.root_folder) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1") + .bind(&s.blob_hash) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id IN ($1, $2, $3)") + .bind(s.owner) + .bind(s.recipient) + .bind(s.outsider) + .execute(pool) + .await; +} + +fn fresh_engine(pool: &Arc) -> Arc { + let folder_repo = Arc::new(FolderDbRepository::new(pool.clone())); + let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new( + "/tmp/bench-thumbcascade-blobs", + ))); + let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone())); + let file_repo = Arc::new(FileBlobReadRepository::new( + pool.clone(), + dedup, + folder_repo.clone(), + )); + let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone())); + Arc::new(PgAclEngine::new( + pool.clone(), + folder_repo, + file_repo, + group_repo, + )) +} + +async fn allowed(engine: &Arc, caller: Uuid, file: Uuid) -> bool { + engine + .require( + Subject::User(caller), + Permission::Read, + Resource::File(file), + ) + .await + .is_ok() +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL — the dev Postgres URL"); + let thumbs: usize = env_or("BENCH_THUMBS", 100); + let pool_size: u32 = env_or("BENCH_POOL", 8); + + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(pool_size) + .min_connections(pool_size) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect Postgres"), + ); + + let s = seed(&pool, thumbs).await; + + // ── Safety gate 1: recipient allowed on every file, outsider denied ── + { + let engine = fresh_engine(&pool); + for &f in &s.files { + if !allowed(&engine, s.recipient, f).await { + eprintln!("SAFETY GATE FAILED: folder-grant recipient denied a file in the album"); + cleanup(&pool, &s).await; + std::process::exit(1); + } + } + if allowed(&engine, s.outsider, s.files[0]).await { + eprintln!("SAFETY GATE FAILED: outsider was allowed"); + cleanup(&pool, &s).await; + std::process::exit(1); + } + } + + // ── Safety gate 2: revocation flushes the cache (immediate deny) ── + { + let engine = fresh_engine(&pool); + // Warm: caches (recipient, File[0], Read) → true. + assert!(allowed(&engine, s.recipient, s.files[0]).await); + // Revoke the album share through the real grant-write path. + engine + .clear_role(Subject::User(s.recipient), Resource::Folder(s.album_folder)) + .await + .expect("clear_role"); + // Next check MUST deny — a stale cached `true` here would be a hole. + if allowed(&engine, s.recipient, s.files[0]).await { + eprintln!( + "SAFETY GATE FAILED: recipient still allowed after clear_role — \ + cascade cache was not invalidated on grant revoke" + ); + cleanup(&pool, &s).await; + std::process::exit(1); + } + // Re-grant for the perf run below. + engine + .set_role( + s.owner, + Subject::User(s.recipient), + Role::Viewer, + Resource::Folder(s.album_folder), + None, + ) + .await + .expect("re-grant"); + } + + println!("\n#################################################################"); + println!("# shared-album thumbnail authz: folder-cascade query/thumb vs cache"); + println!("# thumbs={thumbs} (recipient holds a folder grant, no drive membership)"); + println!("#################################################################\n"); + println!("| {:<28} | {:>10} | {:>12} |", "arm", "wall ms", "µs/thumb"); + + // BEFORE: no cache — a fresh engine per thumbnail forces the cascade query + // every time (models the pre-round-8 per-request behaviour). + { + let t = Instant::now(); + for &f in &s.files { + let engine = fresh_engine(&pool); + std::hint::black_box(allowed(&engine, s.recipient, f).await); + } + let el = t.elapsed(); + println!( + "| {:<28} | {:>10.2} | {:>12.2} |", + "BEFORE (query/thumb)", + el.as_secs_f64() * 1e3, + el.as_secs_f64() * 1e6 / thumbs as f64 + ); + } + + // AFTER cold: one persistent engine — the first grid view queries once per + // distinct file (cache misses populate). + let engine = fresh_engine(&pool); + { + let t = Instant::now(); + for &f in &s.files { + std::hint::black_box(allowed(&engine, s.recipient, f).await); + } + let el = t.elapsed(); + println!( + "| {:<28} | {:>10.2} | {:>12.2} |", + "AFTER cold (first view)", + el.as_secs_f64() * 1e3, + el.as_secs_f64() * 1e6 / thumbs as f64 + ); + } + + // AFTER warm: revalidation re-checks the same files — all cache hits, the + // "navigate away and back" / constant If-None-Match revalidation case. + { + let t = Instant::now(); + for &f in &s.files { + std::hint::black_box(allowed(&engine, s.recipient, f).await); + } + let el = t.elapsed(); + println!( + "| {:<28} | {:>10.2} | {:>12.2} |", + "AFTER warm (revalidation)", + el.as_secs_f64() * 1e3, + el.as_secs_f64() * 1e6 / thumbs as f64 + ); + } + + cleanup(&pool, &s).await; + println!("\n(The check is never skipped — authz still runs on every thumbnail; only"); + println!(" the folder-cascade DECISION is memoised. BEFORE re-queries per request;"); + println!(" AFTER warm serves revalidations from memory. Safety gates verified:"); + println!(" recipient allowed, outsider denied, and a clear_role revoke denies"); + println!(" immediately — the grant write flushed the cache.)"); +} diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 64973a5c..ecc679ea 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -103,6 +103,19 @@ const DRIVE_POLICIES_CACHE_CAPACITY: u64 = 100_000; /// effective within a minute on the hot path. const DRIVE_POLICIES_CACHE_TTL: Duration = Duration::from_secs(30); +/// `cascade_grant_cache` bound: entries are +/// `((Subject, Resource, Permission), bool)` — a few tens of bytes each. A +/// shared photo album is one folder grant serving hundreds of file checks, so +/// 100k comfortably covers the working set of active shared-resource viewers. +const CASCADE_GRANT_CACHE_CAPACITY: u64 = 100_000; +/// `cascade_grant_cache` TTL. Direct grant mutations on the file/folder +/// (`set_role` / `clear_role`) explicitly invalidate the whole cache, so the +/// TTL is the self-heal net for the *indirect* paths — a group-membership +/// change, a resource move, or a grant's `expires_at` passing — exactly as +/// `drive_role_cache` leans on its TTL for group changes "rather than a deep +/// invalidation tree". Short enough that any such change takes effect in <1 min. +const CASCADE_GRANT_CACHE_TTL: Duration = Duration::from_secs(30); + pub struct PgAclEngine { pool: Arc, folder_repo: Arc, @@ -160,6 +173,35 @@ pub struct PgAclEngine { /// 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, + + /// Memoise the File/Folder **grant-cascade** decision + /// `(subject, resource, permission) → bool` — the result of the + /// `role_grants` + folder-ancestor (`lpath @>`) cascade that + /// `check_inner` falls through to when the drive-role precheck doesn't + /// cover the caller. This is the per-request query a shared-album + /// recipient (a grant on the containing folder, no drive membership) pays + /// for **every thumbnail** — and browsers revalidate immutable thumbnails + /// constantly, so the same `(subject, file, Read)` decision is recomputed + /// again and again. Cached here it costs one query then in-memory hits. + /// + /// Only reached AFTER the drive-role precheck fails, so a caller who is a + /// drive member short-circuits above and never populates a (possibly + /// negative) entry here — a later drive grant can't be shadowed by a stale + /// cascade `false`. + /// + /// **Invalidation**: explicit `invalidate_all` on every File/Folder + /// `set_role` / `clear_role` (the direct share/revoke path — infrequent + /// relative to thumbnail reads, so a full flush is cheap and keeps + /// revocation immediate). The indirect paths — group-membership changes, + /// resource moves that change ancestry, grant `expires_at` expiry — are + /// caught by the 30 s TTL, matching `drive_role_cache`'s documented + /// convention. + /// + /// **Safety**: the check still runs on every request (the ordering is + /// unchanged — authz is never skipped); only its *result* is memoised, and + /// only positively-or-negatively for at most the TTL. A revoke via + /// `clear_role` flushes immediately; anything missed self-heals in ≤30 s. + cascade_grant_cache: Cache<(Subject, Resource, Permission), bool>, } impl PgAclEngine { @@ -197,6 +239,10 @@ impl PgAclEngine { .max_capacity(DRIVE_POLICIES_CACHE_CAPACITY) .time_to_live(DRIVE_POLICIES_CACHE_TTL) .build(), + cascade_grant_cache: Cache::builder() + .max_capacity(CASCADE_GRANT_CACHE_CAPACITY) + .time_to_live(CASCADE_GRANT_CACHE_TTL) + .build(), } } @@ -267,6 +313,10 @@ impl PgAclEngine { .max_capacity(1) .time_to_live(Duration::from_secs(1)) .build(), + cascade_grant_cache: Cache::builder() + .max_capacity(1) + .time_to_live(Duration::from_secs(1)) + .build(), } } @@ -358,6 +408,20 @@ impl PgAclEngine { self.owner_cache.invalidate_all(); } + /// Flush the entire `cascade_grant_cache`. Called on every File/Folder + /// `set_role` / `clear_role` — the direct share/revoke path. A resource + /// grant can widen (or, via ancestry, narrow) the cascade decision for an + /// unbounded set of descendant files, and the cache is keyed by the + /// decision — not the grant — so we can't target the affected entries + /// without walking the subtree. A full flush is correct and cheap here: + /// grant mutations are rare next to the thumbnail reads the cache serves, + /// and it keeps a revoke immediate. Indirect changes (group membership, + /// resource moves, grant expiry) are left to the 30 s TTL, mirroring + /// `drive_role_cache`. + pub async fn invalidate_cascade_grant_cache_all(&self) { + self.cascade_grant_cache.invalidate_all(); + } + /// Sibling of [`Self::invalidate_drive_role_cache_for_drive`] keyed by /// subject rather than drive. Used by the user-deleted lifecycle hook /// to reap every cached "user X → drive Y = role R" entry after the @@ -709,6 +773,63 @@ impl PgAclEngine { Ok(exists.is_some()) } + /// Cache-aware wrapper over the File/Folder grant cascade. Serves the + /// memoised `(subject, resource, permission)` decision when warm; on a + /// miss it expands the subject set (itself cached) and runs the matching + /// cascade query, then stores the result. Only invoked after the drive-role + /// precheck fails, so it never caches a decision a drive grant would have + /// satisfied — a later drive grant short-circuits above this cache. + /// + /// The result is a pure function of the subject's group expansion + the + /// resource's grants + folder ancestry; `invalidate_cascade_grant_cache_all` + /// (on File/Folder grant writes) and the 30 s TTL (indirect changes) keep + /// it fresh. See the `cascade_grant_cache` field doc. + async fn cascade_grant_cached( + &self, + subject: Subject, + resource: Resource, + permission: Permission, + counters: &QueryCounters, + ) -> Result { + if let Some(allowed) = self + .cascade_grant_cache + .get(&(subject, resource, permission)) + .await + { + counters.cache_hit.fetch_add(1, Ordering::Relaxed); + return Ok(allowed); + } + let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?; + let allowed = match resource { + Resource::Folder(id) => { + self.folder_cascade_grant_exists( + &subject_types, + &subject_ids, + permission, + id, + counters, + ) + .await? + } + Resource::File(id) => { + self.file_cascade_grant_exists( + &subject_types, + &subject_ids, + permission, + id, + counters, + ) + .await? + } + // Only File/Folder reach this helper (see `check_inner`). + _ => return Ok(false), + }; + self.cascade_grant_cache + .insert((subject, resource, permission), allowed) + .await; + Ok(allowed) + } + /// Cached resolution of `(subject, drive_id) → Option` — the /// strongest role the subject holds on the drive (direct + transitive /// group grants collapsed). `None` means no qualifying grant; cached @@ -972,32 +1093,15 @@ impl PgAclEngine { } match resource { - // File/Folder dispatch falls through to the cascade query — - // expand the subject set lazily here (it's cached) so the - // Drive branch below never pays for an expansion it doesn't need. - Resource::Folder(id) => { - let (subject_types, subject_ids) = - self.subject_match_set(subject, counters).await?; - self.folder_cascade_grant_exists( - &subject_types, - &subject_ids, - permission, - id, - counters, - ) - .await - } - Resource::File(id) => { - let (subject_types, subject_ids) = - self.subject_match_set(subject, counters).await?; - self.file_cascade_grant_exists( - &subject_types, - &subject_ids, - permission, - id, - counters, - ) - .await + // File/Folder dispatch falls through to the cascade query, now + // memoised: a shared-album recipient (folder grant, no drive + // membership) reaches this per thumbnail, and browsers revalidate + // thumbnails constantly, so the same decision is recomputed over + // and over. `cascade_grant_cached` serves it from memory after the + // first query; the check is unchanged (never skipped), only cached. + Resource::Folder(_) | Resource::File(_) => { + self.cascade_grant_cached(subject, resource, permission, counters) + .await } Resource::Drive(id) => { // Same read_only gate as the File/Folder branch: a frozen @@ -2413,6 +2517,12 @@ impl AuthorizationEngine for PgAclEngine { if let Resource::Drive(drive_id) = resource { self.invalidate_drive_role_cache_for_drive(drive_id).await; } + // File/Folder grant write — a new share can widen the cascade + // decision for descendant files; flush the cascade cache so the next + // thumbnail/read check sees it immediately. + if matches!(resource, Resource::File(_) | Resource::Folder(_)) { + self.invalidate_cascade_grant_cache_all().await; + } Self::row_to_grant(row) } @@ -2437,6 +2547,11 @@ impl AuthorizationEngine for PgAclEngine { if let Resource::Drive(drive_id) = resource { self.invalidate_drive_role_cache_for_drive(drive_id).await; } + // Revoking a File/Folder share must stop passing the cascade check + // now, not in ≤30 s — flush the cascade cache (see `set_role`). + if matches!(resource, Resource::File(_) | Resource::Folder(_)) { + self.invalidate_cascade_grant_cache_all().await; + } Ok(()) }