resource_service.rs
821 lignes · 27939 octets
use crate::error::AppError; use crate::models::{resource, resource_share}; use crate::services::team_service::TeamService; use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; /// Permission levels for resource sharing (ordered by privilege). const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"]; /// A share target. Either an individual user or a team — permission /// resolution walks user-direct shares + every team the user belongs to and /// keeps the maximum level. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum Principal { /// An individual user identified by their UUID. User(Uuid), /// A team identified by its UUID. Team(Uuid), } /// Service for registering, querying, and sharing application resources. pub struct ResourceService; impl ResourceService { // ----------------------------------------------------------------- // Resource registry // ----------------------------------------------------------------- /// Register an application resource in the generic registry. /// /// Call this after creating a concrete entity (document, project, etc.) /// so the framework can manage ownership and sharing. pub async fn register( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, owner_id: Uuid, ) -> Result<resource::Model, AppError> { let model = resource::ActiveModel { id: Set(Uuid::new_v4()), resource_type: Set(resource_type.to_string()), resource_id: Set(resource_id), owner_id: Set(owner_id), is_public: Set(false), created_at: Set(Utc::now()), }; let result = model.insert(db).await?; Ok(result) } /// Look up the registry entry for a concrete resource. pub async fn find_by_type_and_id( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, ) -> Result<Option<resource::Model>, AppError> { let entry = resource::Entity::find() .filter(resource::Column::ResourceType.eq(resource_type)) .filter(resource::Column::ResourceId.eq(resource_id)) .one(db) .await?; Ok(entry) } /// Get a registry entry by its internal UUID. pub async fn find_by_id( db: &DatabaseConnection, id: Uuid, ) -> Result<Option<resource::Model>, AppError> { let entry = resource::Entity::find_by_id(id).one(db).await?; Ok(entry) } /// List all resources of a given type owned by a user. pub async fn owned_by_user( db: &DatabaseConnection, owner_id: Uuid, resource_type: &str, ) -> Result<Vec<resource::Model>, AppError> { let entries = resource::Entity::find() .filter(resource::Column::OwnerId.eq(owner_id)) .filter(resource::Column::ResourceType.eq(resource_type)) .order_by_desc(resource::Column::CreatedAt) .all(db) .await?; Ok(entries) } /// Toggle the public visibility flag of a resource. pub async fn set_public( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, is_public: bool, ) -> Result<resource::Model, AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; let mut active: resource::ActiveModel = entry.into(); active.is_public = Set(is_public); let updated = active.update(db).await?; Ok(updated) } /// Remove a resource from the registry (cascades to shares). pub async fn unregister( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, ) -> Result<(), AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id).await?; if let Some(entry) = entry { resource::Entity::delete_by_id(entry.id).exec(db).await?; } Ok(()) } // ----------------------------------------------------------------- // Sharing // ----------------------------------------------------------------- /// Share a resource with another user. /// /// If a share already exists for the same (resource, user), updates the /// permission level instead of creating a duplicate. pub async fn share( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, shared_with_user_id: Uuid, permission_level: &str, shared_by_user_id: Uuid, ) -> Result<resource_share::Model, AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; if entry.owner_id == shared_with_user_id { return Err(AppError::Validation( "Cannot share a resource with its owner".into(), )); } Self::validate_permission_level(permission_level)?; let existing = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter(resource_share::Column::SharedWithUserId.eq(shared_with_user_id)) .one(db) .await?; if let Some(existing) = existing { let mut active: resource_share::ActiveModel = existing.into(); active.permission_level = Set(permission_level.to_string()); active.shared_by_user_id = Set(shared_by_user_id); let updated = active.update(db).await?; return Ok(updated); } let share = resource_share::ActiveModel { id: Set(Uuid::new_v4()), resource_id: Set(entry.id), shared_with_user_id: Set(Some(shared_with_user_id)), shared_with_team_id: Set(None), shared_with_remote_identity_id: Set(None), permission_level: Set(permission_level.to_string()), shared_by_user_id: Set(shared_by_user_id), created_at: Set(Utc::now()), }; let result = share.insert(db).await?; Ok(result) } /// Share a resource with an arbitrary [`Principal`] (user or team). /// /// Generalises [`Self::share`] — the legacy helper stays as a thin wrapper /// so existing callers keep working. Uniqueness is enforced per /// `(resource_id, principal)` via the DB partial unique indexes: /// a second call updates the existing row's permission level. pub async fn share_with( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, principal: Principal, permission_level: &str, shared_by_user_id: Uuid, ) -> Result<resource_share::Model, AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; if let Principal::User(uid) = principal { if entry.owner_id == uid { return Err(AppError::Validation( "Cannot share a resource with its owner".into(), )); } } Self::validate_permission_level(permission_level)?; let (user_filter, team_filter): (Option<Uuid>, Option<Uuid>) = match principal { Principal::User(u) => (Some(u), None), Principal::Team(t) => (None, Some(t)), }; let existing = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter( Condition::all() .add(resource_share::Column::SharedWithUserId.eq(user_filter)) .add(resource_share::Column::SharedWithTeamId.eq(team_filter)), ) .one(db) .await?; if let Some(existing) = existing { let mut active: resource_share::ActiveModel = existing.into(); active.permission_level = Set(permission_level.to_string()); active.shared_by_user_id = Set(shared_by_user_id); return Ok(active.update(db).await?); } let share = resource_share::ActiveModel { id: Set(Uuid::new_v4()), resource_id: Set(entry.id), shared_with_user_id: Set(user_filter), shared_with_team_id: Set(team_filter), shared_with_remote_identity_id: Set(None), permission_level: Set(permission_level.to_string()), shared_by_user_id: Set(shared_by_user_id), created_at: Set(Utc::now()), }; Ok(share.insert(db).await?) } /// Revoke a share by its ID. pub async fn revoke_share(db: &DatabaseConnection, share_id: Uuid) -> Result<(), AppError> { resource_share::Entity::delete_by_id(share_id) .exec(db) .await?; Ok(()) } /// Revoke all shares for a specific (resource, user) pair. pub async fn revoke_user_access( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id).await?; if let Some(entry) = entry { resource_share::Entity::delete_many() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter(resource_share::Column::SharedWithUserId.eq(user_id)) .exec(db) .await?; } Ok(()) } /// List all shares for a given resource. pub async fn list_shares( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, ) -> Result<Vec<resource_share::Model>, AppError> { let entry = Self::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; let shares = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .order_by_desc(resource_share::Column::CreatedAt) .all(db) .await?; Ok(shares) } // ----------------------------------------------------------------- // Access checks // ----------------------------------------------------------------- /// Check whether a user can access a resource at the given permission level. /// /// Access is granted if any of these conditions is true: /// 1. The user is the owner. /// 2. The resource is public and `required_level` is `"read"`. /// 3. A share exists with a permission level >= `required_level`. pub async fn user_can_access( db: &DatabaseConnection, user_id: Uuid, resource_type: &str, resource_id: Uuid, required_level: &str, ) -> Result<bool, AppError> { let entry = match Self::find_by_type_and_id(db, resource_type, resource_id).await? { Some(e) => e, None => return Ok(false), }; if entry.owner_id == user_id { return Ok(true); } if entry.is_public && required_level == "read" { return Ok(true); } let share = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter(resource_share::Column::SharedWithUserId.eq(user_id)) .one(db) .await?; match share { Some(s) => Ok(Self::level_ge(&s.permission_level, required_level)), None => Ok(false), } } /// Get the effective permission level a user has on a resource. /// /// Returns `"owner"` for the owner, the share level if shared, /// `"read"` if public, or `None` if no access. pub async fn effective_permission( db: &DatabaseConnection, user_id: Uuid, resource_type: &str, resource_id: Uuid, ) -> Result<Option<String>, AppError> { let entry = match Self::find_by_type_and_id(db, resource_type, resource_id).await? { Some(e) => e, None => return Ok(None), }; if entry.owner_id == user_id { return Ok(Some("owner".to_string())); } let share = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter(resource_share::Column::SharedWithUserId.eq(user_id)) .one(db) .await?; if let Some(s) = share { return Ok(Some(s.permission_level)); } if entry.is_public { return Ok(Some("read".to_string())); } Ok(None) } /// Compute the effective permission a user has on a resource, walking: /// 1. Ownership → `"owner"`. /// 2. Direct user share → that share's level. /// 3. Every team the user belongs to whose team-share targets the /// resource → take the maximum level across those. /// 4. Public flag → `"read"`. /// /// Returns `None` when the user has no access at all. /// /// Supersedes [`Self::effective_permission`]; the legacy fn remains for /// API compatibility but ignores team memberships. pub async fn effective_permission_for_user( db: &DatabaseConnection, user_id: Uuid, resource_type: &str, resource_id: Uuid, ) -> Result<Option<String>, AppError> { let entry = match Self::find_by_type_and_id(db, resource_type, resource_id).await? { Some(e) => e, None => return Ok(None), }; if entry.owner_id == user_id { return Ok(Some("owner".to_string())); } // Single query pulls both "direct user" and "via team" shares in one // round-trip. The DB CHECK guarantees each row is exactly one flavour. let team_ids = TeamService::team_ids_of_user(db, user_id).await?; let mut condition = Condition::any().add(resource_share::Column::SharedWithUserId.eq(Some(user_id))); if !team_ids.is_empty() { condition = condition.add(resource_share::Column::SharedWithTeamId.is_in(team_ids)); } let shares = resource_share::Entity::find() .filter(resource_share::Column::ResourceId.eq(entry.id)) .filter(condition) .all(db) .await?; let best = shares .iter() .map(|s| Self::level_rank(&s.permission_level)) .max(); if let Some(rank) = best { return Ok(Some(PERMISSION_HIERARCHY[rank].to_string())); } if entry.is_public { return Ok(Some("read".to_string())); } Ok(None) } /// List all resources of a given type that are shared with a user. pub async fn shared_with_user( db: &DatabaseConnection, user_id: Uuid, resource_type: &str, ) -> Result<Vec<(resource::Model, resource_share::Model)>, AppError> { let shares = resource_share::Entity::find() .filter(resource_share::Column::SharedWithUserId.eq(Some(user_id))) .all(db) .await?; let resource_ids: Vec<Uuid> = shares.iter().map(|s| s.resource_id).collect(); if resource_ids.is_empty() { return Ok(vec![]); } let resources = resource::Entity::find() .filter(resource::Column::Id.is_in(resource_ids.clone())) .filter(resource::Column::ResourceType.eq(resource_type)) .all(db) .await?; let pairs: Vec<(resource::Model, resource_share::Model)> = resources .into_iter() .filter_map(|r| { let share = shares.iter().find(|s| s.resource_id == r.id)?.clone(); Some((r, share)) }) .collect(); Ok(pairs) } /// List all resources a user can access (owned + shared + public). pub async fn accessible_by_user( db: &DatabaseConnection, user_id: Uuid, resource_type: &str, ) -> Result<Vec<resource::Model>, AppError> { let owned = Self::owned_by_user(db, user_id, resource_type).await?; let shared: Vec<resource::Model> = Self::shared_with_user(db, user_id, resource_type) .await? .into_iter() .map(|(r, _)| r) .collect(); let public = resource::Entity::find() .filter(resource::Column::ResourceType.eq(resource_type)) .filter(resource::Column::IsPublic.eq(true)) .all(db) .await?; let mut all = owned; for r in shared.into_iter().chain(public) { if !all.iter().any(|existing| existing.id == r.id) { all.push(r); } } Ok(all) } // ----------------------------------------------------------------- // Helpers // ----------------------------------------------------------------- fn validate_permission_level(level: &str) -> Result<(), AppError> { if PERMISSION_HIERARCHY.contains(&level) { Ok(()) } else { Err(AppError::Validation(format!( "Invalid permission level '{}'. Must be one of: {}", level, PERMISSION_HIERARCHY.join(", ") ))) } } /// Returns true if `granted` >= `required` in the hierarchy. fn level_ge(granted: &str, required: &str) -> bool { let granted_idx = PERMISSION_HIERARCHY .iter() .position(|&l| l == granted) .unwrap_or(0); let required_idx = PERMISSION_HIERARCHY .iter() .position(|&l| l == required) .unwrap_or(usize::MAX); granted_idx >= required_idx } /// Numeric rank of a permission level (higher = more privileged). Unknown /// levels fall back to 0 so they are never preferred over a known one. fn level_rank(level: &str) -> usize { PERMISSION_HIERARCHY .iter() .position(|&l| l == level) .unwrap_or(0) } } #[cfg(test)] mod tests { use super::*; use crate::services::password_service::PasswordService; use crate::services::team_service::{TeamRole, TeamService}; use crate::services::user_service::UserService; use crate::types::TeamSlug; async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL") .expect("DATABASE_URL required for resource_service tests"); sea_orm::Database::connect(&url).await.unwrap() } async fn fresh_user(db: &DatabaseConnection) -> Uuid { let unique = Uuid::new_v4().simple().to_string(); let hash = PasswordService::hash_password("IrrelevantForTests1!") .await .unwrap(); UserService::create_user( db, format!("res-test-{}", &unique[..12]), format!("res_test_{}@example.com", &unique[..12]), hash, false, ) .await .unwrap() .id } fn fresh_slug() -> TeamSlug { let raw = format!("rs-{}", &Uuid::new_v4().simple().to_string()[..10]); TeamSlug::parse(&raw).unwrap() } async fn register_resource(db: &DatabaseConnection, owner: Uuid) -> Uuid { let rid = Uuid::new_v4(); ResourceService::register(db, "test_resource", rid, owner) .await .unwrap(); rid } #[tokio::test] async fn share_with_user_principal_is_backwards_compatible() { let db = test_db().await; let owner = fresh_user(&db).await; let alice = fresh_user(&db).await; let rid = register_resource(&db, owner).await; ResourceService::share_with( &db, "test_resource", rid, Principal::User(alice), "write", owner, ) .await .unwrap(); let perm = ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid) .await .unwrap(); assert_eq!(perm.as_deref(), Some("write")); } #[tokio::test] async fn share_with_user_refuses_sharing_back_to_owner() { let db = test_db().await; let owner = fresh_user(&db).await; let rid = register_resource(&db, owner).await; let err = ResourceService::share_with( &db, "test_resource", rid, Principal::User(owner), "read", owner, ) .await .unwrap_err(); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn share_with_team_grants_access_to_every_member() { let db = test_db().await; let owner = fresh_user(&db).await; let alice = fresh_user(&db).await; let bob = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "Devs", None) .await .unwrap(); TeamService::add_member(&db, team.id, alice, TeamRole::Member, None) .await .unwrap(); TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) .await .unwrap(); let rid = register_resource(&db, owner).await; ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team.id), "read", owner, ) .await .unwrap(); let alice_perm = ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid) .await .unwrap(); let bob_perm = ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) .await .unwrap(); assert_eq!(alice_perm.as_deref(), Some("read")); assert_eq!(bob_perm.as_deref(), Some("read")); } #[tokio::test] async fn effective_permission_takes_max_across_user_and_teams() { let db = test_db().await; let owner = fresh_user(&db).await; let bob = fresh_user(&db).await; let team_read = TeamService::create(&db, owner, fresh_slug(), "Read", None) .await .unwrap(); let team_admin = TeamService::create(&db, owner, fresh_slug(), "Admin", None) .await .unwrap(); TeamService::add_member(&db, team_read.id, bob, TeamRole::Member, None) .await .unwrap(); TeamService::add_member(&db, team_admin.id, bob, TeamRole::Member, None) .await .unwrap(); let rid = register_resource(&db, owner).await; // Bob gets "write" directly. ResourceService::share_with( &db, "test_resource", rid, Principal::User(bob), "write", owner, ) .await .unwrap(); // "Read" team only has read. ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team_read.id), "read", owner, ) .await .unwrap(); // "Admin" team has admin → should win. ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team_admin.id), "admin", owner, ) .await .unwrap(); let perm = ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) .await .unwrap(); assert_eq!(perm.as_deref(), Some("admin")); } #[tokio::test] async fn removing_user_from_team_drops_team_based_access() { let db = test_db().await; let owner = fresh_user(&db).await; let bob = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None) .await .unwrap(); TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) .await .unwrap(); let rid = register_resource(&db, owner).await; ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team.id), "write", owner, ) .await .unwrap(); // Sanity: bob has write via team. assert_eq!( ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) .await .unwrap() .as_deref(), Some("write") ); TeamService::remove_member(&db, team.id, bob, None) .await .unwrap(); // After removal bob has no access. assert_eq!( ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) .await .unwrap(), None ); } #[tokio::test] async fn share_with_team_is_idempotent_and_updates_level() { let db = test_db().await; let owner = fresh_user(&db).await; let bob = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "T", None) .await .unwrap(); TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) .await .unwrap(); let rid = register_resource(&db, owner).await; ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team.id), "read", owner, ) .await .unwrap(); ResourceService::share_with( &db, "test_resource", rid, Principal::Team(team.id), "write", owner, ) .await .unwrap(); // Exactly one share row — second call upserted. let shares = ResourceService::list_shares(&db, "test_resource", rid) .await .unwrap(); let team_shares: Vec<_> = shares .iter() .filter(|s| s.shared_with_team_id == Some(team.id)) .collect(); assert_eq!(team_shares.len(), 1); assert_eq!(team_shares[0].permission_level, "write"); } #[test] fn test_level_ge() { assert!(ResourceService::level_ge("admin", "read")); assert!(ResourceService::level_ge("admin", "write")); assert!(ResourceService::level_ge("admin", "admin")); assert!(ResourceService::level_ge("write", "read")); assert!(ResourceService::level_ge("write", "write")); assert!(!ResourceService::level_ge("write", "admin")); assert!(ResourceService::level_ge("read", "read")); assert!(!ResourceService::level_ge("read", "write")); assert!(!ResourceService::level_ge("read", "admin")); } #[test] fn test_validate_permission_level() { assert!(ResourceService::validate_permission_level("read").is_ok()); assert!(ResourceService::validate_permission_level("write").is_ok()); assert!(ResourceService::validate_permission_level("admin").is_ok()); assert!(ResourceService::validate_permission_level("superadmin").is_err()); assert!(ResourceService::validate_permission_level("").is_err()); } }
GitRust