refactor(share,grants): migrate expiration from legacy share into grants, simplify legacy share, normalize shareModal for better UX

This commit is contained in:
Edouard Vanbelle
2026-05-28 19:56:31 +02:00
parent 84d57dd2ae
commit 8800353900
17 changed files with 693 additions and 597 deletions
@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{
application::ports::share_ports::ShareStoragePort,
common::errors::DomainError,
domain::entities::share::{Share, ShareItemType, SharePermissions},
domain::entities::share::{Share, ShareItemType},
};
/// PostgreSQL implementation of [`ShareStoragePort`].
@@ -37,6 +37,8 @@ impl SharePgRepository {
}
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: Uuid = row
.try_get("id")
@@ -52,10 +54,8 @@ impl SharePgRepository {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
// expires_at derived from access_grants subquery (unix seconds as i64)
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
let permissions_read: bool = row.try_get("permissions_read").unwrap_or(true);
let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false);
let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
})?;
@@ -66,8 +66,6 @@ impl SharePgRepository {
let item_type =
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
let permissions =
SharePermissions::new(permissions_read, permissions_write, permissions_reshare);
Ok(Share::from_raw(
id,
@@ -77,7 +75,6 @@ impl SharePgRepository {
token,
password_hash,
expires_at.map(|v| v as u64),
permissions,
created_at as u64,
created_by,
access_count as u64,
@@ -91,23 +88,18 @@ impl ShareStoragePort for SharePgRepository {
r#"
INSERT INTO storage.shares
(id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count)
VALUES
($1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13)
($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
expires_at = EXCLUDED.expires_at,
permissions_read = EXCLUDED.permissions_read,
permissions_write = EXCLUDED.permissions_write,
permissions_reshare = EXCLUDED.permissions_reshare,
access_count = EXCLUDED.access_count
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
access_count = EXCLUDED.access_count
RETURNING
id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
created_at, created_by, access_count
"#,
)
@@ -117,10 +109,6 @@ impl ShareStoragePort for SharePgRepository {
.bind(share.item_type().to_string())
.bind(share.token())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.created_at() as i64)
.bind(share.created_by())
.bind(share.access_count() as i64)
@@ -137,11 +125,13 @@ impl ShareStoragePort for SharePgRepository {
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE token = $1
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.token = $1
"#,
)
.bind(token)
@@ -168,11 +158,13 @@ impl ShareStoragePort for SharePgRepository {
) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE id = $1 AND created_by = $2
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.id = $1 AND s.created_by = $2
"#,
)
.bind(id)
@@ -224,12 +216,14 @@ impl ShareStoragePort for SharePgRepository {
) -> Result<Vec<Share>, DomainError> {
let rows = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE item_id = $1 AND item_type = $2 AND created_by = $3
ORDER BY created_at DESC
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
WHERE s.item_id = $1 AND s.item_type = $2 AND s.created_by = $3
ORDER BY s.created_at DESC
"#,
)
.bind(item_id)
@@ -249,27 +243,21 @@ impl ShareStoragePort for SharePgRepository {
let row = sqlx::query(
r#"
UPDATE storage.shares SET
item_name = $2,
password_hash = $3,
expires_at = $4,
permissions_read = $5,
permissions_write = $6,
permissions_reshare = $7,
access_count = $8
item_name = $2,
password_hash = $3,
access_count = $4
WHERE id = $1
RETURNING
id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_name())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.access_count() as i64)
.fetch_optional(&*self.db_pool)
.await
@@ -296,13 +284,15 @@ impl ShareStoragePort for SharePgRepository {
// Single query with window function — count + rows in one roundtrip
let rows = sqlx::query(
r#"
SELECT id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares
WHERE created_by = $1
ORDER BY created_at DESC
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares s
WHERE s.created_by = $1
ORDER BY s.created_at DESC
LIMIT $2 OFFSET $3
"#,
)
+60 -12
View File
@@ -103,6 +103,7 @@ impl PgAclEngine {
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
LIMIT 1
"#,
@@ -135,6 +136,7 @@ impl PgAclEngine {
FROM storage.access_grants
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
AND resource_type = 'file' AND resource_id = $4
AND (expires_at IS NULL OR expires_at > NOW())
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
@@ -145,6 +147,7 @@ impl PgAclEngine {
AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders
WHERE id = target_f.folder_id)
@@ -186,8 +189,9 @@ impl PgAclEngine {
Ok(Some((res, granter)))
}
/// Decode a (id, subject_type, subject_id, resource_type, resource_id,
/// permission, granted_by, granted_at) row into a `Grant`.
/// Row type for all full-grant SELECT queries:
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
#[allow(clippy::type_complexity)]
fn row_to_grant(
row: (
Uuid,
@@ -198,6 +202,7 @@ impl PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
) -> Result<Grant, DomainError> {
let subject = Subject::from_parts(&row.1, row.2)
@@ -213,6 +218,7 @@ impl PgAclEngine {
permission,
granted_by: row.6,
granted_at: row.7,
expires_at: row.8,
})
}
}
@@ -271,11 +277,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE subject_type = $1
AND subject_id = $2
@@ -636,11 +643,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE resource_type = $1
AND resource_id = $2
@@ -668,11 +676,12 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
@@ -692,10 +701,8 @@ impl AuthorizationEngine for PgAclEngine {
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError> {
// Idempotent: ON CONFLICT DO UPDATE so we always return the row
// (whether newly inserted or pre-existing). The "update" is a no-op
// (granted_by/granted_at preserved from the existing row).
let row = sqlx::query_as::<
_,
(
@@ -707,16 +714,17 @@ impl AuthorizationEngine for PgAclEngine {
String,
Uuid,
chrono::DateTime<chrono::Utc>,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET subject_type = EXCLUDED.subject_type
DO UPDATE SET expires_at = EXCLUDED.expires_at
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at
permission, granted_by, granted_at, expires_at
"#,
)
.bind(subject.type_str())
@@ -725,6 +733,7 @@ impl AuthorizationEngine for PgAclEngine {
.bind(resource.id())
.bind(permission.as_str())
.bind(granted_by)
.bind(expires_at)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
@@ -732,6 +741,45 @@ impl AuthorizationEngine for PgAclEngine {
Self::row_to_grant(row)
}
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
Ok(())
}
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 \
WHERE subject_type = $1 AND subject_id = $2 \
AND resource_type = $4 AND resource_id = $5",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")))?;
Ok(())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)