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
+9
View File
@@ -191,6 +191,9 @@ pub struct CreateGrantDto {
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
@@ -199,6 +202,9 @@ pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
/// Optional expiry applied to every grant written or updated by this call.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ════════════════════════════════════════════════════════════════════════════
@@ -213,6 +219,8 @@ pub struct GrantDto {
pub permission: PermissionDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<Grant> for GrantDto {
@@ -224,6 +232,7 @@ impl From<Grant> for GrantDto {
permission: g.permission.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
expires_at: g.expires_at,
}
}
}
+1 -27
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::domain::entities::share::{Share, SharePermissions};
use crate::domain::entities::share::Share;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ShareDto {
@@ -13,19 +13,11 @@ pub struct ShareDto {
pub url: String,
pub has_password: bool,
pub expires_at: Option<u64>,
pub permissions: SharePermissionsDto,
pub created_at: u64,
pub created_by: String,
pub access_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SharePermissionsDto {
pub read: bool,
pub write: bool,
pub reshare: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateShareDto {
pub item_id: String,
@@ -33,17 +25,14 @@ pub struct CreateShareDto {
pub item_type: String,
pub password: Option<String>,
pub expires_at: Option<u64>,
pub permissions: Option<SharePermissionsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateShareDto {
pub password: Option<String>,
pub expires_at: Option<u64>,
pub permissions: Option<SharePermissionsDto>,
}
/// Extension methods to convert between DTOs and domain entities
impl ShareDto {
pub fn from_entity(share: &Share, base_url: &str) -> Self {
let url = format!("{}/s/{}", base_url, share.token());
@@ -57,24 +46,9 @@ impl ShareDto {
url,
has_password: share.has_password(),
expires_at: share.expires_at(),
permissions: SharePermissionsDto::from_entity(share.permissions()),
created_at: share.created_at(),
created_by: share.created_by().to_string(),
access_count: share.access_count(),
}
}
}
impl SharePermissionsDto {
pub fn from_entity(permissions: &SharePermissions) -> Self {
Self {
read: permissions.read(),
write: permissions.write(),
reshare: permissions.reshare(),
}
}
pub fn to_entity(&self) -> SharePermissions {
SharePermissions::new(self.read, self.write, self.reshare)
}
}
+21 -1
View File
@@ -98,15 +98,35 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint and the existing row is returned.
/// constraint; if the row already exists its `expires_at` is updated.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Update `expires_at` on every grant row for the given subject.
/// Used when a share's expiry is changed — one call updates all
/// permission rows for that token in a single UPDATE.
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Update `expires_at` on every grant row for the given `(subject, resource)`
/// pair. Used by `set_role` to sync the expiry of retained grants when the
/// caller changes expiry without changing permissions.
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
+46 -90
View File
@@ -28,7 +28,7 @@ use crate::{
config::AppConfig,
errors::{DomainError, ErrorKind},
},
domain::entities::share::{Share, ShareItemType, SharePermissions},
domain::entities::share::{Share, ShareItemType},
};
#[derive(Debug, Error)]
@@ -229,87 +229,59 @@ impl ShareUseCase for ShareService {
user_id: Uuid,
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
// Convert the item type
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
// Verify that the item exists
self.verify_item_exists(&dto.item_id, &item_type).await?;
// Convert the permissions DTO if it exists
let permissions = dto.permissions.map(|p| p.to_entity());
// Hash the password if provided (async, semaphore-bounded)
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
};
// Create the Share entity
let share = Share::new(
dto.item_id.clone(),
dto.item_name.clone(),
item_type,
user_id,
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
// Save to the repository
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Mirror the share permissions as ReBAC token grants so that
// `GET /api/grants/outgoing` picks them up and the UI can show the
// share badge without a separate `/api/shares` round-trip.
// The DELETE trigger `trg_cleanup_grants_token` handles cleanup when
// the share is later removed — no extra service-layer code needed there.
{
let share_id = saved_share.id();
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
// Create one Read-only grant for the token subject, carrying expires_at.
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
// cleans up this grant when the share is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
ShareItemType::File => Resource::File(item_id_uuid),
ShareItemType::Folder => Resource::Folder(item_id_uuid),
};
let expires_dt = dto
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
.grant(
user_id,
Subject::Token(saved_share.id()),
Permission::Read,
resource,
expires_dt,
)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
let resource = match saved_share.item_type() {
ShareItemType::File => Resource::File(item_id_uuid),
ShareItemType::Folder => Resource::Folder(item_id_uuid),
};
let subject = Subject::Token(share_id);
let perms = saved_share.permissions();
// Read is always granted
self.authorization
.grant(user_id, subject, Permission::Read, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Write permission → Create + Update
if perms.write() {
self.authorization
.grant(user_id, subject, Permission::Create, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
self.authorization
.grant(user_id, subject, Permission::Update, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
// Reshare permission → Share
if perms.reshare() {
self.authorization
.grant(user_id, subject, Permission::Share, resource)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
}
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
// Return DTO with the requested expires_at (grant subquery on the share
// row would return NULL at this point since INSERT ran before the grant).
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
response.expires_at = dto.expires_at;
Ok(response)
}
async fn get_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<ShareDto, DomainError> {
@@ -364,16 +336,6 @@ impl ShareUseCase for ShareService {
// SECURITY: ownership-verified lookup — prevents IDOR
let mut share = self.fetch_owned_share(id, requester_id).await?;
// Update permissions if provided
if let Some(permissions_dto) = dto.permissions {
let permissions = SharePermissions::new(
permissions_dto.read,
permissions_dto.write,
permissions_dto.reshare,
);
share = share.with_permissions(permissions);
}
// Update password if provided (async, semaphore-bounded)
if let Some(password) = dto.password {
let password_hash = if password.is_empty() {
@@ -384,23 +346,33 @@ impl ShareUseCase for ShareService {
share = share.with_password(password_hash);
}
// Update expiration date if provided
// Expiry is managed at the grant level; update all grants for this token.
let new_expires_at = if dto.expires_at.is_some() {
dto.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0))
} else {
None
};
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
self.authorization
.set_expiry_for_subject(Subject::Token(share.id()), new_expires_at)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
}
// Save the changes
let updated_share = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(
&updated_share,
&self.config.base_url(),
))
// Use the requested expires_at for the response (subquery in update_share
// runs before set_expiry_for_subject committed, so entity may lag).
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
if dto.expires_at.is_some() {
response.expires_at = dto.expires_at;
}
Ok(response)
}
async fn delete_shared_link(&self, id: Uuid, requester_id: Uuid) -> Result<(), DomainError> {
@@ -510,8 +482,6 @@ impl ShareUseCase for ShareService {
#[allow(dead_code)]
mod tests {
use super::*;
#[allow(unused_imports)]
use crate::application::dtos::share_dto::SharePermissionsDto;
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::storage_ports::FileReadPort;
@@ -606,7 +576,6 @@ mod tests {
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
self.verify_item_exists(&dto.item_id, &item_type).await?;
let permissions = dto.permissions.map(|p| p.to_entity());
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
@@ -616,9 +585,7 @@ mod tests {
dto.item_name.clone(),
item_type,
user_id,
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
let saved_share = self
@@ -692,9 +659,6 @@ mod tests {
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if let Some(p) = dto.permissions {
share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare));
}
if let Some(password) = dto.password {
let hash = if password.is_empty() {
None
@@ -703,9 +667,6 @@ mod tests {
};
share = share.with_password(hash);
}
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
}
let updated = self
.share_repository
.update_share(&share)
@@ -1208,11 +1169,6 @@ mod tests {
item_type: "file".to_string(),
password: Some("secret".to_string()),
expires_at: None,
permissions: Some(SharePermissionsDto {
read: true,
write: false,
reshare: false,
}),
};
let result = service.create_shared_link(Uuid::new_v4(), dto).await;
+2 -115
View File
@@ -12,20 +12,13 @@ pub struct Share {
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
expires_at: Option<u64>,
permissions: SharePermissions,
created_at: u64,
created_by: Uuid,
access_count: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SharePermissions {
read: bool,
write: bool,
reshare: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShareItemType {
File,
@@ -38,31 +31,14 @@ impl Share {
item_name: Option<String>,
item_type: ShareItemType,
created_by: Uuid,
permissions: Option<SharePermissions>,
password_hash: Option<String>,
expires_at: Option<u64>,
) -> Result<Self, ShareError> {
// Validate item_id
if item_id.is_empty() {
return Err(ShareError::ValidationError(
"Item ID cannot be empty".to_string(),
));
}
// Validate expiration date if provided
if let Some(expires) = expires_at {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
if expires <= now {
return Err(ShareError::InvalidExpiration(
"Expiration date must be in the future".to_string(),
));
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
@@ -75,12 +51,7 @@ impl Share {
item_type,
token: Uuid::new_v4().to_string(),
password_hash,
expires_at,
permissions: permissions.unwrap_or(SharePermissions {
read: true,
write: false,
reshare: false,
}),
expires_at: None,
created_at: now,
created_by,
access_count: 0,
@@ -96,7 +67,6 @@ impl Share {
token: String,
password_hash: Option<String>,
expires_at: Option<u64>,
permissions: SharePermissions,
created_at: u64,
created_by: Uuid,
access_count: u64,
@@ -109,7 +79,6 @@ impl Share {
token,
password_hash,
expires_at,
permissions,
created_at,
created_by,
access_count,
@@ -142,10 +111,6 @@ impl Share {
self.expires_at
}
pub fn permissions(&self) -> &SharePermissions {
&self.permissions
}
pub fn created_at(&self) -> u64 {
self.created_at
}
@@ -160,21 +125,11 @@ impl Share {
// ── Builder-style modifiers (immutable) ──
pub fn with_permissions(mut self, permissions: SharePermissions) -> Self {
self.permissions = permissions;
self
}
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
self.password_hash = password_hash;
self
}
pub fn with_expiration(mut self, expires_at: Option<u64>) -> Self {
self.expires_at = expires_at;
self
}
pub fn with_token(mut self, token: String) -> Self {
self.token = token;
self
@@ -212,28 +167,6 @@ impl Share {
}
}
impl SharePermissions {
pub fn new(read: bool, write: bool, reshare: bool) -> Self {
Self {
read,
write,
reshare,
}
}
pub fn read(&self) -> bool {
self.read
}
pub fn write(&self) -> bool {
self.write
}
pub fn reshare(&self) -> bool {
self.reshare
}
}
impl std::fmt::Display for ShareItemType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -275,59 +208,17 @@ mod tests {
ShareItemType::File,
uid,
None,
None,
None,
)
.unwrap();
assert_eq!(share.item_id(), "test_file_id");
assert_eq!(*share.item_type(), ShareItemType::File);
assert_eq!(share.created_by(), uid);
assert!(share.permissions().read());
assert!(!share.permissions().write());
assert!(!share.permissions().reshare());
assert!(!share.has_password());
assert!(share.expires_at().is_none());
assert_eq!(share.access_count(), 0);
}
#[test]
fn test_share_is_expired() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
// Create a share that expires in the future
let future = now + 3600; // 1 hour in the future
let share = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
None,
None,
Some(future),
)
.unwrap();
assert!(!share.is_expired());
// Test with past expiration (should fail during creation)
let past = now - 3600; // 1 hour in the past
let share_result = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
None,
None,
Some(past),
);
assert!(share_result.is_err());
}
#[test]
fn test_share_item_type_conversion() {
assert_eq!(ShareItemType::File.to_string(), "file");
@@ -355,9 +246,7 @@ mod tests {
None,
ShareItemType::File,
test_user_id(),
None,
Some("some_hash_value".to_string()),
None,
)
.unwrap();
@@ -373,8 +262,6 @@ mod tests {
ShareItemType::File,
test_user_id(),
None,
None, // No password
None,
)
.unwrap();
+8
View File
@@ -200,6 +200,14 @@ pub struct Grant {
pub permission: Permission,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl Grant {
pub fn is_expired(&self) -> bool {
self.expires_at
.is_some_and(|exp| exp < chrono::Utc::now())
}
}
// ════════════════════════════════════════════════════════════════════════════
@@ -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)
+12 -2
View File
@@ -86,6 +86,7 @@ pub async fn create_grant(
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err(e) = authz
@@ -97,7 +98,7 @@ 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).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}");
@@ -189,6 +190,7 @@ pub async fn set_role(
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
@@ -225,11 +227,19 @@ pub async fn set_role(
}
}
for perm in &to_add {
if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await {
if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await {
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// 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 {
return AppError::from(e).into_response();
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
+1 -4
View File
@@ -34,9 +34,7 @@ use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
SearchSuggestionItem, SearchSuggestionsDto,
};
use crate::application::dtos::share_dto::{
CreateShareDto, ShareDto, SharePermissionsDto, UpdateShareDto,
};
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
use crate::application::dtos::trash_dto::{
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
};
@@ -257,7 +255,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
OidcExchangeDto,
// Share schemas
ShareDto,
SharePermissionsDto,
CreateShareDto,
UpdateShareDto,
// Trash schemas