fix(authz): invalidate role cache on change

This commit is contained in:
Edouard Vanbelle
2026-07-06 23:01:37 +02:00
parent 79f4792709
commit f7deb7aaf4
7 changed files with 134 additions and 10 deletions
+68 -1
View File
@@ -283,6 +283,62 @@ impl PgAclEngine {
}
}
/// Drop the `owner_cache` entry for `resource`. Called after any
/// operation that changes which drive a file/folder belongs to —
/// the pre-D6 comment on `owner_cache` ("a resource's owner is
/// immutable") stopped being true when cross-drive MOVE landed.
///
/// Without this call, admin (or any other role holder) on the
/// destination drive gets `authz.denied` when acting on the moved
/// resource: the cached (stale) `Resource → src_drive_id` lookup
/// steers the drive-role precheck at `check_inner` toward the
/// SOURCE drive where the caller has no role, and the fallback
/// per-resource cascade doesn't cover drive-level grants. TTL
/// backstops eventually (5 min), but every write path that MOVEs
/// content across drives MUST invalidate here so authz observes
/// the new drive on the next check.
pub async fn invalidate_owner_cache_for_resource(&self, resource: Resource) {
self.owner_cache.invalidate(&resource).await;
}
/// Bulk cousin of [`Self::invalidate_owner_cache_for_resource`] —
/// clears the entire `owner_cache`. Called by folder cross-drive
/// MOVE where the moved subtree's descendants each carry their
/// own stale entry, and we don't (yet) walk the subtree to
/// invalidate them individually. The cache repopulates lazily on
/// next access; the overhead is a single JOIN per file/folder
/// touched in the following minute or two, versus a stale-authz
/// bug that returned `NotFound` for legitimate Delete.
pub async fn invalidate_owner_cache_all(&self) {
self.owner_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
/// user row (and its DB-cascade-cleared role_grants) is gone. Without
/// this call the entry lingers until TTL; in practice auth rejection
/// on the deleted user's tokens fires first, but leaving stale
/// authorisation rows in the cache is poor hygiene and would surface
/// as an issue if a session survived (e.g. long-lived Basic Auth via
/// app password) or if a same-uuid user were ever recreated.
pub async fn invalidate_drive_role_cache_for_subject(&self, subject: Subject) {
if let Err(err) = self
.drive_role_cache
.invalidate_entries_if(move |key, _v| key.0 == subject)
{
tracing::error!(
target: "oxicloud::authz",
event = "authz.cache_invalidation_failed",
cache = "drive_role_cache",
subject = ?subject,
error = %err,
"drive_role_cache cannot be bulk-invalidated by subject — \
cache builder is missing support_invalidation_closures()",
);
}
}
/// Expand a user subject into the set of subject UUIDs that should match
/// in `access_grants`: the user's own UUID, every group the user is
/// transitively a member of, and (for internal users only) the implicit
@@ -2099,8 +2155,19 @@ impl UserLifecycleHook for AuthzCacheLifecycleHook {
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
// No DB writes here — just memory invalidation. `_tx` is
// intentionally ignored.
// intentionally ignored. The DB cascade
// (`trg_cleanup_role_grants_user`) already dropped every
// role_grants row for this subject; we mirror that cleanup on
// both authz caches:
// 1. `user_groups_cache` — recomputed group expansion.
// 2. `drive_role_cache` — cached "user X → drive Y = role R"
// entries seeded by prior authz checks. Without this
// the deleted user's role stays visible in-process for
// up to the cache TTL (~30 s).
self.engine.invalidate_user_groups_cache(user.id()).await;
self.engine
.invalidate_drive_role_cache_for_subject(Subject::User(user.id()))
.await;
Ok(())
}
}