refactor(share,grants): migrate expiration from legacy share into grants, simplify legacy share, normalize shareModal for better UX
This commit is contained in:
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user