feat: add VisibilityFilteredAssetRepository decorator for automatic access control on asset queries
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<dyn AssetRepository>,
|
||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||
share_repo: Option<Arc<dyn ShareRepository>>,
|
||||
}
|
||||
|
||||
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<dyn ShareRepository>) -> 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<dyn AssetRepository> {
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SystemId>,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
@@ -17,6 +19,7 @@ pub struct GetTimelineQuery {
|
||||
pub struct GetTimelineHandler {
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||
share_repo: Option<Arc<dyn ShareRepository>>,
|
||||
}
|
||||
|
||||
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<dyn ShareRepository>) -> Self {
|
||||
self.share_repo = Some(share_repo);
|
||||
self
|
||||
}
|
||||
|
||||
fn effective_repo(&self, caller_id: SystemId) -> Arc<dyn AssetRepository> {
|
||||
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<Vec<(Asset, StructuredData)>, 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?;
|
||||
|
||||
|
||||
296
crates/application/src/catalog/visibility.rs
Normal file
296
crates/application/src/catalog/visibility.rs
Normal file
@@ -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<dyn AssetRepository>,
|
||||
share_repo: Arc<dyn ShareRepository>,
|
||||
caller_id: SystemId,
|
||||
}
|
||||
|
||||
impl VisibilityFilteredAssetRepository {
|
||||
pub fn new(
|
||||
inner: Arc<dyn AssetRepository>,
|
||||
share_repo: Arc<dyn ShareRepository>,
|
||||
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<bool, DomainError> {
|
||||
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<Option<Asset>, 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<Vec<Asset>, 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<Vec<Asset>, 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user