feat(myshares): new version of myshares sections, supporting grants Users + Tokens
This commit is contained in:
@@ -312,5 +312,54 @@ pub struct SharedWithMeItemDto {
|
||||
pub resource: ResourceContentDto,
|
||||
}
|
||||
|
||||
/// Derive the closest-matching role label from a set of permissions.
|
||||
/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`.
|
||||
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
|
||||
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
|
||||
"admin"
|
||||
} else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) {
|
||||
"editor"
|
||||
} else {
|
||||
"viewer"
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for `GET /api/grants/incoming/resources`.
|
||||
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// My-Shares DTOs (GET /api/grants/outgoing/resources)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// One (subject, permissions) entry within an outgoing resource item.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct OutgoingResourceGrantDto {
|
||||
pub grant_id: Uuid,
|
||||
/// `"user"` | `"token"`
|
||||
pub subject_type: String,
|
||||
pub subject_id: Uuid,
|
||||
/// Human-readable label (username for users, share name for tokens).
|
||||
pub subject_display: String,
|
||||
/// Derived role label: `"viewer"` | `"editor"` | `"admin"`.
|
||||
pub role: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Whether the token has a password set. Always `false` for user subjects.
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
/// One item in the my-shares list.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct OutgoingResourceItemDto {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
/// Earliest grant date across all subjects on this resource.
|
||||
pub first_shared_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Full resource details. Shape is determined by `resource_type`.
|
||||
pub resource: ResourceContentDto,
|
||||
/// One entry per (subject, permissions) pair.
|
||||
pub grants: Vec<OutgoingResourceGrantDto>,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/grants/outgoing/resources`.
|
||||
pub type MySharesDto = CursorListResponse<OutgoingResourceItemDto>;
|
||||
|
||||
@@ -12,7 +12,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
|
||||
ResourceKind, Subject,
|
||||
};
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
@@ -97,6 +98,21 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// `GET /api/grants/outgoing` ("things I've shared with others").
|
||||
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
||||
|
||||
/// Cursor-paginated list of resources that `granted_by` has shared with
|
||||
/// others. Multiple permission rows for the same (subject, resource) pair
|
||||
/// are collapsed into one `OutgoingGrantEntry`; multiple subjects on the
|
||||
/// same resource are grouped into one `OutgoingResourceSummary`.
|
||||
///
|
||||
/// Returns `(summaries, next_cursor)`.
|
||||
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>;
|
||||
|
||||
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
|
||||
/// constraint; if the row already exists its `expires_at` is updated.
|
||||
async fn grant(
|
||||
|
||||
@@ -205,8 +205,7 @@ pub struct Grant {
|
||||
|
||||
impl Grant {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at
|
||||
.is_some_and(|exp| exp < chrono::Utc::now())
|
||||
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +259,42 @@ pub struct IncomingGrantSummary {
|
||||
pub granted_by: Uuid,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// OutgoingGrantEntry / OutgoingResourceSummary — per-subject grant within a
|
||||
// resource that the current user shared with others
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// One (subject, permissions) pair within an outgoing resource summary.
|
||||
/// The `subject_display` field is resolved by the SQL layer: username for
|
||||
/// `user` subjects, share item_name for `token` subjects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutgoingGrantEntry {
|
||||
pub grant_id: Uuid,
|
||||
pub subject_type: String,
|
||||
pub subject_id: Uuid,
|
||||
/// Human-readable label: username (users) or share name (tokens).
|
||||
pub subject_display: String,
|
||||
/// All permissions held by this subject on the resource (aggregated).
|
||||
pub permissions: Vec<Permission>,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`).
|
||||
/// Always `false` for `user` subjects.
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
/// All subjects that the current user has shared a single resource with,
|
||||
/// together with the resource type, id, and when it was first shared.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutgoingResourceSummary {
|
||||
pub resource_type: ResourceKind,
|
||||
pub resource_id: Uuid,
|
||||
/// Earliest `granted_at` across all grants on this resource.
|
||||
pub first_shared_at: chrono::DateTime<chrono::Utc>,
|
||||
/// One entry per (subject, permissions) pair.
|
||||
pub grants: Vec<OutgoingGrantEntry>,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GrantCursor — opaque pagination cursor for list_incoming_resources_paged
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
|
||||
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -31,7 +32,8 @@ use crate::common::di::AppState;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::domain::services::authorization::{
|
||||
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||||
Subject,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
@@ -98,7 +100,10 @@ pub async fn create_grant(
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz.grant(caller_id, subject, perm, resource, expires_at).await {
|
||||
match authz
|
||||
.grant(caller_id, subject, perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(grant) => results.push(grant.into()),
|
||||
Err(err) => {
|
||||
error!("grant insert failed for {perm:?}: {err}");
|
||||
@@ -227,7 +232,10 @@ pub async fn set_role(
|
||||
}
|
||||
}
|
||||
for perm in &to_add {
|
||||
if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await {
|
||||
if let Err(e) = authz
|
||||
.grant(caller_id, subject, *perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
@@ -236,7 +244,10 @@ pub async fn set_role(
|
||||
// includes newly added ones and any that were already present (retained).
|
||||
// Callers that omit expires_at will clear any existing expiry; this is
|
||||
// intentional: it keeps all permission rows for the pair consistent.
|
||||
if let Err(e) = authz.set_expiry_on_resource(subject, resource, expires_at).await {
|
||||
if let Err(e) = authz
|
||||
.set_expiry_on_resource(subject, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
@@ -560,6 +571,180 @@ pub async fn list_on_resource(
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/outgoing/resources
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/outgoing/resources",
|
||||
params(SharedWithMeQuery),
|
||||
responses(
|
||||
(status = 200,
|
||||
description = "Cursor-paginated resources the caller has shared with others. \
|
||||
Each item carries the full resource details plus all subjects \
|
||||
(users and tokens) the resource was shared with. \
|
||||
`next_cursor` is absent on the last page.",
|
||||
body = MySharesDto),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_my_shares(
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<SharedWithMeQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
let limit = q.limit_clamped() as u32;
|
||||
|
||||
let sort_by = q.sort_by.as_deref().unwrap_or("first_shared_at");
|
||||
if !matches!(
|
||||
sort_by,
|
||||
"first_shared_at" | "name" | "type" | "subject" | "role"
|
||||
) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "invalid sort_by; valid values: first_shared_at, name, type, subject, role"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let reverse = q.reverse;
|
||||
|
||||
let cursor = q
|
||||
.decode_cursor::<GrantCursor>()
|
||||
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
|
||||
|
||||
let (summaries, next_cursor) = match state
|
||||
.authorization
|
||||
.list_outgoing_resources_paged(caller_id, limit, cursor, sort_by, reverse)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service_concrete;
|
||||
|
||||
// Split summaries by resource kind for parallel resolution.
|
||||
let file_summaries: Vec<&OutgoingResourceSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::File))
|
||||
.collect();
|
||||
let folder_summaries: Vec<&OutgoingResourceSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::Folder))
|
||||
.collect();
|
||||
|
||||
let file_ids: Vec<String> = file_summaries
|
||||
.iter()
|
||||
.map(|s| s.resource_id.to_string())
|
||||
.collect();
|
||||
let folder_ids: Vec<String> = folder_summaries
|
||||
.iter()
|
||||
.map(|s| s.resource_id.to_string())
|
||||
.collect();
|
||||
|
||||
let (file_results, folder_results) = tokio::join!(
|
||||
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
|
||||
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
|
||||
);
|
||||
|
||||
let mut file_idx = 0usize;
|
||||
let mut folder_idx = 0usize;
|
||||
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
|
||||
|
||||
for summary in &summaries {
|
||||
let grants: Vec<OutgoingResourceGrantDto> = summary
|
||||
.grants
|
||||
.iter()
|
||||
.map(|g| OutgoingResourceGrantDto {
|
||||
grant_id: g.grant_id,
|
||||
subject_type: g.subject_type.clone(),
|
||||
subject_id: g.subject_id,
|
||||
subject_display: g.subject_display.clone(),
|
||||
role: role_from_permissions(&g.permissions).to_owned(),
|
||||
granted_at: g.granted_at,
|
||||
expires_at: g.expires_at,
|
||||
has_password: g.has_password,
|
||||
})
|
||||
.collect();
|
||||
|
||||
match summary.resource_type {
|
||||
ResourceKind::File => {
|
||||
let result = &file_results[file_idx];
|
||||
file_idx += 1;
|
||||
match result {
|
||||
Ok(file_dto) => {
|
||||
items.push(OutgoingResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
first_shared_at: summary.first_shared_at,
|
||||
resource: ResourceContentDto::File(
|
||||
file_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
"Skipping stale outgoing file grant for resource_id={}: not found",
|
||||
summary.resource_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return AppError::internal_error(format!(
|
||||
"Failed to fetch file {}: {e}",
|
||||
summary.resource_id
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
ResourceKind::Folder => {
|
||||
let result = &folder_results[folder_idx];
|
||||
folder_idx += 1;
|
||||
match result {
|
||||
Ok(folder_dto) => {
|
||||
items.push(OutgoingResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
first_shared_at: summary.first_shared_at,
|
||||
resource: ResourceContentDto::Folder(
|
||||
folder_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
"Skipping stale outgoing folder grant for resource_id={}: not found",
|
||||
summary.resource_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return AppError::internal_error(format!(
|
||||
"Failed to fetch folder {}: {e}",
|
||||
summary.resource_id
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(MySharesDto::with_cursor(
|
||||
items,
|
||||
next_cursor.map(|c| c.encode()),
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// Silence unused-import warnings for SubjectDto when only certain endpoints
|
||||
// touch it directly.
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
get(grant_handler::list_shared_with_me),
|
||||
)
|
||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||
.route("/outgoing/resources", get(grant_handler::list_my_shares))
|
||||
.with_state(app_state.clone())
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user