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