team_service.rs
633 lignes · 22207 octets
//! Team service. //! //! CRUD + membership management for [`teams`](crate::models::team) and //! [`team_members`](crate::models::team_member). Integrates with //! [`RustwardenHooks`](crate::hooks::RustwardenHooks) (on_team_* events) so //! consumer apps can mirror team state into their own indices. //! //! Teams are the non-user principal accepted by //! [`ResourceService::share_with`](crate::services::resource_service::ResourceService::share_with). //! Resolving effective permissions across users + teams is handled by //! [`ResourceService::effective_permission_for_user`](crate::services::resource_service::ResourceService::effective_permission_for_user). use crate::error::AppError; use crate::hooks::RustwardenHooks; use crate::models::{team, team_member}; use crate::types::TeamSlug; use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, }; use std::sync::Arc; use uuid::Uuid; /// Allowed membership roles inside a team. /// /// `Owner` duplicates the `team.owner_id` column for convenience: the creator /// is inserted as an owner member at creation time so `teams_of_user` works /// without a `UNION` join. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum TeamRole { /// The user who created the team; has full control. Owner, /// A member with elevated privileges who can manage membership. Admin, /// A regular team member with read-only access to team resources. Member, } impl TeamRole { /// Return the lowercase string representation of this role. pub fn as_str(&self) -> &'static str { match self { TeamRole::Owner => "owner", TeamRole::Admin => "admin", TeamRole::Member => "member", } } /// Parse a raw string into a `TeamRole`, returning `None` for unrecognised values. pub fn parse(raw: &str) -> Option<Self> { match raw { "owner" => Some(Self::Owner), "admin" => Some(Self::Admin), "member" => Some(Self::Member), _ => None, } } } /// Service for creating and managing teams and their memberships. pub struct TeamService; impl TeamService { /// Create a team and insert the owner as the first (owner-role) member. /// /// Errors: /// - `AppError::Validation` if `name` is empty. /// - `AppError::Database` on unique constraint violation (slug collision). pub async fn create( db: &DatabaseConnection, owner_id: Uuid, slug: TeamSlug, name: &str, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<team::Model, AppError> { let name = name.trim(); if name.is_empty() || name.len() > 128 { return Err(AppError::Validation( "Team name must be 1-128 characters".into(), )); } let team_id = Uuid::new_v4(); let record = team::ActiveModel { id: Set(team_id), slug: Set(slug.into_inner()), name: Set(name.to_string()), owner_id: Set(owner_id), created_at: Set(Utc::now()), } .insert(db) .await?; team_member::ActiveModel { id: Set(Uuid::new_v4()), team_id: Set(team_id), user_id: Set(Some(owner_id)), role: Set(TeamRole::Owner.as_str().to_string()), joined_at: Set(Utc::now()), remote_identity_id: Set(None), } .insert(db) .await?; if let Some(hooks) = hooks { if let Err(e) = hooks.on_team_created(db, team_id, owner_id).await { tracing::warn!(team_id = %team_id, "on_team_created hook failed: {}", e); } } Ok(record) } /// Look up a team by its id. pub async fn find_by_id( db: &DatabaseConnection, team_id: Uuid, ) -> Result<Option<team::Model>, AppError> { Ok(team::Entity::find_by_id(team_id).one(db).await?) } /// Look up a team by its unique slug. pub async fn find_by_slug( db: &DatabaseConnection, slug: &TeamSlug, ) -> Result<Option<team::Model>, AppError> { Ok(team::Entity::find() .filter(team::Column::Slug.eq(slug.as_str())) .one(db) .await?) } /// Add a member to a team. Idempotent on `(team_id, user_id)` — updates /// the role if the edge already exists. pub async fn add_member( db: &DatabaseConnection, team_id: Uuid, user_id: Uuid, role: TeamRole, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<team_member::Model, AppError> { // Upsert via partial-unique-index lookup (RFC 0018: PK is now // a surrogate `id`; the `(team_id, user_id) WHERE user_id IS // NOT NULL` partial unique index keeps row-level uniqueness). let existing = team_member::Entity::find() .filter(team_member::Column::TeamId.eq(team_id)) .filter(team_member::Column::UserId.eq(Some(user_id))) .one(db) .await?; let record = if let Some(existing) = existing { let mut active: team_member::ActiveModel = existing.into(); active.role = Set(role.as_str().to_string()); active.update(db).await? } else { team_member::ActiveModel { id: Set(Uuid::new_v4()), team_id: Set(team_id), user_id: Set(Some(user_id)), role: Set(role.as_str().to_string()), joined_at: Set(Utc::now()), remote_identity_id: Set(None), } .insert(db) .await? }; if let Some(hooks) = hooks { if let Err(e) = hooks.on_team_member_added(db, team_id, user_id).await { tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_added hook failed: {}", e); } } Ok(record) } /// RFC 0009: add a member by [`crate::identity::Identity`]. Local identities take the /// existing `(team_id, user_id)` happy path; remote identities are /// inserted with `user_id = nil` (PK constraint) and the /// `remote_identity_id` sibling column populated. The unique /// partial index `team_members_remote_unique` (added by migration /// `m20260510_000015`) prevents duplicate remote rows for the same /// (team, identity) pair. pub async fn add_member_identity( db: &DatabaseConnection, team_id: Uuid, identity: crate::identity::Identity, role: TeamRole, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<team_member::Model, AppError> { match identity { crate::identity::Identity::Local(user_id) => { Self::add_member(db, team_id, user_id, role, hooks).await } crate::identity::Identity::Remote(remote_id) => { let active = team_member::ActiveModel { id: Set(Uuid::new_v4()), team_id: Set(team_id), user_id: Set(None), role: Set(role.as_str().to_string()), joined_at: Set(Utc::now()), remote_identity_id: Set(Some(remote_id)), }; Ok(active.insert(db).await?) } } } /// RFC 0009: every team the [`crate::identity::Identity`] belongs to. Local /// identities re-use [`Self::teams_of_user`]; remote identities /// are looked up via `team_members.remote_identity_id`. pub async fn teams_of_identity( db: &DatabaseConnection, identity: crate::identity::Identity, ) -> Result<Vec<team::Model>, AppError> { use sea_orm::{JoinType, QuerySelect, RelationTrait}; match identity { crate::identity::Identity::Local(user_id) => Self::teams_of_user(db, user_id).await, crate::identity::Identity::Remote(remote_id) => Ok(team::Entity::find() .join_rev(JoinType::InnerJoin, team_member::Relation::Team.def()) .filter(team_member::Column::RemoteIdentityId.eq(Some(remote_id))) .all(db) .await?), } } /// Remove a member from a team. Refuses to remove the team owner — use /// `delete` to drop the whole team instead. pub async fn remove_member( db: &DatabaseConnection, team_id: Uuid, user_id: Uuid, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<(), AppError> { let team = Self::find_by_id(db, team_id) .await? .ok_or_else(|| AppError::NotFound(format!("Team {team_id}")))?; if team.owner_id == user_id { return Err(AppError::Validation( "Team owner cannot be removed; delete the team instead".into(), )); } team_member::Entity::delete_many() .filter(team_member::Column::TeamId.eq(team_id)) .filter(team_member::Column::UserId.eq(Some(user_id))) .exec(db) .await?; if let Some(hooks) = hooks { if let Err(e) = hooks.on_team_member_removed(db, team_id, user_id).await { tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_removed hook failed: {}", e); } } Ok(()) } /// List every (user, role) pair for the given team, most-recent-join first. pub async fn list_members( db: &DatabaseConnection, team_id: Uuid, ) -> Result<Vec<team_member::Model>, AppError> { Ok(team_member::Entity::find() .filter(team_member::Column::TeamId.eq(team_id)) .order_by_desc(team_member::Column::JoinedAt) .all(db) .await?) } /// List every team the user belongs to. pub async fn teams_of_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<team::Model>, AppError> { use sea_orm::{JoinType, QuerySelect, RelationTrait}; let teams = team::Entity::find() .join_rev(JoinType::InnerJoin, team_member::Relation::Team.def()) .filter(team_member::Column::UserId.eq(user_id)) .order_by_desc(team::Column::CreatedAt) .all(db) .await?; Ok(teams) } /// Return every `team_id` the user belongs to. Cheap lookup used by /// permission resolution. pub async fn team_ids_of_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<Uuid>, AppError> { let rows = team_member::Entity::find() .filter(team_member::Column::UserId.eq(user_id)) .all(db) .await?; Ok(rows.into_iter().map(|r| r.team_id).collect()) } /// Hard-delete a team. Cascade drops `team_members` (composite FK) and /// `resource_shares` where the team was the principal (FK on /// `shared_with_team_id`). pub async fn delete( db: &DatabaseConnection, team_id: Uuid, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<(), AppError> { let existed = team::Entity::find_by_id(team_id).one(db).await?.is_some(); team::Entity::delete_by_id(team_id).exec(db).await?; if existed { if let Some(hooks) = hooks { if let Err(e) = hooks.on_team_deleted(db, team_id).await { tracing::warn!(team_id = %team_id, "on_team_deleted hook failed: {}", e); } } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::hooks::{AuditEvent, RustwardenHooks}; use crate::services::password_service::PasswordService; use crate::services::user_service::UserService; use async_trait::async_trait; use std::sync::Mutex; async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required for team_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!("team-test-{}", &unique[..12]), format!("team_test_{}@example.com", &unique[..12]), hash, false, ) .await .unwrap() .id } fn fresh_slug() -> TeamSlug { let raw = format!("t-{}", &Uuid::new_v4().simple().to_string()[..10]); TeamSlug::parse(&raw).unwrap() } #[derive(Default, Clone)] struct RecordingHooks(Arc<Mutex<Vec<String>>>); #[async_trait] impl RustwardenHooks for RecordingHooks { async fn on_team_created( &self, _db: &DatabaseConnection, team_id: Uuid, owner_id: Uuid, ) -> anyhow::Result<()> { self.0 .lock() .unwrap() .push(format!("created:{team_id}:{owner_id}")); Ok(()) } async fn on_team_deleted( &self, _db: &DatabaseConnection, team_id: Uuid, ) -> anyhow::Result<()> { self.0.lock().unwrap().push(format!("deleted:{team_id}")); Ok(()) } async fn on_team_member_added( &self, _db: &DatabaseConnection, team_id: Uuid, user_id: Uuid, ) -> anyhow::Result<()> { self.0 .lock() .unwrap() .push(format!("added:{team_id}:{user_id}")); Ok(()) } async fn on_team_member_removed( &self, _db: &DatabaseConnection, team_id: Uuid, user_id: Uuid, ) -> anyhow::Result<()> { self.0 .lock() .unwrap() .push(format!("removed:{team_id}:{user_id}")); Ok(()) } async fn on_audit_event( &self, _db: &DatabaseConnection, _event: AuditEvent<'_>, ) -> anyhow::Result<()> { Ok(()) } } #[tokio::test] async fn create_team_inserts_owner_as_first_member() { let db = test_db().await; let owner = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None) .await .unwrap(); let members = TeamService::list_members(&db, team.id).await.unwrap(); assert_eq!(members.len(), 1); assert_eq!(members[0].user_id, Some(owner)); assert_eq!(members[0].role, "owner"); } #[tokio::test] async fn create_rejects_empty_name() { let db = test_db().await; let owner = fresh_user(&db).await; let err = TeamService::create(&db, owner, fresh_slug(), " ", None) .await .unwrap_err(); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn add_member_is_idempotent_and_updates_role() { let db = test_db().await; let owner = fresh_user(&db).await; let alice = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "T", None) .await .unwrap(); TeamService::add_member(&db, team.id, alice, TeamRole::Member, None) .await .unwrap(); TeamService::add_member(&db, team.id, alice, TeamRole::Admin, None) .await .unwrap(); let members = TeamService::list_members(&db, team.id).await.unwrap(); let alice_row = members.iter().find(|m| m.user_id == Some(alice)).unwrap(); assert_eq!(alice_row.role, "admin"); // No duplicate. assert_eq!( members.iter().filter(|m| m.user_id == Some(alice)).count(), 1, "add_member must not create duplicates" ); } #[tokio::test] async fn remove_member_refuses_to_remove_owner() { let db = test_db().await; let owner = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "T", None) .await .unwrap(); let err = TeamService::remove_member(&db, team.id, owner, None) .await .unwrap_err(); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn remove_member_drops_the_edge() { 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(); TeamService::remove_member(&db, team.id, bob, None) .await .unwrap(); let members = TeamService::list_members(&db, team.id).await.unwrap(); assert!(members.iter().all(|m| m.user_id != Some(bob))); } #[tokio::test] async fn teams_of_user_returns_membership_teams() { let db = test_db().await; let owner = fresh_user(&db).await; let bob = fresh_user(&db).await; let t1 = TeamService::create(&db, owner, fresh_slug(), "A", None) .await .unwrap(); let t2 = TeamService::create(&db, owner, fresh_slug(), "B", None) .await .unwrap(); TeamService::add_member(&db, t1.id, bob, TeamRole::Member, None) .await .unwrap(); let bobs_teams = TeamService::teams_of_user(&db, bob).await.unwrap(); assert_eq!(bobs_teams.len(), 1); assert_eq!(bobs_teams[0].id, t1.id); let owners_teams = TeamService::teams_of_user(&db, owner).await.unwrap(); let ids: Vec<Uuid> = owners_teams.iter().map(|t| t.id).collect(); assert!(ids.contains(&t1.id)); assert!(ids.contains(&t2.id)); } #[tokio::test] async fn delete_cascades_members() { 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(); TeamService::delete(&db, team.id, None).await.unwrap(); assert!( TeamService::find_by_id(&db, team.id) .await .unwrap() .is_none() ); let remaining = team_member::Entity::find() .filter(team_member::Column::TeamId.eq(team.id)) .all(&db) .await .unwrap(); assert!(remaining.is_empty(), "cascade must clear team_members"); } #[tokio::test] async fn hooks_fire_for_create_add_remove_delete() { let db = test_db().await; let owner = fresh_user(&db).await; let bob = fresh_user(&db).await; let recording = RecordingHooks::default(); let hooks: Arc<dyn RustwardenHooks> = Arc::new(recording.clone()); let team = TeamService::create(&db, owner, fresh_slug(), "Observed", Some(&hooks)) .await .unwrap(); TeamService::add_member(&db, team.id, bob, TeamRole::Member, Some(&hooks)) .await .unwrap(); TeamService::remove_member(&db, team.id, bob, Some(&hooks)) .await .unwrap(); TeamService::delete(&db, team.id, Some(&hooks)) .await .unwrap(); let events = recording.0.lock().unwrap().clone(); assert_eq!(events.len(), 4, "4 hook events expected, got: {events:?}"); assert!(events[0].starts_with("created:")); assert!(events[1].starts_with("added:")); assert!(events[2].starts_with("removed:")); assert!(events[3].starts_with("deleted:")); } // ─── RFC 0009 — add_member_identity ───────────────────────────── #[tokio::test] async fn rfc0009_add_member_identity_local_writes_user_id_column() { use crate::identity::Identity; let db = test_db().await; let owner = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "rfc9-local", None) .await .unwrap(); let bob = fresh_user(&db).await; let row = TeamService::add_member_identity( &db, team.id, Identity::Local(bob), TeamRole::Member, None, ) .await .unwrap(); assert_eq!(row.user_id, Some(bob)); assert!(row.remote_identity_id.is_none()); } #[tokio::test] async fn rfc0009_add_member_identity_remote_writes_remote_identity_id_column() { use crate::identity::Identity; let db = test_db().await; let owner = fresh_user(&db).await; let team = TeamService::create(&db, owner, fresh_slug(), "rfc9-remote", None) .await .unwrap(); // The `remote_identities` row needs to exist for any future // cross-table join, but the partial unique index alone is what // we exercise here. We pick a fresh UUID; real RFC 0010 flow // creates the row before adding the membership. let remote_id = Uuid::new_v4(); let row = TeamService::add_member_identity( &db, team.id, Identity::Remote(remote_id), TeamRole::Member, None, ) .await .unwrap(); assert_eq!(row.remote_identity_id, Some(remote_id)); assert_eq!(row.user_id, None); } }
GitRust