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