From b5cda3afeb29cd75e5d54c033372a3275274c6a7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 19:06:49 +0200 Subject: [PATCH] feat: add VisibilityFilteredAssetRepository decorator for automatic access control on asset queries --- crates/application/src/catalog/mod.rs | 2 + .../src/catalog/queries/get_asset.rs | 35 ++- .../src/catalog/queries/get_timeline.rs | 29 +- crates/application/src/catalog/visibility.rs | 296 ++++++++++++++++++ .../tests/catalog/queries/get_timeline.rs | 2 + crates/presentation/src/handlers/assets.rs | 1 + 6 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 crates/application/src/catalog/visibility.rs diff --git a/crates/application/src/catalog/mod.rs b/crates/application/src/catalog/mod.rs index d1b1df0..0ac4ec7 100644 --- a/crates/application/src/catalog/mod.rs +++ b/crates/application/src/catalog/mod.rs @@ -1,8 +1,10 @@ pub mod commands; pub mod queries; +pub mod visibility; pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler}; pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; +pub use visibility::VisibilityFilteredAssetRepository; diff --git a/crates/application/src/catalog/queries/get_asset.rs b/crates/application/src/catalog/queries/get_asset.rs index e2b187b..7500e76 100644 --- a/crates/application/src/catalog/queries/get_asset.rs +++ b/crates/application/src/catalog/queries/get_asset.rs @@ -1,8 +1,9 @@ +use crate::catalog::visibility::VisibilityFilteredAssetRepository; use domain::{ catalog::entities::Asset, catalog::services::resolve_metadata, errors::DomainError, - ports::{AssetMetadataRepository, AssetRepository}, + ports::{AssetMetadataRepository, AssetRepository, ShareRepository}, value_objects::{StructuredData, SystemId}, }; use std::sync::Arc; @@ -16,6 +17,7 @@ pub struct GetAssetQuery { pub struct GetAssetHandler { asset_repo: Arc, metadata_repo: Arc, + share_repo: Option>, } impl GetAssetHandler { @@ -26,6 +28,28 @@ impl GetAssetHandler { Self { asset_repo, metadata_repo, + share_repo: None, + } + } + + /// Enable sharing-aware visibility filtering. When set, the handler + /// wraps the inner `AssetRepository` with a `VisibilityFilteredAssetRepository` + /// so that shared assets are visible to the caller. + pub fn with_visibility_filter(mut self, share_repo: Arc) -> Self { + self.share_repo = Some(share_repo); + self + } + + /// Returns the effective asset repo — wrapped with a visibility filter + /// when a `ShareRepository` has been configured, otherwise the raw inner repo. + fn effective_repo(&self, caller_id: SystemId) -> Arc { + match &self.share_repo { + Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new( + self.asset_repo.clone(), + share_repo.clone(), + caller_id, + )), + None => self.asset_repo.clone(), } } @@ -33,13 +57,16 @@ impl GetAssetHandler { &self, query: GetAssetQuery, ) -> Result<(Asset, StructuredData), DomainError> { - let asset = self - .asset_repo + let repo = self.effective_repo(query.user_id); + + let asset = repo .find_by_id(&query.asset_id) .await? .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; - if asset.owner_user_id != query.user_id { + // When the visibility filter is active it already enforces access. + // When it is not, fall back to the original owner-only check. + if self.share_repo.is_none() && asset.owner_user_id != query.user_id { return Err(DomainError::Forbidden("Access denied".to_string())); } diff --git a/crates/application/src/catalog/queries/get_timeline.rs b/crates/application/src/catalog/queries/get_timeline.rs index dea02f1..c3b9719 100644 --- a/crates/application/src/catalog/queries/get_timeline.rs +++ b/crates/application/src/catalog/queries/get_timeline.rs @@ -1,8 +1,9 @@ +use crate::catalog::visibility::VisibilityFilteredAssetRepository; use domain::{ catalog::entities::Asset, catalog::services::resolve_metadata, errors::DomainError, - ports::{AssetMetadataRepository, AssetRepository}, + ports::{AssetMetadataRepository, AssetRepository, ShareRepository}, value_objects::{StructuredData, SystemId}, }; use std::sync::Arc; @@ -10,6 +11,7 @@ use std::sync::Arc; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GetTimelineQuery { pub owner_id: SystemId, + pub caller_id: Option, pub limit: u32, pub offset: u32, } @@ -17,6 +19,7 @@ pub struct GetTimelineQuery { pub struct GetTimelineHandler { asset_repo: Arc, metadata_repo: Arc, + share_repo: Option>, } impl GetTimelineHandler { @@ -27,6 +30,24 @@ impl GetTimelineHandler { Self { asset_repo, metadata_repo, + share_repo: None, + } + } + + /// Enable sharing-aware visibility filtering on timeline queries. + pub fn with_visibility_filter(mut self, share_repo: Arc) -> Self { + self.share_repo = Some(share_repo); + self + } + + fn effective_repo(&self, caller_id: SystemId) -> Arc { + match &self.share_repo { + Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new( + self.asset_repo.clone(), + share_repo.clone(), + caller_id, + )), + None => self.asset_repo.clone(), } } @@ -34,8 +55,10 @@ impl GetTimelineHandler { &self, query: GetTimelineQuery, ) -> Result, DomainError> { - let assets = self - .asset_repo + let caller_id = query.caller_id.unwrap_or(query.owner_id); + let repo = self.effective_repo(caller_id); + + let assets = repo .find_by_owner(&query.owner_id, query.limit, query.offset) .await?; diff --git a/crates/application/src/catalog/visibility.rs b/crates/application/src/catalog/visibility.rs new file mode 100644 index 0000000..83af003 --- /dev/null +++ b/crates/application/src/catalog/visibility.rs @@ -0,0 +1,296 @@ +use async_trait::async_trait; +use domain::{ + catalog::entities::Asset, + errors::DomainError, + ports::{AssetRepository, ShareRepository}, + value_objects::{Checksum, SystemId}, +}; +use std::sync::Arc; + +/// Decorator that wraps an `AssetRepository` and filters query results +/// based on sharing permissions. The caller sees only assets they own +/// or have been granted access to via a `ShareScope` + `ShareTarget`. +/// +/// Write operations (`save`, `delete`) pass through to the inner repository +/// unchanged — authorization for writes is handled at the use-case layer. +pub struct VisibilityFilteredAssetRepository { + inner: Arc, + share_repo: Arc, + caller_id: SystemId, +} + +impl VisibilityFilteredAssetRepository { + pub fn new( + inner: Arc, + share_repo: Arc, + caller_id: SystemId, + ) -> Self { + Self { + inner, + share_repo, + caller_id, + } + } + + /// Returns `true` if the caller owns the asset or has been granted + /// access through a share scope that targets them. + async fn caller_can_access(&self, asset: &Asset) -> Result { + if asset.owner_user_id == self.caller_id { + return Ok(true); + } + + // Find all share scopes that cover this asset + let scopes = self + .share_repo + .find_scopes_for_resource(&asset.asset_id) + .await?; + + if scopes.is_empty() { + return Ok(false); + } + + // Find all share targets that name this caller + let caller_targets = self + .share_repo + .find_targets_for_user(&self.caller_id) + .await?; + + // The caller has access if any of their targets reference a scope + // that covers this asset. + for scope in &scopes { + if scope.is_expired() { + continue; + } + if caller_targets.iter().any(|t| t.scope_id == scope.scope_id) { + return Ok(true); + } + } + + Ok(false) + } +} + +#[async_trait] +impl AssetRepository for VisibilityFilteredAssetRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + let asset = self.inner.find_by_id(id).await?; + match asset { + Some(a) if self.caller_can_access(&a).await? => Ok(Some(a)), + _ => Ok(None), + } + } + + async fn find_by_checksum(&self, checksum: &Checksum) -> Result, DomainError> { + let assets = self.inner.find_by_checksum(checksum).await?; + let mut visible = Vec::with_capacity(assets.len()); + for asset in assets { + if self.caller_can_access(&asset).await? { + visible.push(asset); + } + } + Ok(visible) + } + + async fn find_by_owner( + &self, + owner_id: &SystemId, + limit: u32, + offset: u32, + ) -> Result, DomainError> { + if owner_id == &self.caller_id { + // Querying own assets — no filtering needed. + return self.inner.find_by_owner(owner_id, limit, offset).await; + } + + let assets = self.inner.find_by_owner(owner_id, limit, offset).await?; + let mut visible = Vec::with_capacity(assets.len()); + for asset in assets { + if self.caller_can_access(&asset).await? { + visible.push(asset); + } + } + Ok(visible) + } + + async fn save(&self, asset: &Asset) -> Result<(), DomainError> { + self.inner.save(asset).await + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + self.inner.delete(id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryAssetRepository, InMemoryShareRepository}; + use domain::{ + catalog::entities::{AssetType, SourceReference}, + sharing::entities::{ScopeType, ShareScope, ShareTarget, ShareableType, TargetType}, + value_objects::{Checksum, SystemId}, + }; + + fn make_asset(owner: SystemId) -> Asset { + Asset::new( + SourceReference { + volume_id: SystemId::new(), + relative_path: "test/photo.jpg".to_string(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }, + AssetType::Image, + "image/jpeg", + 1024, + owner, + ) + } + + fn share_asset(asset_id: SystemId, granter: SystemId) -> ShareScope { + ShareScope::new(ScopeType::User, ShareableType::Asset, asset_id, granter) + } + + fn target_user(scope_id: SystemId, user_id: SystemId) -> ShareTarget { + ShareTarget::new(scope_id, TargetType::User, user_id, SystemId::new()) + } + + #[tokio::test] + async fn owner_can_always_see_own_asset() { + let owner_id = SystemId::new(); + let asset = make_asset(owner_id); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + owner_id, + ); + + let found = filtered.find_by_id(&asset.asset_id).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().asset_id, asset.asset_id); + } + + #[tokio::test] + async fn stranger_cannot_see_unshared_asset() { + let owner_id = SystemId::new(); + let stranger_id = SystemId::new(); + let asset = make_asset(owner_id); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + stranger_id, + ); + + let found = filtered.find_by_id(&asset.asset_id).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn shared_user_can_see_asset() { + let owner_id = SystemId::new(); + let friend_id = SystemId::new(); + let asset = make_asset(owner_id); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + + // Create a share scope on the asset and target the friend + let scope = share_asset(asset.asset_id, owner_id); + share_repo.save_scope(&scope).await.unwrap(); + + let target = target_user(scope.scope_id, friend_id); + share_repo.save_target(&target).await.unwrap(); + + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + friend_id, + ); + + let found = filtered.find_by_id(&asset.asset_id).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().asset_id, asset.asset_id); + } + + #[tokio::test] + async fn find_by_checksum_filters_inaccessible() { + let owner_id = SystemId::new(); + let stranger_id = SystemId::new(); + + let asset_a = make_asset(owner_id); + let mut asset_b = make_asset(stranger_id); + // Give asset_b the same checksum as asset_a + asset_b.source_reference.checksum = asset_a.source_reference.checksum.clone(); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset_a).await.unwrap(); + inner.save(&asset_b).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + + // Stranger queries by checksum — should only see their own + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + stranger_id, + ); + + let results = filtered + .find_by_checksum(&asset_a.source_reference.checksum) + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].owner_user_id, stranger_id); + } + + #[tokio::test] + async fn find_by_owner_skips_filter_for_own_assets() { + let owner_id = SystemId::new(); + let asset = make_asset(owner_id); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + owner_id, + ); + + let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap(); + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn find_by_owner_filters_others_assets() { + let owner_id = SystemId::new(); + let stranger_id = SystemId::new(); + let asset = make_asset(owner_id); + + let inner = Arc::new(InMemoryAssetRepository::new()); + inner.save(&asset).await.unwrap(); + + let share_repo = Arc::new(InMemoryShareRepository::new()); + + // Stranger queries owner's assets without a share — should get nothing + let filtered = VisibilityFilteredAssetRepository::new( + inner.clone(), + share_repo.clone(), + stranger_id, + ); + + let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap(); + assert!(results.is_empty()); + } +} diff --git a/crates/application/tests/catalog/queries/get_timeline.rs b/crates/application/tests/catalog/queries/get_timeline.rs index e39009e..9fc436f 100644 --- a/crates/application/tests/catalog/queries/get_timeline.rs +++ b/crates/application/tests/catalog/queries/get_timeline.rs @@ -31,6 +31,7 @@ async fn returns_paginated_assets() { let page = handler .execute(GetTimelineQuery { owner_id: owner, + caller_id: None, limit: 3, offset: 0, }) @@ -50,6 +51,7 @@ async fn returns_empty_for_no_assets() { let page = handler .execute(GetTimelineQuery { owner_id: SystemId::new(), + caller_id: None, limit: 10, offset: 0, }) diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs index dbe5c1c..63a0307 100644 --- a/crates/presentation/src/handlers/assets.rs +++ b/crates/presentation/src/handlers/assets.rs @@ -82,6 +82,7 @@ pub async fn timeline( ) -> Result, AppError> { let query = GetTimelineQuery { owner_id: claims.user_id, + caller_id: None, limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE), offset: params.offset.unwrap_or(0), };