feat(drive): repl user_id by caller has read access

repl user_id by caller has read access in readonly functions
    using CALLER_CAN_READ_DRIVE constant

    ensure webdav preview is using the permission handler
This commit is contained in:
Edouard Vanbelle
2026-07-02 00:45:11 +02:00
parent 79bc60899b
commit 09790644f3
9 changed files with 249 additions and 148 deletions
@@ -43,6 +43,37 @@ use crate::domain::services::path_service::StoragePath;
use crate::infrastructure::services::dedup_service::DedupService;
use uuid::Uuid;
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
/// any active `role_grants` on the drive owning `fi` (the aliased file
/// row). Group memberships (direct + transitive) are expanded inline via
/// `storage.caller_group_ids($1)` (recursive; see migration
/// `20260901000002_caller_group_ids_function.sql`).
///
/// Used by every drive-scoped file search query in this repo:
/// - `search_files_paginated`
/// - `search_files_in_subtree`
///
/// **Alias contract**: queries splicing this in MUST alias
/// `storage.files` as `fi`. `$1` is reserved for `caller_id`; other bind
/// params start at `$2`.
///
/// This mirrors — but is intentionally not shared with — the folder
/// variant in `folder_db_repository.rs` (aliased `fo.drive_id`) and the
/// drive-listing shapes in `drive_pg_repository`/`list_media_files`.
/// When the grant model changes, update all sites in parallel.
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
SELECT 1 \
FROM storage.role_grants g \
WHERE g.resource_type = 'drive' \
AND g.resource_id = fi.drive_id \
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
AND ( \
(g.subject_type = 'user' AND g.subject_id = $1) \
OR (g.subject_type = 'group' AND g.subject_id IN \
(SELECT storage.caller_group_ids($1))) \
) \
)";
/// Type alias for file metadata rows from SQL queries.
/// Fields: id, name, folder_id, folder_path, size, mime_type,
/// created_at, updated_at, blob_hash, user_id, created_by, updated_by.
@@ -200,7 +231,7 @@ impl FileBlobReadRepository {
&self,
ids: &[String],
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<File>, DomainError> {
// Index hits are externally produced strings — parse defensively.
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
@@ -208,9 +239,15 @@ impl FileBlobReadRepository {
return Ok(Vec::new());
}
// Post-PR-B: drive-membership scoping via
// [`CALLER_CAN_READ_DRIVE`] (bound to `$1`) replaces the legacy
// `fi.user_id = $caller` predicate. Group grants are honoured
// inline through `storage.caller_group_ids`.
//
// Bind order: $1 = caller_id, $2 = ids array, $3.. = criteria.
let mut conditions: Vec<String> = vec![
"fi.id = ANY($1)".to_string(),
"fi.user_id = $2".to_string(),
CALLER_CAN_READ_DRIVE.to_string(),
"fi.id = ANY($2)".to_string(),
"fi.is_trashed = false".to_string(),
];
let mut bind_idx = 2u32;
@@ -242,8 +279,8 @@ impl FileBlobReadRepository {
);
let mut query = sqlx::query_as::<_, FileRow>(&sql)
.bind(uuid_ids)
.bind(user_id);
.bind(caller_id)
.bind(uuid_ids);
if let Some(folder_id) = criteria.folder_id.as_deref() {
query = query.bind(folder_id);
}
@@ -1263,11 +1300,15 @@ impl FileReadPort for FileBlobReadRepository {
/// Uses `COUNT(*) OVER()` window function to return the total matching
/// count alongside the paginated rows in a **single query** — no separate
/// COUNT round-trip.
///
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
/// rather than the legacy `fi.user_id = $caller` predicate. Group
/// grants are honoured inline through `storage.caller_group_ids`.
async fn search_files_paginated(
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
let offset = criteria.offset as i64;
let limit = criteria.limit as i64;
@@ -1285,10 +1326,10 @@ impl FileReadPort for FileBlobReadRepository {
// ── Build dynamic WHERE + bind indices ───────────────────────────
let mut conditions: Vec<String> = vec![
"fi.user_id = $1".to_string(),
CALLER_CAN_READ_DRIVE.to_string(),
"fi.is_trashed = false".to_string(),
];
let mut bind_idx = 1u32; // $1 = user_id
let mut bind_idx = 1u32; // $1 = caller_id
if folder_id.is_some() {
bind_idx += 1;
@@ -1341,7 +1382,7 @@ impl FileReadPort for FileBlobReadRepository {
i64,
),
>(&sql)
.bind(user_id);
.bind(caller_id);
if let Some(fid) = folder_id {
query = query.bind(fid);
@@ -1386,16 +1427,20 @@ impl FileReadPort for FileBlobReadRepository {
///
/// Uses `COUNT(*) OVER()` to return the total count alongside the
/// paginated rows — no separate COUNT round-trip.
///
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
/// rather than the legacy `fi.user_id = $caller` predicate — same
/// group-cascade semantics as `search_files_paginated`.
async fn search_files_in_subtree(
&self,
root_folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
// When no root folder specified, delegate to existing paginated search
let root_id = match root_folder_id {
None => {
return self.search_files_paginated(None, criteria, user_id).await;
return self.search_files_paginated(None, criteria, caller_id).await;
}
Some(id) => id,
};
@@ -1416,10 +1461,10 @@ impl FileReadPort for FileBlobReadRepository {
// ── Build dynamic WHERE clauses ──
let mut conditions = Vec::new();
let mut bind_idx = 2u32; // $1 = user_id, $2 = root_folder_id
let mut bind_idx = 2u32; // $1 = caller_id, $2 = root_folder_id
conditions.push("fi.is_trashed = false".to_string());
conditions.push("fi.user_id = $1".to_string());
conditions.push(CALLER_CAN_READ_DRIVE.to_string());
conditions.push(
"fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid)".to_string(),
);
@@ -1472,7 +1517,7 @@ impl FileReadPort for FileBlobReadRepository {
i64,
),
>(&sql)
.bind(user_id)
.bind(caller_id)
.bind(root_id);
if let Some(name) = &criteria.name_contains
@@ -1513,10 +1558,10 @@ impl FileReadPort for FileBlobReadRepository {
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<usize, DomainError> {
let (_, count) = self
.search_files_paginated(folder_id, criteria, user_id)
.search_files_paginated(folder_id, criteria, caller_id)
.await?;
Ok(count)
}
@@ -75,6 +75,39 @@ type FolderRowOptUser = (
Option<Uuid>,
);
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
/// any active `role_grants` on the drive owning `fo` (the aliased folder
/// row). Group memberships (direct + transitive) are expanded inline via
/// `storage.caller_group_ids($1)` (recursive; see migration
/// `20260901000002_caller_group_ids_function.sql`).
///
/// Used by every drive-scoped folder query in this repo:
/// - `list_root_folders_for_caller` / `_paginated`
/// - `search_folders` (all three branches)
/// - `list_descendant_folders`
///
/// **Alias contract**: queries splicing this in MUST alias
/// `storage.folders` as `fo`. `$1` is reserved for `caller_id`; other
/// bind params start at `$2`.
///
/// This mirrors — but is not shared with — the drive-membership shape in
/// `drive_pg_repository::list_readable_by` (uses `JOIN` on `d.id`) and
/// the media-scoping subqueries in `file_blob_read_repository` (use
/// `IN (SELECT d.id …)` with the `include_in_photo_index` policy
/// filter). When the grant model changes, update all sites in parallel.
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
SELECT 1 \
FROM storage.role_grants g \
WHERE g.resource_type = 'drive' \
AND g.resource_id = fo.drive_id \
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
AND ( \
(g.subject_type = 'user' AND g.subject_id = $1) \
OR (g.subject_type = 'group' AND g.subject_id IN \
(SELECT storage.caller_group_ids($1))) \
) \
)";
/// PostgreSQL-backed folder repository.
///
/// All folder metadata lives in the `storage.folders` table. The physical
@@ -412,35 +445,26 @@ impl FolderRepository for FolderDbRepository {
// `project_caller_role_on_file_folder_dto` and the note at the
// top of `folder_repository.rs`. Frontend cross-references
// `/api/drives::caller_role` via `folder.drive_id`.
let rows: Vec<FolderRow> = sqlx::query_as(
r#"
SELECT f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
f.created_by, f.updated_by
FROM storage.folders f
WHERE f.parent_id IS NULL
AND NOT f.is_trashed
AND EXISTS (
SELECT 1
FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
)
ORDER BY f.name
"#,
)
.bind(caller_id)
.fetch_all(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_root_folders: {e}")))?;
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND NOT fo.is_trashed \
AND {CALLER_CAN_READ_DRIVE} \
ORDER BY fo.name"
);
let rows: Vec<FolderRow> = sqlx::query_as(&sql)
.bind(caller_id)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_root_folders: {e}"))
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
@@ -531,41 +555,30 @@ impl FolderRepository for FolderDbRepository {
limit: usize,
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
let rows: Vec<FolderRowPaginated> = sqlx::query_as(
r#"
SELECT f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
f.created_by, f.updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders f
WHERE f.parent_id IS NULL
AND NOT f.is_trashed
AND EXISTS (
SELECT 1
FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
)
ORDER BY f.name
LIMIT $2 OFFSET $3
"#,
)
.bind(caller_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_root_folders_paginated: {e}"))
})?;
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND NOT fo.is_trashed \
AND {CALLER_CAN_READ_DRIVE} \
ORDER BY fo.name \
LIMIT $2 OFFSET $3"
);
let rows: Vec<FolderRowPaginated> = sqlx::query_as(&sql)
.bind(caller_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_root_folders_paginated: {e}"))
})?;
let total = if include_total {
Some(rows.first().map_or(0, |r| r.11) as usize)
@@ -1018,19 +1031,22 @@ impl FolderRepository for FolderDbRepository {
///
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
/// - Recursive + folder_id: delegates to `list_descendant_folders`
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
/// - Recursive + no folder_id: drive-scoped `EXISTS role_grants` [AND LIKE]
///
/// Post-PR-B: filters by drive-membership grants inline (via
/// `caller_group_ids`) instead of `user_id = $caller`.
#[allow(clippy::type_complexity)]
async fn search_folders(
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder scope → existing optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, user_id)
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
@@ -1049,7 +1065,7 @@ impl FolderRepository for FolderDbRepository {
};
if recursive {
// Recursive, no folder scope → ALL user folders
// Recursive, no folder scope → ALL folders in caller's readable drives
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
@@ -1058,7 +1074,7 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
WHERE {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
@@ -1066,13 +1082,13 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.fetch_all(self.pool())
.await
}
@@ -1086,7 +1102,8 @@ impl FolderRepository for FolderDbRepository {
.collect();
}
// Non-recursive: direct children of parent_id, filtered by user
// Non-recursive: direct children of parent_id, restricted to drives
// the caller can read (parent_id already establishes the subtree).
let sql = if parent_id.is_some() {
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
@@ -1096,14 +1113,14 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
WHERE fo.parent_id = $2::uuid \
AND {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
)
} else {
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
let name_clause_root = match name_contains {
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
_ => "",
@@ -1117,7 +1134,7 @@ impl FolderRepository for FolderDbRepository {
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
AND {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause_root} \
ORDER BY fo.name"
@@ -1127,27 +1144,27 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowOptUser> = if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(caller_id)
.bind(pid)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(caller_id)
.bind(pid)
.bind(user_id)
.fetch_all(self.pool())
.await
}
} else if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.fetch_all(self.pool())
.await
}
@@ -1160,16 +1177,19 @@ impl FolderRepository for FolderDbRepository {
.collect()
}
/// Lists all descendant folders in a subtree using ltree GiST index.
/// Lists all descendant folders in a subtree using ltree GiST index,
/// scoped to drives the caller can read.
///
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
/// subtree in one indexed scan. Optional name filter is pushed to SQL.
/// subtree in one indexed scan. Post-PR-B: drive-membership filter
/// inline via `caller_group_ids`, replacing the legacy
/// `fo.user_id = $caller` predicate.
#[allow(clippy::type_complexity)]
async fn list_descendant_folders(
&self,
folder_id: &str,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> {
let (where_extra, name_pattern) = match name_contains {
Some(name) if name.len() >= 3 => {
@@ -1183,10 +1203,10 @@ impl FolderRepository for FolderDbRepository {
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
WHERE {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid) \
AND fo.id != $2::uuid \
@@ -1196,14 +1216,14 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(folder_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(folder_id)
.fetch_all(self.pool())
.await