remote_resource_share_service.rs
462 lignes · 17740 octets
//! Sharing of generic application resources with remote ActivityPub //! identities (RFC 0029). //! //! Companion of //! [`rustwarden_core::services::resource_service::ResourceService`] but //! scoped to `remote_identities` instead of users / teams. The consumer //! composes the two services when resolving the effective permission of //! a [`rustwarden_core::identity::Identity`] — see //! [`docs/fediverse-consumer-guide.md`](../../../../docs/fediverse-consumer-guide.md). //! //! Schema lives in migration //! `m20260516_000002_remote_resource_shares`. Permission levels mirror //! the framework hierarchy: `"read" < "write" < "admin"`. use crate::models::fediverse_resource_share; use chrono::Utc; use rustwarden_core::error::AppError; use rustwarden_core::services::resource_service::ResourceService; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; /// Permission levels accepted by [`RemoteResourceShareService::share`], /// in ascending order of capability. /// /// Mirrors `rustwarden_core::services::resource_service`'s hierarchy — /// composition is straightforward (the higher level wins). pub const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"]; /// Sharing of generic application resources with remote ActivityPub /// identities. /// /// Stateless service — all methods are associated `async fn` and take /// `&DatabaseConnection` as their first parameter, following the /// rustwarden conventions (see /// [`docs/SERVICES_REFERENCE.md`](../../../../docs/SERVICES_REFERENCE.md)). /// /// # Example /// /// ```rust,ignore /// use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService; /// /// // Share `repo_id` with `@alice@autre.org` (resolved as `remote_id`) in write mode. /// RemoteResourceShareService::share( /// &db, "repository", repo_id, remote_id, "write", admin_user_id, /// ).await?; /// /// // Later, resolve the effective permission on a read attempt. /// let level = RemoteResourceShareService::effective_permission_for_remote_identity( /// &db, "repository", repo_id, remote_id, /// ).await?; /// assert_eq!(level.as_deref(), Some("write")); /// ``` pub struct RemoteResourceShareService; impl RemoteResourceShareService { /// Share a resource with a remote identity at the given permission /// level. Upsert by `(resource_id, remote_identity_id)` — an existing /// share is updated to the new permission level, rather than /// inserting a duplicate. /// /// # Errors /// /// - [`AppError::NotFound`] if the resource registry entry does not /// exist for `(resource_type, resource_id)`. /// - [`AppError::Validation`] if `permission_level` is not one of /// `"read" | "write" | "admin"`. /// - [`AppError::Database`] on FK violation (e.g. unknown /// `remote_identity_id`) or other persistence error. pub async fn share( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, permission_level: &str, shared_by_user_id: Uuid, ) -> Result<fediverse_resource_share::Model, AppError> { let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| { AppError::NotFound(format!("Resource not found: {resource_type}/{resource_id}")) })?; if !PERMISSION_HIERARCHY.contains(&permission_level) { return Err(AppError::Validation(format!( "Invalid permission level: {permission_level}" ))); } // Upsert by (resource_id, remote_identity_id). let existing = fediverse_resource_share::Entity::find() .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id)) .filter(fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id)) .one(db) .await?; match existing { Some(row) => { // Update permission level + grantor in case it changed. let mut active: fediverse_resource_share::ActiveModel = row.into(); active.permission_level = Set(permission_level.to_string()); active.shared_by_user_id = Set(shared_by_user_id); Ok(active.update(db).await?) } None => { let active = fediverse_resource_share::ActiveModel { id: Set(Uuid::new_v4()), resource_id: Set(entry.id), remote_identity_id: Set(remote_identity_id), permission_level: Set(permission_level.to_string()), shared_by_user_id: Set(shared_by_user_id), created_at: Set(Utc::now()), }; Ok(active.insert(db).await?) } } } /// Revoke a remote share by its `(resource, remote_identity)` pair. /// Returns `true` if a row was deleted, `false` if no share existed. /// /// # Errors /// /// - [`AppError::NotFound`] if the resource registry entry is /// missing — distinguishes "resource never existed" from "share /// does not exist". /// - [`AppError::Database`] on persistence error. pub async fn unshare( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, ) -> Result<bool, AppError> { let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| { AppError::NotFound(format!("Resource not found: {resource_type}/{resource_id}")) })?; let result = fediverse_resource_share::Entity::delete_many() .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id)) .filter(fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id)) .exec(db) .await?; Ok(result.rows_affected > 0) } /// Resolve the effective permission level of a remote identity on a /// resource. Returns `Some("read" | "write" | "admin")` if a share /// exists, `None` otherwise. /// /// The consumer composes this with /// [`ResourceService::effective_permission_for_user`] when resolving /// an [`rustwarden_core::identity::Identity::Remote`]. /// /// # Errors /// /// - [`AppError::NotFound`] if the resource registry entry is missing. /// - [`AppError::Database`] on persistence error. pub async fn effective_permission_for_remote_identity( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, ) -> Result<Option<String>, AppError> { let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| { AppError::NotFound(format!("Resource not found: {resource_type}/{resource_id}")) })?; let row = fediverse_resource_share::Entity::find() .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id)) .filter(fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id)) .one(db) .await?; Ok(row.map(|m| m.permission_level)) } /// List all remote shares for a resource (admin display, ordered by /// most recent first). /// /// # Errors /// /// - [`AppError::NotFound`] if the resource registry entry is missing. /// - [`AppError::Database`] on persistence error. pub async fn list_shares( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| { AppError::NotFound(format!("Resource not found: {resource_type}/{resource_id}")) })?; let rows = fediverse_resource_share::Entity::find() .filter(fediverse_resource_share::Column::ResourceId.eq(entry.id)) .order_by_desc(fediverse_resource_share::Column::CreatedAt) .all(db) .await?; Ok(rows) } /// List all resources shared with a given remote identity (for a /// "my access" page on the remote identity's side). Ordered by most /// recent first. pub async fn list_for_remote_identity( db: &DatabaseConnection, remote_identity_id: Uuid, ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { let rows = fediverse_resource_share::Entity::find() .filter(fediverse_resource_share::Column::RemoteIdentityId.eq(remote_identity_id)) .order_by_desc(fediverse_resource_share::Column::CreatedAt) .all(db) .await?; Ok(rows) } } #[cfg(test)] mod tests { use super::*; use rustwarden_core::models::resource; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn resource_row(rtype: &str, rid: Uuid) -> resource::Model { resource::Model { id: Uuid::new_v4(), resource_type: rtype.to_string(), resource_id: rid, owner_id: Uuid::new_v4(), is_public: false, created_at: Utc::now(), } } fn share_row( resource_id: Uuid, remote_id: Uuid, level: &str, ) -> fediverse_resource_share::Model { fediverse_resource_share::Model { id: Uuid::new_v4(), resource_id, remote_identity_id: remote_id, permission_level: level.to_string(), shared_by_user_id: Uuid::new_v4(), created_at: Utc::now(), } } // ─── Pure helpers ──────────────────────────────────────────────── #[test] fn permission_hierarchy_is_ordered() { assert_eq!(PERMISSION_HIERARCHY, &["read", "write", "admin"]); } // ─── share() ───────────────────────────────────────────────────── #[tokio::test] async fn share_rejects_unknown_resource() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<resource::Model>::new()]) .into_connection(); let err = RemoteResourceShareService::share( &db, "repository", Uuid::new_v4(), Uuid::new_v4(), "read", Uuid::new_v4(), ) .await .unwrap_err(); assert!(matches!(err, AppError::NotFound(_))); } #[tokio::test] async fn share_rejects_invalid_permission_level() { let entry = resource_row("repository", Uuid::new_v4()); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .into_connection(); let err = RemoteResourceShareService::share( &db, "repository", Uuid::new_v4(), Uuid::new_v4(), "owner", Uuid::new_v4(), ) .await .unwrap_err(); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn share_inserts_when_no_existing_row() { let entry = resource_row("repository", Uuid::new_v4()); let entry_id = entry.id; let remote_id = Uuid::new_v4(); let grantor = Uuid::new_v4(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) // No existing share row. .append_query_results([Vec::<fediverse_resource_share::Model>::new()]) // Insert returns the new row. .append_query_results([vec![share_row(entry_id, remote_id, "read")]]) .into_connection(); let model = RemoteResourceShareService::share( &db, "repository", Uuid::new_v4(), remote_id, "read", grantor, ) .await .expect("share should succeed"); assert_eq!(model.resource_id, entry_id); assert_eq!(model.remote_identity_id, remote_id); assert_eq!(model.permission_level, "read"); } #[tokio::test] async fn share_updates_when_existing_row() { let entry = resource_row("repository", Uuid::new_v4()); let entry_id = entry.id; let remote_id = Uuid::new_v4(); let existing = share_row(entry_id, remote_id, "read"); let updated = fediverse_resource_share::Model { permission_level: "write".to_string(), ..existing.clone() }; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_query_results([vec![existing]]) // Update returns the updated row. .append_query_results([vec![updated.clone()]]) .into_connection(); let model = RemoteResourceShareService::share( &db, "repository", Uuid::new_v4(), remote_id, "write", Uuid::new_v4(), ) .await .expect("share should succeed (upsert)"); assert_eq!(model.permission_level, "write"); } // ─── unshare() ─────────────────────────────────────────────────── #[tokio::test] async fn unshare_returns_true_when_row_deleted() { let entry = resource_row("repository", Uuid::new_v4()); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let deleted = RemoteResourceShareService::unshare(&db, "repository", Uuid::new_v4(), Uuid::new_v4()) .await .unwrap(); assert!(deleted); } #[tokio::test] async fn unshare_returns_false_when_no_row() { let entry = resource_row("repository", Uuid::new_v4()); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 0, }]) .into_connection(); let deleted = RemoteResourceShareService::unshare(&db, "repository", Uuid::new_v4(), Uuid::new_v4()) .await .unwrap(); assert!(!deleted); } // ─── effective_permission_for_remote_identity() ────────────────── #[tokio::test] async fn effective_permission_returns_some_when_share_exists() { let entry = resource_row("repository", Uuid::new_v4()); let entry_id = entry.id; let remote_id = Uuid::new_v4(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_query_results([vec![share_row(entry_id, remote_id, "admin")]]) .into_connection(); let level = RemoteResourceShareService::effective_permission_for_remote_identity( &db, "repository", Uuid::new_v4(), remote_id, ) .await .unwrap(); assert_eq!(level.as_deref(), Some("admin")); } #[tokio::test] async fn effective_permission_returns_none_when_no_share() { let entry = resource_row("repository", Uuid::new_v4()); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_query_results([Vec::<fediverse_resource_share::Model>::new()]) .into_connection(); let level = RemoteResourceShareService::effective_permission_for_remote_identity( &db, "repository", Uuid::new_v4(), Uuid::new_v4(), ) .await .unwrap(); assert!(level.is_none()); } // ─── list_shares() / list_for_remote_identity() ────────────────── #[tokio::test] async fn list_shares_returns_empty_when_resource_unshared() { let entry = resource_row("repository", Uuid::new_v4()); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![entry]]) .append_query_results([Vec::<fediverse_resource_share::Model>::new()]) .into_connection(); let rows = RemoteResourceShareService::list_shares(&db, "repository", Uuid::new_v4()) .await .unwrap(); assert!(rows.is_empty()); } #[tokio::test] async fn list_for_remote_identity_returns_empty_when_no_shares() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<fediverse_resource_share::Model>::new()]) .into_connection(); let rows = RemoteResourceShareService::list_for_remote_identity(&db, Uuid::new_v4()) .await .unwrap(); assert!(rows.is_empty()); } }
GitRust