perf(authz): round 8 — cache the File/Folder grant-cascade decision for shared-album thumbnails

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-18 14:03:43 +00:00
parent 7626dc95c1
commit 79b94126be
4 changed files with 601 additions and 26 deletions
+10
View File
@@ -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
+77
View File
@@ -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.
+373
View File
@@ -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<T: std::str::FromStr>(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<Uuid>,
}
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<PgPool>) -> Arc<PgAclEngine> {
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<PgAclEngine>, 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.)");
}
+141 -26
View File
@@ -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<PgPool>,
folder_repo: Arc<FolderDbRepository>,
@@ -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<Uuid, DrivePolicies>,
/// 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<bool, DomainError> {
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<Role>` — 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(())
}