feat: add VisibilityFilteredAssetRepository decorator for automatic access control on asset queries

This commit is contained in:
2026-05-31 19:06:49 +02:00
parent 0b2237860e
commit b5cda3afeb
6 changed files with 358 additions and 7 deletions

View File

@@ -1,8 +1,10 @@
pub mod commands; pub mod commands;
pub mod queries; pub mod queries;
pub mod visibility;
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler}; pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery};
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use visibility::VisibilityFilteredAssetRepository;

View File

@@ -1,8 +1,9 @@
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
use domain::{ use domain::{
catalog::entities::Asset, catalog::entities::Asset,
catalog::services::resolve_metadata, catalog::services::resolve_metadata,
errors::DomainError, errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository}, ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
value_objects::{StructuredData, SystemId}, value_objects::{StructuredData, SystemId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -16,6 +17,7 @@ pub struct GetAssetQuery {
pub struct GetAssetHandler { pub struct GetAssetHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
metadata_repo: Arc<dyn AssetMetadataRepository>, metadata_repo: Arc<dyn AssetMetadataRepository>,
share_repo: Option<Arc<dyn ShareRepository>>,
} }
impl GetAssetHandler { impl GetAssetHandler {
@@ -26,6 +28,28 @@ impl GetAssetHandler {
Self { Self {
asset_repo, asset_repo,
metadata_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, &self,
query: GetAssetQuery, query: GetAssetQuery,
) -> Result<(Asset, StructuredData), DomainError> { ) -> Result<(Asset, StructuredData), DomainError> {
let asset = self let repo = self.effective_repo(query.user_id);
.asset_repo
let asset = repo
.find_by_id(&query.asset_id) .find_by_id(&query.asset_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; .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())); return Err(DomainError::Forbidden("Access denied".to_string()));
} }

View File

@@ -1,8 +1,9 @@
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
use domain::{ use domain::{
catalog::entities::Asset, catalog::entities::Asset,
catalog::services::resolve_metadata, catalog::services::resolve_metadata,
errors::DomainError, errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository}, ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
value_objects::{StructuredData, SystemId}, value_objects::{StructuredData, SystemId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -10,6 +11,7 @@ use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GetTimelineQuery { pub struct GetTimelineQuery {
pub owner_id: SystemId, pub owner_id: SystemId,
pub caller_id: Option<SystemId>,
pub limit: u32, pub limit: u32,
pub offset: u32, pub offset: u32,
} }
@@ -17,6 +19,7 @@ pub struct GetTimelineQuery {
pub struct GetTimelineHandler { pub struct GetTimelineHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
metadata_repo: Arc<dyn AssetMetadataRepository>, metadata_repo: Arc<dyn AssetMetadataRepository>,
share_repo: Option<Arc<dyn ShareRepository>>,
} }
impl GetTimelineHandler { impl GetTimelineHandler {
@@ -27,6 +30,24 @@ impl GetTimelineHandler {
Self { Self {
asset_repo, asset_repo,
metadata_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, &self,
query: GetTimelineQuery, query: GetTimelineQuery,
) -> Result<Vec<(Asset, StructuredData)>, DomainError> { ) -> Result<Vec<(Asset, StructuredData)>, DomainError> {
let assets = self let caller_id = query.caller_id.unwrap_or(query.owner_id);
.asset_repo let repo = self.effective_repo(caller_id);
let assets = repo
.find_by_owner(&query.owner_id, query.limit, query.offset) .find_by_owner(&query.owner_id, query.limit, query.offset)
.await?; .await?;

View 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());
}
}

View File

@@ -31,6 +31,7 @@ async fn returns_paginated_assets() {
let page = handler let page = handler
.execute(GetTimelineQuery { .execute(GetTimelineQuery {
owner_id: owner, owner_id: owner,
caller_id: None,
limit: 3, limit: 3,
offset: 0, offset: 0,
}) })
@@ -50,6 +51,7 @@ async fn returns_empty_for_no_assets() {
let page = handler let page = handler
.execute(GetTimelineQuery { .execute(GetTimelineQuery {
owner_id: SystemId::new(), owner_id: SystemId::new(),
caller_id: None,
limit: 10, limit: 10,
offset: 0, offset: 0,
}) })

View File

@@ -82,6 +82,7 @@ pub async fn timeline(
) -> Result<Json<TimelineResponse>, AppError> { ) -> Result<Json<TimelineResponse>, AppError> {
let query = GetTimelineQuery { let query = GetTimelineQuery {
owner_id: claims.user_id, owner_id: claims.user_id,
caller_id: None,
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE), limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
offset: params.offset.unwrap_or(0), offset: params.offset.unwrap_or(0),
}; };