//! PostgreSQL-backed implementation of `AuthorizationEngine`. //! //! Stores grants in `storage.access_grants` (see migration //! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check //! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, //! using the existing GiST index for O(log N) traversal. //! //! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id` //! are checked first via dedicated helpers; if the caller is the owner, no //! SQL against `access_grants` happens. //! //! ## Lifecycle cleanup //! //! In v1, cleanup of grant rows when a resource or subject is permanently //! deleted is enforced by **DB triggers** (`trg_cleanup_grants_*` in the //! migration). The application layer does not call `revoke_all_for_*` //! explicitly today — the triggers are the canonical path because they //! also catch bulk SQL maintenance, admin scripts, and any code path that //! bypasses the service layer. //! //! The `revoke_all_for_resource` / `revoke_all_for_subject` methods exist //! on the trait for future use cases: //! - **Caching** (planned) — a `CachedAuthorizationEngine` decorator needs //! to see the invalidation event at the engine boundary, not just at the //! SQL level. When caching lands, services will start calling these //! methods explicitly before/around delete operations. //! - **Alternate engines** (OpenFGA, future) — engines that don't share a //! DB transaction with the resource table need an explicit signal to //! delete their tuples. use std::collections::HashSet; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; use uuid::Uuid; use moka::future::Cache; use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::entities::subject_group::INTERNAL_GROUP_ID; use crate::domain::repositories::subject_group_repository::SubjectGroupRepository; use crate::domain::services::authorization::{ Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary, Permission, Resource, ResourceKind, Subject, }; use crate::infrastructure::repositories::pg::SubjectGroupPgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; /// Per-call counters surfaced through `tracing::debug!` for performance /// observability: cache hit-rate, SQL traffic, transitive expansion size. /// /// Sub-microsecond cost when debug logging is off (one atomic write per /// increment, no allocation, no formatting). #[derive(Default)] struct QueryCounters { cache_hit: AtomicU32, sql_queries: AtomicU32, expanded_groups: AtomicU32, } pub struct PgAclEngine { pool: Arc, folder_repo: Arc, file_repo: Arc, /// Group repository — `None` only in test stubs that don't exercise authz. group_repo: Option>, /// Memoise `user_id → transitive group set` for 30 s. Bounded to 50 000 /// entries; eviction is LRU + TTL. Stale by up to TTL after a membership /// change — acceptable trade-off (see plan, "Cache TTL behaviour"). user_groups_cache: Cache>>, } impl PgAclEngine { pub fn new( pool: Arc, folder_repo: Arc, file_repo: Arc, group_repo: Arc, ) -> Self { Self { pool, folder_repo, file_repo, group_repo: Some(group_repo), user_groups_cache: Cache::builder() .max_capacity(50_000) .time_to_live(Duration::from_secs(30)) .build(), } } /// Creates a stub instance for tests that need to construct services /// without a real PostgreSQL pool. Connecting to the lazy pool will /// fail at runtime — only safe in tests that exercise types, not actual /// authz queries. #[cfg(test)] pub fn new_stub() -> Self { let pool = sqlx::pool::PoolOptions::::new() .max_connections(1) .connect_lazy("postgres://invalid:5432/none") .unwrap(); Self { pool: Arc::new(pool), folder_repo: Arc::new(FolderDbRepository::new_stub()), file_repo: Arc::new(FileBlobReadRepository::new_stub()), group_repo: None, user_groups_cache: Cache::builder() .max_capacity(1) .time_to_live(Duration::from_secs(1)) .build(), } } /// Drop the cached transitive-group expansion for one user, forcing /// the next `expand_user(uid)` to walk the recursive CTE again. /// /// Called by [`AuthzCacheLifecycleHook`] on `on_user_logout` / /// `on_user_deleted` so a re-login (or a re-created account with the /// same id) doesn't observe stale memberships during the 30 s TTL /// window. Cheap — moka's `invalidate` is a single concurrent-map op. pub async fn invalidate_user_groups_cache(&self, user_id: Uuid) { self.user_groups_cache.invalidate(&user_id).await; } /// 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 /// `INTERNAL_GROUP_ID`. /// /// External users (`auth.users.is_external = TRUE`) do NOT belong to /// the Internal virtual group — they are grant-only recipients whose /// access is determined exclusively by explicit grants on their /// `user_id` or on subject groups they were explicitly added to. /// `SubjectGroupService::add_member` rejects externals, so the only /// path by which an external user reaches a resource is via a /// `subject_type='user'` grant. /// /// This is the **only** place transitive membership is walked. A future /// closure-table swap-in (Option 3 in the design doc) replaces just the /// `repo.groups_for_user` call below — every caller stays unchanged. async fn expand_user( &self, user_id: Uuid, counters: &QueryCounters, ) -> Result>, DomainError> { if let Some(cached) = self.user_groups_cache.get(&user_id).await { counters.cache_hit.store(1, Ordering::Relaxed); counters .expanded_groups .store(cached.len() as u32, Ordering::Relaxed); return Ok(cached); } let mut set: HashSet = HashSet::new(); set.insert(user_id); // Look up `is_external` for the caller — external users do not // belong to the Internal virtual group. Unknown user (no row) is // treated as external to fail closed: a deleted or bogus user_id // must not gain implicit Internal membership. counters.sql_queries.fetch_add(1, Ordering::Relaxed); let is_external: bool = sqlx::query_scalar("SELECT is_external FROM auth.users WHERE id = $1") .bind(user_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| { DomainError::internal_error("PgAcl", format!("lookup is_external: {e}")) })? .unwrap_or(true); if !is_external { set.insert(INTERNAL_GROUP_ID); } if let Some(repo) = &self.group_repo { counters.sql_queries.fetch_add(1, Ordering::Relaxed); let direct = repo.groups_for_user(user_id).await.map_err(|e| { DomainError::internal_error("PgAcl", format!("groups_for_user: {e}")) })?; set.extend(direct); } counters .expanded_groups .store(set.len() as u32, Ordering::Relaxed); let arc = Arc::new(set); self.user_groups_cache.insert(user_id, arc.clone()).await; Ok(arc) } /// Expand a caller's `Subject` into the `(subject_types, subject_ids)` /// pair that should be matched in `storage.access_grants`. For User /// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`; /// for any non-user subject (Token / External / Group as direct caller) /// it's a single-element pair with no cascade. /// /// Shared by `check_inner` (permission decision) and the /// `list_incoming_*` queries ("Shared with me") so that any folder/file /// the user can `read` via a group grant also appears in their incoming /// listing. Shares the `expand_user` Moka cache, so the listing call /// right after a permission check is a cache hit. async fn subject_match_set( &self, subject: Subject, counters: &QueryCounters, ) -> Result<(Vec<&'static str>, Vec), DomainError> { match subject { Subject::User(uid) => { let expanded = self.expand_user(uid, counters).await?; Ok((vec!["user", "group"], expanded.iter().copied().collect())) } _ => Ok((vec![subject.type_str()], vec![subject.id()])), } } /// Returns the owner UUID for any resource type. async fn owner_of(&self, resource: Resource) -> Result { match resource { Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await, Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await, } } /// Cascading check for folders: is there a grant on any ancestor folder /// (including the target itself) for any of the given subject IDs and /// any of the given subject types? /// /// `subject_types` is `["user", "group"]` when the caller is a User /// (so we match both their own grants and their group-mediated grants), /// or a single-element slice for Token / External / Group-direct callers. /// `subject_ids` is the expanded set returned by `expand_user` (or a /// single-element vec for non-user callers). /// /// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade. async fn folder_cascade_grant_exists( &self, subject_types: &[&str], subject_ids: &[Uuid], permission: Permission, folder_id: Uuid, counters: &QueryCounters, ) -> Result { counters.sql_queries.fetch_add(1, Ordering::Relaxed); let exists: Option = sqlx::query_scalar( r#" SELECT 1 FROM storage.access_grants g JOIN storage.folders gf ON gf.id = g.resource_id WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) AND g.permission = $3 AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) LIMIT 1 "#, ) .bind(subject_types) .bind(subject_ids) .bind(permission.as_str()) .bind(folder_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("folder cascade: {e}")))?; Ok(exists.is_some()) } /// Cascading check for files: either a direct file grant OR a grant on /// any ancestor folder of the file's containing folder. See /// `folder_cascade_grant_exists` for the meaning of `subject_types` / /// `subject_ids`. async fn file_cascade_grant_exists( &self, subject_types: &[&str], subject_ids: &[Uuid], permission: Permission, file_id: Uuid, counters: &QueryCounters, ) -> Result { counters.sql_queries.fetch_add(1, Ordering::Relaxed); let exists: Option = sqlx::query_scalar( r#" SELECT 1 FROM ( -- direct file grant SELECT 1 FROM storage.access_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) AND permission = $3 AND resource_type = 'file' AND resource_id = $4 AND (expires_at IS NULL OR expires_at > NOW()) UNION ALL -- cascading from any ancestor folder of the file's containing folder SELECT 1 FROM storage.access_grants g JOIN storage.folders gf ON gf.id = g.resource_id JOIN storage.files target_f ON target_f.id = $4 WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) AND g.permission = $3 AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND target_f.folder_id IS NOT NULL AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = target_f.folder_id) ) any_match LIMIT 1 "#, ) .bind(subject_types) .bind(subject_ids) .bind(permission.as_str()) .bind(file_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?; Ok(exists.is_some()) } /// Look up a single grant by id. Returns `(resource, granted_by)` so /// the REST `DELETE /api/grants/{id}` handler can decide authorization /// without a second round-trip. Returns `Ok(None)` if no such grant. pub async fn find_grant_by_id( &self, grant_id: Uuid, ) -> Result, DomainError> { let row: Option<(String, Uuid, Uuid)> = sqlx::query_as( "SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1", ) .bind(grant_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?; let Some((rt, rid, granter)) = row else { return Ok(None); }; let res = Resource::from_parts(&rt, rid) .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; Ok(Some((res, granter))) } /// Row type for all full-grant SELECT queries: /// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at) #[allow(clippy::type_complexity)] fn row_to_grant( row: ( Uuid, String, Uuid, String, Uuid, String, Uuid, chrono::DateTime, Option>, ), ) -> Result { let subject = Subject::from_parts(&row.1, row.2) .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?; let resource = Resource::from_parts(&row.3, row.4) .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; let permission = Permission::parse(&row.5) .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?; Ok(Grant { id: row.0, subject, resource, permission, granted_by: row.6, granted_at: row.7, expires_at: row.8, }) } /// The actual permission decision. Wrapped by `check()` which adds /// per-call instrumentation. async fn check_inner( &self, subject: Subject, permission: Permission, resource: Resource, counters: &QueryCounters, ) -> Result { // Owner short-circuit (only for User subjects — groups/tokens/external // are never owners of resources). if let Subject::User(uid) = subject { counters.sql_queries.fetch_add(1, Ordering::Relaxed); match self.owner_of(resource).await { Ok(owner) if owner == uid => return Ok(true), Ok(_) => { /* not owner — fall through to grants */ } Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => { // Resource doesn't exist — no permission. Return false // rather than propagating NotFound; the caller (`require`) // converts a false back to NotFound on its own. return Ok(false); } Err(e) => return Err(e), } } // Expand the subject so group-mediated grants apply when the caller // is a User. See `subject_match_set` for the shared shape used by // both the cascade check and the "shared with me" listing queries. let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?; 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 } } } } impl AuthorizationEngine for PgAclEngine { async fn check( &self, subject: Subject, permission: Permission, resource: Resource, ) -> Result { let start = std::time::Instant::now(); let counters = QueryCounters::default(); let result = self .check_inner(subject, permission, resource, &counters) .await; // Single structured debug line per check. No-op when subscriber // filter is at INFO or above. See plan, "Debug instrumentation". tracing::debug!( target: "oxicloud::authz", event = "authz.check", subject = %subject, permission = %permission, resource = %resource, allowed = result.as_ref().copied().unwrap_or(false), duration_us = start.elapsed().as_micros() as u64, cache_hit = counters.cache_hit.load(Ordering::Relaxed) > 0, sql_queries = counters.sql_queries.load(Ordering::Relaxed), expanded_groups = counters.expanded_groups.load(Ordering::Relaxed), ); result } async fn list_incoming_grants( &self, subject: Subject, permission_filter: Option, ) -> Result, DomainError> { let perm_str = permission_filter.map(|p| p.as_str().to_string()); let counters = QueryCounters::default(); let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?; let rows = sqlx::query_as::< _, ( Uuid, String, Uuid, String, Uuid, String, Uuid, chrono::DateTime, Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) AND ($3::text IS NULL OR permission = $3) ORDER BY granted_at DESC "#, ) .bind(&subject_types) .bind(&subject_ids) .bind(perm_str) .fetch_all(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("list incoming: {e}")))?; rows.into_iter().map(Self::row_to_grant).collect() } async fn list_incoming_resources_paged( &self, subject: Subject, kinds: &[ResourceKind], limit: u32, cursor: Option, sort_by: &str, reverse: bool, ) -> Result<(Vec, Option), DomainError> { // ── Common setup ────────────────────────────────────────────────────── let kind_strs: Option> = if kinds.is_empty() { None } else { Some(kinds.iter().map(|k| k.as_str()).collect()) }; let fetch_limit = (limit as i64) + 1; // Unified row type — the last two columns carry the sort key when present, // NULL otherwise. This lets every sort mode share a single query_as call. // 0 resource_type String // 1 resource_id Uuid // 2 permissions Vec // 3 granted_at DateTime // 4 granted_by Uuid // 5 sort_str Option — resource_name (name/type) or owner_name (granted_by) // 6 sort_int Option — category_order (type) or file size in bytes (size) type Row = ( String, Uuid, Vec, chrono::DateTime, Uuid, Option, Option, ); // Extract all cursor fields up-front; each branch uses the subset it needs. // Fixed parameter positions used in all SQL variants: // $4 = cursor_str (resource_name / owner_name) // $5 = cursor_int (type_order) // $6 = cursor_at (granted_at) // $7 = cursor_id (resource_id) // $8 = fetch_limit let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); let cursor_at = cursor.as_ref().map(|c| c.granted_at); let cursor_id = cursor.as_ref().map(|c| c.resource_id); // ── agg CTE (identical in all branches) ─────────────────────────────── // `subject_type`/`subject_id` are arrays here: for a User caller this // is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the // listing includes every resource the user can reach via a group // grant (matching what `check()` allows). See `subject_match_set`. const AGG: &str = r#"agg AS ( SELECT resource_type, resource_id, array_agg(DISTINCT permission ORDER BY permission) AS permissions, MIN(granted_at) AS granted_at, (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by FROM storage.access_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) AND ($3::text[] IS NULL OR resource_type = ANY($3)) GROUP BY resource_type, resource_id )"#; // ── Build sort-specific SQL fragments ───────────────────────────────── // "name" and "type" share the same LEFT JOINs; only sort_int_expr, // the cursor WHERE condition, and ORDER BY differ. // Each branch emits two variants selected by `reverse`. let sql = match sort_by { "name" | "type" => { let sort_int_expr = if sort_by == "type" { "CASE WHEN agg.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" } else { "NULL::bigint" }; // Normal vs reversed keyset + ORDER BY. let (where_clause, order_clause) = if sort_by == "type" { if reverse { ( r#"( $5::integer IS NULL OR sort_int < $5 OR (sort_int = $5 AND LOWER(sort_str) < $4) OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#, "sort_int DESC, LOWER(sort_str) DESC, resource_id DESC", ) } else { ( r#"( $5::integer IS NULL OR sort_int > $5 OR (sort_int = $5 AND LOWER(sort_str) > $4) OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#, "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC", ) } } else if reverse { ( r#"( $4::text IS NULL OR LOWER(sort_str) < $4 OR (LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#, "LOWER(sort_str) DESC, resource_id DESC", ) } else { ( r#"( $4::text IS NULL OR LOWER(sort_str) > $4 OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#, "LOWER(sort_str) ASC, resource_id ASC", ) }; format!( r#"WITH {AGG}, named AS ( SELECT agg.*, COALESCE( CASE WHEN agg.resource_type = 'folder' THEN f.name END, CASE WHEN agg.resource_type = 'file' THEN fi.name END ) AS sort_str, {sort_int_expr} AS sort_int FROM agg LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder' LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' ) SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int FROM named WHERE {where_clause} ORDER BY {order_clause} LIMIT $8"# ) } "granted_by" => { // Joins auth.users to sort alphabetically by username. // Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7). let (where_clause, order_clause) = if reverse { ( r#"( $4::text IS NULL OR sort_str < $4 OR (sort_str = $4 AND ( $6::timestamptz IS NULL OR granted_at > $6 OR (granted_at = $6 AND resource_id > $7::uuid))))"#, "sort_str DESC, granted_at ASC, resource_id ASC", ) } else { ( r#"( $4::text IS NULL OR sort_str > $4 OR (sort_str = $4 AND ( $6::timestamptz IS NULL OR granted_at < $6 OR (granted_at = $6 AND resource_id < $7::uuid))))"#, "sort_str ASC, granted_at DESC, resource_id DESC", ) }; format!( r#"WITH {AGG}, owner_named AS ( SELECT agg.*, LOWER(u.username) AS sort_str, NULL::bigint AS sort_int FROM agg LEFT JOIN auth.users u ON u.id = agg.granted_by ) SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int FROM owner_named WHERE {where_clause} ORDER BY {order_clause} LIMIT $8"# ) } _ => { // Default: sort by grant date. // Normal = DESC (newest first); reversed = ASC (oldest first). // Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused. let (where_clause, order_clause) = if reverse { ( r#"( $6::timestamptz IS NULL OR granted_at > $6 OR (granted_at = $6 AND resource_id > $7::uuid))"#, "granted_at ASC, resource_id ASC", ) } else { ( r#"( $6::timestamptz IS NULL OR granted_at < $6 OR (granted_at = $6 AND resource_id < $7::uuid))"#, "granted_at DESC, resource_id DESC", ) }; format!( r#"WITH {AGG} SELECT resource_type, resource_id, permissions, granted_at, granted_by, NULL::text AS sort_str, NULL::bigint AS sort_int FROM agg WHERE {where_clause} ORDER BY {order_clause} LIMIT $8"# ) } }; // Expand the caller so group-mediated grants surface in the listing, // mirroring `check()`. Shares the Moka cache (`expand_user`). let counters = QueryCounters::default(); let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?; // ── Execute — uniform 8 binds for every sort mode ───────────────────── let mut rows: Vec = sqlx::query_as::<_, Row>(&sql) .bind(&subject_types) // $1 .bind(&subject_ids) // $2 .bind(&kind_strs) // $3 .bind(&cursor_str) // $4 sort_str cursor .bind(cursor_int) // $5 sort_int cursor .bind(cursor_at) // $6 granted_at cursor .bind(cursor_id) // $7 resource_id cursor .bind(fetch_limit) // $8 .fetch_all(self.pool.as_ref()) .await .map_err(|e| { DomainError::internal_error( "PgAcl", format!("list_incoming_resources_paged ({sort_by}): {e}"), ) })?; // ── Pagination ──────────────────────────────────────────────────────── let has_next = rows.len() > limit as usize; rows.truncate(limit as usize); let next_cursor = if has_next { rows.last().map(|r| { let sort_str_lc = r.5.as_deref().map(str::to_lowercase); match sort_by { "name" => GrantCursor { sort_by: "name".to_owned(), granted_at: r.3, resource_id: r.1, resource_name: sort_str_lc, sort_int: None, reverse, }, "type" => GrantCursor { sort_by: "type".to_owned(), granted_at: r.3, resource_id: r.1, resource_name: sort_str_lc, sort_int: r.6, reverse, }, "granted_by" => GrantCursor { sort_by: "granted_by".to_owned(), granted_at: r.3, resource_id: r.1, resource_name: r.5.clone(), // already lowercased by SQL sort_int: None, reverse, }, _ => GrantCursor { sort_by: "granted_at".to_owned(), granted_at: r.3, resource_id: r.1, resource_name: None, sort_int: None, reverse, }, } }) } else { None }; // ── Convert rows to domain summaries ────────────────────────────────── let summaries = rows .into_iter() .filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| { let resource_type = ResourceKind::parse(&rt)?; let permissions = perms_str .into_iter() .filter_map(|s| Permission::parse(&s)) .collect(); Some(IncomingGrantSummary { resource_type, resource_id: rid, permissions, granted_at, granted_by, }) }) .collect(); Ok((summaries, next_cursor)) } async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError> { let rows = sqlx::query_as::< _, ( Uuid, String, Uuid, String, Uuid, String, Uuid, chrono::DateTime, Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2 ORDER BY granted_at DESC "#, ) .bind(resource.type_str()) .bind(resource.id()) .fetch_all(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("list on resource: {e}")))?; rows.into_iter().map(Self::row_to_grant).collect() } async fn list_outgoing_resources_paged( &self, granted_by: Uuid, limit: u32, cursor: Option, sort_by: &str, reverse: bool, ) -> Result<(Vec, Option), DomainError> { let fetch_limit = (limit as i64) + 1; // Row shape — one row per (resource, subject, permission). // Columns: // 0 resource_type String // 1 resource_id Uuid // 2 first_shared_at DateTime — MIN(granted_at) across resource // 3 subject_type String // 4 subject_id Uuid // 5 subject_display String — username or share item_name // 6 grant_id Uuid // 7 granted_at DateTime — this (subject, perm) row // 8 expires_at Option> // 9 permission String // 10 sort_str Option // 11 sort_int Option // 12 has_password bool — token: shares.password_hash IS NOT NULL type Row = ( String, Uuid, chrono::DateTime, String, Uuid, String, Uuid, chrono::DateTime, Option>, String, Option, Option, bool, ); let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); let cursor_at = cursor.as_ref().map(|c| c.granted_at); let cursor_id = cursor.as_ref().map(|c| c.resource_id); // ── Resource-page CTE (one row per resource, cursor-paginated) ───────── // We page on resources (by first_shared_at + resource_id) so that the // limit/cursor semantics are consistent with the incoming endpoint. // All grants for each paged resource are then retrieved in the same query. // // $1 = granted_by // $2 = cursor_str (resource_name for name/type, owner_name for granted_by) // $3 = cursor_int (category_order for type, size for size) // $4 = cursor_at (first_shared_at) // $5 = cursor_id (resource_id) // $6 = fetch_limit let sql = match sort_by { "name" | "type" => { let sort_int_expr = if sort_by == "type" { "CASE WHEN ag.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" } else { "NULL::bigint" }; let (page_where, page_order) = if sort_by == "type" { if reverse { ( r#"( $3::integer IS NULL OR sort_int < $3 OR (sort_int = $3 AND LOWER(sort_str) < $2) OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, "sort_int DESC, LOWER(sort_str) DESC, resource_id DESC", ) } else { ( r#"( $3::integer IS NULL OR sort_int > $3 OR (sort_int = $3 AND LOWER(sort_str) > $2) OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC", ) } } else if reverse { ( r#"( $2::text IS NULL OR LOWER(sort_str) < $2 OR (LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, "LOWER(sort_str) DESC, resource_id DESC", ) } else { ( r#"( $2::text IS NULL OR LOWER(sort_str) > $2 OR (LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, "LOWER(sort_str) ASC, resource_id ASC", ) }; format!( r#"WITH resource_page AS ( SELECT ag.resource_type, ag.resource_id, MIN(ag.granted_at) AS first_shared_at, COALESCE( CASE WHEN ag.resource_type = 'folder' THEN f.name END, CASE WHEN ag.resource_type = 'file' THEN fi.name END ) AS sort_str, {sort_int_expr} AS sort_int FROM storage.access_grants ag LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder' LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file' WHERE ag.granted_by = $1 GROUP BY ag.resource_type, ag.resource_id, f.name, fi.name, fi.category_order ), rp AS ( SELECT * FROM resource_page WHERE {page_where} ORDER BY {page_order} LIMIT $6 ) SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, ag.subject_type, ag.subject_id, COALESCE(u.username, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, rp.sort_str, rp.sort_int, (sh.password_hash IS NOT NULL) AS has_password FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.granted_by = $1 LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN auth.subject_groups sg ON ag.subject_type = 'group' AND sg.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id -- Per-resource grant ordering: groups → users → password-protected -- links → public links (matches the "Shared with" subject sort). -- Resource ordering comes from {page_order}; the CASE only -- breaks ties within one resource. ORDER BY {page_order}, CASE WHEN ag.subject_type = 'group' THEN 0 WHEN ag.subject_type = 'user' THEN 1 WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2 ELSE 3 END ASC, LOWER(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) ASC, ag.granted_at"# ) } "subject" => { // Page on (subject_type_order, subject_display, resource_id) triples so // every swimlane is always contiguous across cursor pages. // // subject_type_order: 0 = group, 1 = user, 2 = token with password, // 3 = token without password // — picked so the My Shares "Shared with" view naturally renders the // higher-trust principals (groups, then named users) above the // lower-trust ones (anonymous link tokens). // // Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display), // resource_id = last resource_id. let (page_where, page_order) = if reverse { ( r#"( $3::bigint IS NULL OR sort_int < $3 OR (sort_int = $3 AND LOWER(subject_display) < $2) OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", ) } else { ( r#"( $3::bigint IS NULL OR sort_int > $3 OR (sort_int = $3 AND LOWER(subject_display) > $2) OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", ) }; format!( r#"WITH pairs AS ( SELECT ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id, MAX(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, MAX(CASE WHEN ag.subject_type = 'group' THEN 0 WHEN ag.subject_type = 'user' THEN 1 WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2 ELSE 3 END)::bigint AS sort_int, MIN(ag.granted_at) AS first_granted_at FROM storage.access_grants ag LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN auth.subject_groups sg ON ag.subject_type = 'group' AND sg.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id WHERE ag.granted_by = $1 AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id ), rp AS ( SELECT * FROM pairs WHERE {page_where} ORDER BY {page_order} LIMIT $6 ) SELECT ag.resource_type, ag.resource_id, rp.first_granted_at AS first_shared_at, ag.subject_type, ag.subject_id, rp.subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, LOWER(rp.subject_display) AS sort_str, rp.sort_int, rp.has_password FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.subject_type = rp.subject_type AND ag.subject_id = rp.subject_id AND ag.granted_by = $1 AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) ORDER BY {page_order}"# ) } "role" => { // Page on (role_order, subject_display, resource_id) triples so that all // of one person's grants within a role are contiguous — enabling aggregation // ("Bob on Folder A, Folder B") to work correctly across cursor pages. // role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer // Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id let (page_where, page_order) = if reverse { ( r#"( $3::bigint IS NULL OR sort_int < $3 OR (sort_int = $3 AND LOWER(subject_display) < $2) OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", ) } else { ( r#"( $3::bigint IS NULL OR sort_int > $3 OR (sort_int = $3 AND LOWER(subject_display) > $2) OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", ) }; format!( r#"WITH pairs AS ( SELECT ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id, MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, CASE WHEN BOOL_OR(ag.permission = 'delete') AND BOOL_OR(ag.permission = 'share') THEN 0 WHEN BOOL_OR(ag.permission = 'create') OR BOOL_OR(ag.permission = 'update') THEN 1 ELSE 2 END::bigint AS sort_int, MIN(ag.granted_at) AS first_granted_at FROM storage.access_grants ag LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id WHERE ag.granted_by = $1 AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id ), rp AS ( SELECT * FROM pairs WHERE {page_where} ORDER BY {page_order} LIMIT $6 ) SELECT ag.resource_type, ag.resource_id, rp.first_granted_at AS first_shared_at, ag.subject_type, ag.subject_id, rp.subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, LOWER(rp.subject_display) AS sort_str, rp.sort_int, rp.has_password FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.subject_type = rp.subject_type AND ag.subject_id = rp.subject_id AND ag.granted_by = $1 AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) ORDER BY {page_order}"# ) } _ => { // Default: sort by first_shared_at DESC (newest resource shared first). let (page_where, page_order) = if reverse { ( r#"( $4::timestamptz IS NULL OR first_shared_at > $4 OR (first_shared_at = $4 AND resource_id > $5::uuid))"#, "first_shared_at ASC, resource_id ASC", ) } else { ( r#"( $4::timestamptz IS NULL OR first_shared_at < $4 OR (first_shared_at = $4 AND resource_id < $5::uuid))"#, "first_shared_at DESC, resource_id DESC", ) }; format!( r#"WITH resource_page AS ( SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at, NULL::text AS sort_str, NULL::bigint AS sort_int FROM storage.access_grants WHERE granted_by = $1 GROUP BY resource_type, resource_id ), rp AS ( SELECT * FROM resource_page WHERE {page_where} ORDER BY {page_order} LIMIT $6 ) SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, ag.subject_type, ag.subject_id, COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, NULL::text AS sort_str, NULL::bigint AS sort_int, (sh.password_hash IS NOT NULL) AS has_password FROM rp JOIN storage.access_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.granted_by = $1 LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id ORDER BY {page_order}, ag.subject_id, ag.granted_at"# ) } }; let rows: Vec = sqlx::query_as::<_, Row>(&sql) .bind(granted_by) // $1 .bind(&cursor_str) // $2 sort_str cursor .bind(cursor_int) // $3 sort_int cursor .bind(cursor_at) // $4 first_shared_at cursor .bind(cursor_id) // $5 resource_id cursor .bind(fetch_limit) // $6 .fetch_all(self.pool.as_ref()) .await .map_err(|e| { DomainError::internal_error( "PgAcl", format!("list_outgoing_resources_paged ({sort_by}): {e}"), ) })?; // ── Subject / Role sorts: page on (resource_id, subject_id) pairs ─────── // Each pair becomes one OutgoingResourceSummary with exactly one grant, // preserving the SQL-ordered swimlane sequence across cursor pages. if matches!(sort_by, "subject" | "role") { let mut seen_pairs: Vec<(Uuid, Uuid)> = Vec::new(); let mut seen_pair_set: std::collections::HashSet<(Uuid, Uuid)> = std::collections::HashSet::new(); for r in &rows { if seen_pair_set.insert((r.1, r.4)) { seen_pairs.push((r.1, r.4)); } } let has_next = seen_pairs.len() > limit as usize; seen_pairs.truncate(limit as usize); let keep: std::collections::HashSet<(Uuid, Uuid)> = seen_pairs.iter().copied().collect(); let last_row = rows.iter().rfind(|r| keep.contains(&(r.1, r.4))); let next_cursor = if has_next { last_row.map(|r| { let resource_name = r.10.clone(); // LOWER(subject_display) for both subject and role sort GrantCursor { sort_by: sort_by.to_owned(), granted_at: r.2, resource_id: r.1, resource_name, sort_int: r.11, reverse, } }) } else { None }; // Group rows: (resource_id, subject_id) → OutgoingGrantEntry. let mut entry_map: std::collections::HashMap< (Uuid, Uuid), (ResourceKind, OutgoingGrantEntry), > = std::collections::HashMap::new(); for r in rows.into_iter().filter(|r| keep.contains(&(r.1, r.4))) { let ( rt_str, resource_id, _first_shared_at, subj_type, subj_id, subj_display, grant_id, granted_at, expires_at, perm_str, _, _, has_password, ) = r; let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; }; let Some(perm) = Permission::parse(&perm_str) else { continue; }; let key = (resource_id, subj_id); let (_, entry) = entry_map.entry(key).or_insert_with(|| { ( resource_type, OutgoingGrantEntry { grant_id, subject_type: subj_type.clone(), subject_id: subj_id, subject_display: subj_display.clone(), permissions: Vec::new(), granted_at, expires_at, has_password, }, ) }); if !entry.permissions.contains(&perm) { entry.permissions.push(perm); } } let summaries: Vec = seen_pairs .into_iter() .filter_map(|(rid, sid)| { let (resource_type, grant) = entry_map.remove(&(rid, sid))?; Some(OutgoingResourceSummary { resource_type, resource_id: rid, first_shared_at: grant.granted_at, grants: vec![grant], }) }) .collect(); return Ok((summaries, next_cursor)); } // ── All other sorts: page on distinct resource_ids ──────────────────── let mut seen_resources: Vec = Vec::new(); let mut seen_set: std::collections::HashSet = std::collections::HashSet::new(); for r in &rows { if seen_set.insert(r.1) { seen_resources.push(r.1); } } let has_next = seen_resources.len() > limit as usize; seen_resources.truncate(limit as usize); let keep: std::collections::HashSet = seen_resources.iter().copied().collect(); let last_row = rows.iter().rfind(|r| keep.contains(&r.1)); let next_cursor = if has_next { last_row.map(|r| { let sort_str_lc = r.10.as_deref().map(str::to_lowercase); match sort_by { "name" => GrantCursor { sort_by: "name".to_owned(), granted_at: r.2, resource_id: r.1, resource_name: sort_str_lc, sort_int: None, reverse, }, "type" => GrantCursor { sort_by: "type".to_owned(), granted_at: r.2, resource_id: r.1, resource_name: sort_str_lc, sort_int: r.11, reverse, }, _ => GrantCursor { sort_by: "first_shared_at".to_owned(), granted_at: r.2, resource_id: r.1, resource_name: None, sort_int: None, reverse, }, } }) } else { None }; // Group flat rows by resource_id → (ResourceKind, first_shared_at, subjects). type ResourceEntry = ( ResourceKind, chrono::DateTime, std::collections::HashMap, ); let mut resource_map: std::collections::HashMap = std::collections::HashMap::new(); for r in rows.into_iter().filter(|r| keep.contains(&r.1)) { let ( rt_str, resource_id, first_shared_at, subj_type, subj_id, subj_display, grant_id, granted_at, expires_at, perm_str, _, _, has_password, ) = r; let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; }; let Some(perm) = Permission::parse(&perm_str) else { continue; }; let (_, _, subj_map) = resource_map.entry(resource_id).or_insert_with(|| { ( resource_type, first_shared_at, std::collections::HashMap::new(), ) }); let entry = subj_map .entry(subj_id) .or_insert_with(|| OutgoingGrantEntry { grant_id, subject_type: subj_type.clone(), subject_id: subj_id, subject_display: subj_display.clone(), permissions: Vec::new(), granted_at, expires_at, has_password, }); if !entry.permissions.contains(&perm) { entry.permissions.push(perm); } } let summaries: Vec = seen_resources .into_iter() .filter_map(|rid| { let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?; let mut grants: Vec = subj_map.into_values().collect(); // Per-resource subject ordering (matches the subject-sort // branch's SQL CASE): // 0 = group, 1 = user, 2 = token-with-password, 3 = token, // 4 = external. // Alphabetical tiebreak by display name. This intentionally // ignores role/permission tier — the share dialog renders // role as a separate pill; ordering by subject type is the // UX contract. let subject_rank = |e: &OutgoingGrantEntry| -> u8 { match e.subject_type.as_str() { "group" => 0, "user" => 1, "token" if e.has_password => 2, "token" => 3, _ => 4, } }; grants.sort_by(|a, b| { subject_rank(a).cmp(&subject_rank(b)).then_with(|| { a.subject_display .to_lowercase() .cmp(&b.subject_display.to_lowercase()) }) }); Some(OutgoingResourceSummary { resource_type, resource_id: rid, first_shared_at, grants, }) }) .collect(); Ok((summaries, next_cursor)) } async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError> { let rows = sqlx::query_as::< _, ( Uuid, String, Uuid, String, Uuid, String, Uuid, chrono::DateTime, Option>, ), >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at FROM storage.access_grants WHERE granted_by = $1 ORDER BY granted_at DESC "#, ) .bind(granted_by) .fetch_all(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("list outgoing: {e}")))?; rows.into_iter().map(Self::row_to_grant).collect() } async fn grant( &self, granted_by: Uuid, subject: Subject, permission: Permission, resource: Resource, expires_at: Option>, ) -> Result { let row = sqlx::query_as::< _, ( Uuid, String, Uuid, String, Uuid, String, Uuid, chrono::DateTime, Option>, ), >( r#" INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) DO UPDATE SET expires_at = EXCLUDED.expires_at RETURNING id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at "#, ) .bind(subject.type_str()) .bind(subject.id()) .bind(resource.type_str()) .bind(resource.id()) .bind(permission.as_str()) .bind(granted_by) .bind(expires_at) .fetch_one(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?; Self::row_to_grant(row) } async fn set_expiry_for_subject( &self, subject: Subject, expires_at: Option>, ) -> Result<(), DomainError> { sqlx::query( "UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2", ) .bind(subject.type_str()) .bind(subject.id()) .bind(expires_at) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?; Ok(()) } async fn set_expiry_on_resource( &self, subject: Subject, resource: Resource, expires_at: Option>, ) -> Result<(), DomainError> { sqlx::query( "UPDATE storage.access_grants SET expires_at = $3 \ WHERE subject_type = $1 AND subject_id = $2 \ AND resource_type = $4 AND resource_id = $5", ) .bind(subject.type_str()) .bind(subject.id()) .bind(expires_at) .bind(resource.type_str()) .bind(resource.id()) .execute(self.pool.as_ref()) .await .map_err(|e| { DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")) })?; Ok(()) } async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { sqlx::query("DELETE FROM storage.access_grants WHERE id = $1") .bind(grant_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?; Ok(()) } async fn revoke_all_for_resource(&self, resource: Resource) -> Result { let result = sqlx::query( "DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2", ) .bind(resource.type_str()) .bind(resource.id()) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?; Ok(result.rows_affected() as usize) } async fn revoke_all_for_subject(&self, subject: Subject) -> Result { let result = sqlx::query( "DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2", ) .bind(subject.type_str()) .bind(subject.id()) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?; Ok(result.rows_affected() as usize) } } // ───────────────────────────────────────────────────────────────────────────── // AuthzCacheLifecycleHook // // Owns invalidation of the `user_groups_cache` Moka entry when a user's // state changes in ways that affect transitive-group expansion (logout // — so a re-login with new group memberships doesn't observe a stale // expansion during the 30 s TTL window; delete — so a re-created // account with the same id doesn't inherit the old cached value). // // Lives in this file (not under a centralised `lifecycle/` directory) // because the authz engine owns its own cache invariants. See the // "owner-located convention" note in // `docs/architecture/user-lifecycle.md`. // ───────────────────────────────────────────────────────────────────────────── use async_trait::async_trait; use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; use crate::domain::entities::user::User; /// Lifecycle hook: drops the `user_groups_cache` entry for one user on /// logout / deletion so the next authz check rebuilds it from current /// `subject_group_members` rows. pub struct AuthzCacheLifecycleHook { engine: Arc, } impl AuthzCacheLifecycleHook { pub fn new(engine: Arc) -> Self { Self { engine } } } #[async_trait] impl UserLifecycleHook for AuthzCacheLifecycleHook { fn name(&self) -> &'static str { "authz_cache" } async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> { // New user can't have a stale cache entry (no prior `expand_user` // call has produced one). Explicit no-op per the trait convention. Ok(()) } async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> { // Login doesn't change group membership; the cache (if present) // is still correct. Ok(()) } async fn on_user_logout(&self, user: &User, _reason: LogoutReason) -> Result<(), DomainError> { self.engine.invalidate_user_groups_cache(user.id()).await; Ok(()) } async fn on_user_deleted( &self, user: &User, _mode: DeletionMode, _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError> { // No DB writes here — just memory invalidation. `_tx` is // intentionally ignored. self.engine.invalidate_user_groups_cache(user.id()).await; Ok(()) } }