feat(myshares): new version of myshares sections, supporting grants Users + Tokens

This commit is contained in:
Edouard Vanbelle
2026-05-29 02:16:15 +02:00
parent 8800353900
commit 63722c868e
43 changed files with 2676 additions and 243 deletions
+618 -2
View File
@@ -36,7 +36,8 @@ use sqlx::PgPool;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
Permission, Resource, ResourceKind, Subject,
};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -664,6 +665,619 @@ impl AuthorizationEngine for PgAclEngine {
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn list_outgoing_resources_paged(
&self,
granted_by: Uuid,
limit: u32,
cursor: Option<GrantCursor>,
sort_by: &str,
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), 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<Utc> — 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<Utc> — this (subject, perm) row
// 8 expires_at Option<DateTime<Utc>>
// 9 permission String
// 10 sort_str Option<String>
// 11 sort_int Option<i64>
// 12 has_password bool — token: shares.password_hash IS NOT NULL
type Row = (
String,
Uuid,
chrono::DateTime<chrono::Utc>,
String,
Uuid,
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<String>,
Option<i64>,
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, 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 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"#
)
}
"subject" => {
// Page on (subject_type_order, subject_display, resource_id) triples so
// every swimlane is always contiguous across cursor pages.
//
// subject_type_order: 0 = user, 1 = token without password, 2 = token with password
//
// 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, 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 = 'user' THEN 0
WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL 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}"#
)
}
"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<Row> = 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<OutgoingResourceSummary> = 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<Uuid> = Vec::new();
let mut seen_set: std::collections::HashSet<Uuid> = 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<Uuid> = 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<chrono::Utc>,
std::collections::HashMap<Uuid, OutgoingGrantEntry>,
);
let mut resource_map: std::collections::HashMap<Uuid, ResourceEntry> =
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<OutgoingResourceSummary> = seen_resources
.into_iter()
.filter_map(|rid| {
let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?;
let mut grants: Vec<OutgoingGrantEntry> = subj_map.into_values().collect();
let role_rank = |perms: &[Permission]| -> u8 {
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
0 // admin → Can manage
} else if perms.contains(&Permission::Create)
|| perms.contains(&Permission::Update)
{
1 // editor → Can edit
} else {
2 // viewer → Can view
}
};
grants.sort_by(|a, b| {
role_rank(&a.permissions)
.cmp(&role_rank(&b.permissions))
.then_with(|| {
// users before tokens
let type_rank = |st: &str| if st == "user" { 0u8 } else { 1 };
type_rank(&a.subject_type).cmp(&type_rank(&b.subject_type))
})
.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<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
@@ -776,7 +1390,9 @@ impl AuthorizationEngine for PgAclEngine {
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")))?;
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
})?;
Ok(())
}