feat(drive): prepare removal of user_id
this commit changes GET /api/<resources> to return resource caller has access to
this is not anymmore resources users is owner of
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
//! The repo deals only with the `storage.drives` table itself. Drive
|
||||
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
|
||||
//! and is queried through the engine's existing grant paths;
|
||||
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
|
||||
//! `list_readable_by` below resolves `role_grants` → `storage.drives`
|
||||
//! via a single join.
|
||||
//!
|
||||
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
|
||||
@@ -75,7 +75,7 @@ impl DrivePgRepository {
|
||||
/// is declared owner→viewer (strongest→weakest), so `MIN` picks the
|
||||
/// strongest of the caller's grants on the drive (direct +
|
||||
/// group-mediated collapsed by GROUP BY). Used only by
|
||||
/// `list_for_subjects`.
|
||||
/// `list_readable_by`.
|
||||
fn row_to_drive_with_name_and_role(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
@@ -463,13 +463,15 @@ impl DriveRepository for DrivePgRepository {
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn list_for_subjects(
|
||||
async fn list_readable_by(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
|
||||
// Joining role_grants → drives → folders returns every drive the
|
||||
// expanded subject set can read, paired with its display name.
|
||||
// caller can read, paired with its display name. Group
|
||||
// memberships (direct + transitive) are expanded inline by
|
||||
// `storage.caller_group_ids($caller)` — no Rust-side ceremony.
|
||||
//
|
||||
// ORDER BY puts default drives first (so the picker UI doesn't
|
||||
// need a follow-up sort), then alphabetical by name. GROUP BY
|
||||
// collapses duplicate role_grants on the same drive (direct +
|
||||
@@ -481,8 +483,6 @@ impl DriveRepository for DrivePgRepository {
|
||||
// weakest), so MIN returns the strongest. Cast `::text` matches
|
||||
// the codebase convention for reading enum columns into Rust
|
||||
// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
|
||||
// Collapses direct + group-mediated grants on the same drive
|
||||
// into one row alongside the existing GROUP BY.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
@@ -495,8 +495,11 @@ impl DriveRepository for DrivePgRepository {
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
WHERE (
|
||||
(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)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
d.quota_bytes, d.used_bytes, d.policies,
|
||||
@@ -505,16 +508,10 @@ impl DriveRepository for DrivePgRepository {
|
||||
LOWER(f.name) ASC
|
||||
"#,
|
||||
)
|
||||
.bind(
|
||||
subject_types
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.bind(subject_ids)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
||||
.map_err(|e| Self::map_sqlx_err("list_readable_by", e))?;
|
||||
|
||||
rows.iter()
|
||||
.map(Self::row_to_drive_with_name_and_role)
|
||||
|
||||
@@ -452,29 +452,28 @@ impl FileBlobReadRepository {
|
||||
/// drive_id already ordered by capture date, so LIMIT stops the scan
|
||||
/// early. Same O(LIMIT) shape as the pre-D7 `user_id`-keyed hot path.
|
||||
///
|
||||
/// Scope (`docs/plan/drive.md` §15): the caller's *effective subjects*
|
||||
/// × drives with `policies.include_in_photo_index = true`. Default
|
||||
/// personal drives always match because the flag is materialised to
|
||||
/// `true` at drive creation (see
|
||||
/// Scope (`docs/plan/drive.md` §15): drives with
|
||||
/// `policies.include_in_photo_index = true` where the caller has a
|
||||
/// direct grant (`subject_type = 'user'`) OR a grant on a group they
|
||||
/// belong to transitively. Group membership is expanded inline by the
|
||||
/// `storage.caller_group_ids(caller)` SQL function (migration
|
||||
/// `20260901000002_caller_group_ids_function.sql`) — no ceremony at
|
||||
/// the handler layer, no cross-space ambiguity from the earlier
|
||||
/// parallel-arrays pattern.
|
||||
///
|
||||
/// Default personal drives always match because the flag is
|
||||
/// materialised to `true` at drive creation (see
|
||||
/// `DriveRepository::create_personal_drive_atomic` + the backfill
|
||||
/// migration `20260901000000_default_personal_photo_music_flags.sql`)
|
||||
/// — no per-kind carve-out needed. Non-default drives (secondary
|
||||
/// personals, shared drives) surface here only after their owner
|
||||
/// flips the flag on via the admin "Manage policies" modal.
|
||||
///
|
||||
/// `subject_types` / `subject_ids` are the caller expanded through
|
||||
/// their group memberships (`AuthorizationEngine::
|
||||
/// expand_subject_for_listing`); the arrays reach into the ANY()
|
||||
/// predicates so a group-mediated grant on a drive counts too.
|
||||
pub async fn list_media_files(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
before: Option<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
|
||||
let subject_types_owned: Vec<String> =
|
||||
subject_types.iter().map(|s| s.to_string()).collect();
|
||||
let rows: Vec<MediaFileRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
@@ -495,21 +494,23 @@ impl FileBlobReadRepository {
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
WHERE (
|
||||
(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)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (d.policies->>'include_in_photo_index')::boolean = true
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
AND ($3::bigint IS NULL
|
||||
OR EXTRACT(EPOCH FROM fi.media_sort_date)::bigint < $3::bigint)
|
||||
AND ($2::bigint IS NULL
|
||||
OR EXTRACT(EPOCH FROM fi.media_sort_date)::bigint < $2::bigint)
|
||||
ORDER BY fi.media_sort_date DESC
|
||||
LIMIT $4
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(&subject_types_owned)
|
||||
.bind(subject_ids)
|
||||
.bind(caller_id)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
@@ -537,21 +538,18 @@ impl FileBlobReadRepository {
|
||||
/// Scope: same `include_in_photo_index` predicate as
|
||||
/// `list_media_files` (§15). Places is the map view over the same
|
||||
/// content set the Photos timeline shows, so the two surfaces MUST
|
||||
/// agree on drive scope. If a drive is opt-out for Photos its
|
||||
/// geotagged files never appear on the map either.
|
||||
/// agree on drive scope. Group membership is expanded inline by
|
||||
/// `storage.caller_group_ids(caller)`.
|
||||
///
|
||||
/// This query is a per-cell aggregate (group by rounded lat/lng
|
||||
/// bucket) rather than an ORDER BY / LIMIT hot path — the plain
|
||||
/// `idx_files_drive_id` is sufficient to seek by drive.
|
||||
pub async fn list_geo_clusters(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
bounds: GeoBounds,
|
||||
cell: f64,
|
||||
) -> Result<Vec<GeoCluster>, DomainError> {
|
||||
let subject_types_owned: Vec<String> =
|
||||
subject_types.iter().map(|s| s.to_string()).collect();
|
||||
let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT count(*) AS n,
|
||||
@@ -566,21 +564,23 @@ impl FileBlobReadRepository {
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
WHERE (
|
||||
(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)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (d.policies->>'include_in_photo_index')::boolean = true
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
AND fm.latitude IS NOT NULL
|
||||
AND fm.longitude IS NOT NULL
|
||||
AND fm.longitude BETWEEN $3 AND $4
|
||||
AND fm.latitude BETWEEN $5 AND $6
|
||||
GROUP BY round(fm.longitude / $7), round(fm.latitude / $7)
|
||||
AND fm.longitude BETWEEN $2 AND $3
|
||||
AND fm.latitude BETWEEN $4 AND $5
|
||||
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
|
||||
"#,
|
||||
)
|
||||
.bind(&subject_types_owned)
|
||||
.bind(subject_ids)
|
||||
.bind(caller_id)
|
||||
.bind(bounds.west)
|
||||
.bind(bounds.east)
|
||||
.bind(bounds.south)
|
||||
|
||||
@@ -392,47 +392,55 @@ impl FolderRepository for FolderDbRepository {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||
// Drive-scoped root-folder listing: return every root folder
|
||||
// whose drive the caller has any role_grant on. Group
|
||||
// memberships (direct + transitive) resolve inline via
|
||||
// `storage.caller_group_ids($1)`.
|
||||
//
|
||||
// Closes `bug_root_folder_listing_legacy_user_id`: pre-D7 this
|
||||
// query filtered on `folders.user_id = $caller`, which returned
|
||||
// rows admin had created for other users' drives without ever
|
||||
// getting a role on them. The drive-membership predicate below
|
||||
// makes the "admin's own listing" correct without a separate
|
||||
// filter.
|
||||
//
|
||||
// `caller_role` is NOT surfaced here — see the memory
|
||||
// `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}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
@@ -512,60 +520,52 @@ impl FolderRepository for FolderDbRepository {
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
/// Paginated folder listing filtered by owner — single query with
|
||||
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner_paginated(
|
||||
/// Paginated companion to `list_root_folders_for_caller` — same
|
||||
/// drive-membership predicate, adds LIMIT/OFFSET and an optional
|
||||
/// window-function COUNT so total pages can be surfaced without a
|
||||
/// second round-trip.
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||
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 total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.11) as usize)
|
||||
|
||||
@@ -377,10 +377,15 @@ impl PgAclEngine {
|
||||
|
||||
/// Public wrapper around `subject_match_set` for callers that need
|
||||
/// the expanded `(subject_types, subject_ids)` pair without invoking
|
||||
/// the engine's full `check`/`require` pipeline. Used by
|
||||
/// `GET /api/drives` (and future drive-aware listing surfaces) to
|
||||
/// ask the `DriveRepository` for every drive the caller can read,
|
||||
/// reusing the engine's cached group-expansion logic.
|
||||
/// the engine's full `check`/`require` pipeline.
|
||||
///
|
||||
/// **Retained for legacy callers only** — new listing queries embed
|
||||
/// the `storage.caller_group_ids` PostgreSQL function inline (see
|
||||
/// migration `20260901000002_caller_group_ids_function.sql`) and
|
||||
/// take a bare `caller_id: Uuid` instead of the pre-expanded arrays.
|
||||
/// The engine's Moka cache still backs the fast path for per-request
|
||||
/// AuthZ decisions (`check_inner`, `drive_role_cache`) where the
|
||||
/// same subject is looked up repeatedly.
|
||||
pub async fn expand_subject_for_listing(
|
||||
&self,
|
||||
subject: Subject,
|
||||
|
||||
Reference in New Issue
Block a user