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
+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(())
}