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:
+10
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.)");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user