fediverse_invite_service.rs
552 lignes · 20029 octets
//! Cross-instance team-membership invitations (RFC 0009). //! //! State machine `Pending → Accepted | Rejected | Revoked | Expired`. //! On `Accept`, the service resolves the remote identity and inserts //! a `team_members` row with `remote_identity_id` populated. The AP //! activities themselves are produced by the `build_*_payload` helpers //! in `fediverse_invite_payloads` and dispatched by //! the consumer through [`crate::services::fediverse_outbound_service::FediverseOutboundService`]. //! use chrono::{Duration as ChronoDuration, Utc}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use std::time::Duration; use uuid::Uuid; use crate::config::FediverseConfig; use crate::models::fediverse_pending_invite; use rustwarden_core::error::AppError; use rustwarden_core::models::{team, team_member}; use rustwarden_core::services::team_service::TeamRole; /// Lifecycle of a [`fediverse_pending_invite::Model`] row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InviteStatus { /// Awaiting IdP decision. Pending, /// IdP user clicked Accept; the membership row exists. Accepted, /// IdP user clicked Reject. Rejected, /// Inviter cancelled the invitation. Revoked, /// `expires_at` reached without decision. Expired, } impl InviteStatus { /// Wire form stored in the `status` column. pub fn as_str(self) -> &'static str { match self { Self::Pending => "pending", Self::Accepted => "accepted", Self::Rejected => "rejected", Self::Revoked => "revoked", Self::Expired => "expired", } } /// Best-effort parse — unknown values fall back to `Pending`. pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "accepted" => Self::Accepted, "rejected" => Self::Rejected, "revoked" => Self::Revoked, "expired" => Self::Expired, _ => Self::Pending, } } } /// Stateless service exposing the invite state machine. pub struct FediverseInviteService; impl FediverseInviteService { /// Persist a `pending` invitation. The caller is expected to /// enqueue the AP `Invite{TeamMembership}` activity to the /// remote inbox via `FediverseOutboundService::enqueue` using /// [`build_invite_payload`]. pub async fn invite_remote( db: &DatabaseConnection, team_id: Uuid, invited_handle: &str, role: TeamRole, invited_by_user_id: Uuid, ttl: Duration, ) -> Result<fediverse_pending_invite::Model, AppError> { let now = Utc::now(); let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) })?; let id = Uuid::new_v4(); let active = fediverse_pending_invite::ActiveModel { id: Set(id), activity_id: Set(format!("https://invite/{id}")), // tweak in handler with FediverseConfig if needed team_id: Set(team_id), invited_handle: Set(invited_handle.to_string()), invited_remote_identity_id: Set(None), role: Set(role.as_str().to_string()), invited_by_user_id: Set(invited_by_user_id), status: Set(InviteStatus::Pending.as_str().to_string()), created_at: Set(now), expires_at: Set(expires_at), decided_at: Set(None), }; Ok(active.insert(db).await?) } /// IdP-side accept: marks the invite `Accepted`, persists the /// resolved `remote_identity_id`, and inserts the `team_members` /// row with the remote sibling FK set. pub async fn accept( db: &DatabaseConnection, invite_id: Uuid, remote_identity_id: Uuid, ) -> Result<team_member::Model, AppError> { let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("Invite {invite_id} not found")))?; if invite.status != InviteStatus::Pending.as_str() { return Err(AppError::Validation(format!( "Cannot accept: invite is in state {}", invite.status ))); } let team_id = invite.team_id; let role = invite.role.clone(); let mut active: fediverse_pending_invite::ActiveModel = invite.into(); active.status = Set(InviteStatus::Accepted.as_str().to_string()); active.invited_remote_identity_id = Set(Some(remote_identity_id)); active.decided_at = Set(Some(Utc::now())); active.update(db).await?; let member = team_member::ActiveModel { id: Set(Uuid::new_v4()), team_id: Set(team_id), // RFC 0018: PK relaxed; remote rows leave `user_id` NULL. // The CHECK XOR guarantees exactly one principal column is // populated; the unique partial index on // (team_id, remote_identity_id) prevents duplicates. user_id: Set(None), role: Set(role), joined_at: Set(Utc::now()), remote_identity_id: Set(Some(remote_identity_id)), }; Ok(member.insert(db).await?) } /// IdP-side reject. pub async fn reject( db: &DatabaseConnection, invite_id: Uuid, ) -> Result<fediverse_pending_invite::Model, AppError> { Self::transition_terminal( db, invite_id, InviteStatus::Rejected, &[InviteStatus::Pending], ) .await } /// Inviter-side revoke. Allowed both before and after acceptance /// — when revoking an accepted invite, the membership row stays /// in place and is removed by the lifecycle signal handler in /// RFC 0010 (`Revoke{Membership}`). pub async fn revoke( db: &DatabaseConnection, invite_id: Uuid, ) -> Result<fediverse_pending_invite::Model, AppError> { Self::transition_terminal( db, invite_id, InviteStatus::Revoked, &[InviteStatus::Pending, InviteStatus::Accepted], ) .await } /// List currently-pending invites for a team. pub async fn list_pending_for_team( db: &DatabaseConnection, team_id: Uuid, ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { Ok(fediverse_pending_invite::Entity::find() .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) .filter(fediverse_pending_invite::Column::Status.eq(InviteStatus::Pending.as_str())) .all(db) .await?) } /// List currently-pending invites addressed to a specific handle /// (`@user@domain`). Used by the IdP-side endpoint /// `GET /api/v1/fediverse/invites`. In `debug_local_federation` /// mode both sides share the same DB; in production a real IdP /// keeps a mirror table populated by its own inbox handler. pub async fn list_pending_for_handle( db: &DatabaseConnection, handle: &str, ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { Ok(fediverse_pending_invite::Entity::find() .filter(fediverse_pending_invite::Column::InvitedHandle.eq(handle)) .filter(fediverse_pending_invite::Column::Status.eq(InviteStatus::Pending.as_str())) .all(db) .await?) } async fn transition_terminal( db: &DatabaseConnection, invite_id: Uuid, target: InviteStatus, from: &[InviteStatus], ) -> Result<fediverse_pending_invite::Model, AppError> { let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("Invite {invite_id} not found")))?; let current = InviteStatus::parse(&invite.status); if !from.contains(¤t) { return Err(AppError::Validation(format!( "Cannot transition: invite is in state {}", invite.status ))); } let mut active: fediverse_pending_invite::ActiveModel = invite.into(); active.status = Set(target.as_str().to_string()); active.decided_at = Set(Some(Utc::now())); Ok(active.update(db).await?) } } /// Build the AP `Invite{TeamMembership}` JSON-LD payload. pub fn build_invite_payload( config: &FediverseConfig, invite: &fediverse_pending_invite::Model, team: &team::Model, ) -> serde_json::Value { serde_json::json!({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://schema.rustwarden.org/team-membership/v1" ], "type": "Invite", "id": invite.activity_id, "actor": format!("{}", config.base_url()), "object": { "type": "TeamMembership", "team": { "id": format!("{}teams/{}", config.base_url(), team.id), "name": team.name, }, "role": invite.role, }, "target": invite.invited_handle, "expiresAt": invite.expires_at.to_rfc3339(), }) } /// Build the AP `Accept{Invite}` activity sent back from the IdP. pub fn build_accept_invite_payload( config: &FediverseConfig, invite_activity_id: &str, ) -> serde_json::Value { serde_json::json!({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Accept", "actor": format!("{}", config.base_url()), "object": invite_activity_id, }) } /// Build the AP `Reject{Invite}` activity sent back from the IdP. pub fn build_reject_invite_payload( config: &FediverseConfig, invite_activity_id: &str, ) -> serde_json::Value { serde_json::json!({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Reject", "actor": format!("{}", config.base_url()), "object": invite_activity_id, }) } /// Build the AP `Revoke{Membership}` activity for either side. pub fn build_revoke_membership_payload( config: &FediverseConfig, membership_id: Uuid, team_id: Uuid, ) -> serde_json::Value { serde_json::json!({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Revoke", "actor": format!("{}", config.base_url()), "object": { "type": "TeamMembership", "id": membership_id, "team": format!("{}teams/{}", config.base_url(), team_id), }, }) } #[cfg(test)] mod tests { use super::*; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn fresh_pending(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model { let now = Utc::now(); fediverse_pending_invite::Model { id, activity_id: format!("https://b.tld/invites/{id}"), team_id, invited_handle: "alice@a.tld".into(), invited_remote_identity_id: None, role: "member".into(), invited_by_user_id: Uuid::new_v4(), status: "pending".into(), created_at: now, expires_at: now + ChronoDuration::days(7), decided_at: None, } } fn test_fediverse_config() -> FediverseConfig { FediverseConfig::for_tests("test.local") } fn test_team(id: Uuid) -> team::Model { team::Model { id, slug: "engineering".into(), name: "Engineering".into(), owner_id: Uuid::new_v4(), created_at: Utc::now(), } } #[test] fn invite_status_round_trips_through_string() { for s in [ InviteStatus::Pending, InviteStatus::Accepted, InviteStatus::Rejected, InviteStatus::Revoked, InviteStatus::Expired, ] { assert_eq!(InviteStatus::parse(s.as_str()), s); } assert_eq!(InviteStatus::parse("garbage"), InviteStatus::Pending); } #[test] fn build_invite_payload_includes_team_and_handle() { let config = test_fediverse_config(); let team_id = Uuid::new_v4(); let invite = fresh_pending(Uuid::new_v4(), team_id); let team = test_team(team_id); let payload = build_invite_payload(&config, &invite, &team); assert_eq!(payload["type"], serde_json::json!("Invite")); assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); assert_eq!(payload["object"]["role"], serde_json::json!("member")); assert_eq!( payload["object"]["team"]["name"], serde_json::json!("Engineering") ); } #[test] fn build_accept_invite_payload_references_invite_id() { let config = test_fediverse_config(); let payload = build_accept_invite_payload(&config, "https://b.tld/invites/x"); assert_eq!(payload["type"], serde_json::json!("Accept")); assert_eq!( payload["object"], serde_json::json!("https://b.tld/invites/x") ); } #[tokio::test] async fn invite_remote_persists_pending_row() { let row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .into_connection(); let inserted = FediverseInviteService::invite_remote( &db, row.team_id, "alice@a.tld", TeamRole::Member, row.invited_by_user_id, Duration::from_secs(7 * 86400), ) .await .unwrap(); assert_eq!(inserted.status, "pending"); } #[tokio::test] async fn invite_remote_uses_provided_role() { let mut row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); row.role = "admin".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .into_connection(); let inserted = FediverseInviteService::invite_remote( &db, row.team_id, "alice@a.tld", TeamRole::Admin, row.invited_by_user_id, Duration::from_secs(86400), ) .await .unwrap(); assert_eq!(inserted.role, "admin"); } #[tokio::test] async fn accept_marks_invite_accepted_and_inserts_team_member() { let invite_id = Uuid::new_v4(); let team_id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let invite = fresh_pending(invite_id, team_id); let mut after_invite = invite.clone(); after_invite.status = "accepted".into(); after_invite.invited_remote_identity_id = Some(remote_id); after_invite.decided_at = Some(Utc::now()); let now = Utc::now(); let new_member = team_member::Model { id: Uuid::new_v4(), team_id, user_id: None, role: "member".into(), joined_at: now, remote_identity_id: Some(remote_id), }; let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) // 1. SELECT invite .append_query_results([vec![invite]]) // 2. SELECT issued by .update() on invite .append_query_results([vec![after_invite]]) // 3. INSERT into team_members → returned row .append_query_results([vec![new_member.clone()]]) .append_exec_results([ MockExecResult { last_insert_id: 0, rows_affected: 1, }, MockExecResult { last_insert_id: 0, rows_affected: 1, }, ]) .into_connection(); let member = FediverseInviteService::accept(&db, invite_id, remote_id) .await .unwrap(); assert_eq!(member.remote_identity_id, Some(remote_id)); assert_eq!(member.team_id, team_id); } #[tokio::test] async fn accept_refuses_non_pending_invite() { let invite_id = Uuid::new_v4(); let mut invite = fresh_pending(invite_id, Uuid::new_v4()); invite.status = "rejected".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .into_connection(); let err = FediverseInviteService::accept(&db, invite_id, Uuid::new_v4()) .await .expect_err("must refuse non-pending"); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn reject_marks_rejected() { let invite_id = Uuid::new_v4(); let invite = fresh_pending(invite_id, Uuid::new_v4()); let mut after = invite.clone(); after.status = "rejected".into(); after.decided_at = Some(Utc::now()); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let updated = FediverseInviteService::reject(&db, invite_id) .await .unwrap(); assert_eq!(updated.status, "rejected"); } #[tokio::test] async fn revoke_marks_revoked_when_pending() { let invite_id = Uuid::new_v4(); let invite = fresh_pending(invite_id, Uuid::new_v4()); let mut after = invite.clone(); after.status = "revoked".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let updated = FediverseInviteService::revoke(&db, invite_id) .await .unwrap(); assert_eq!(updated.status, "revoked"); } #[tokio::test] async fn revoke_marks_revoked_when_already_accepted() { let invite_id = Uuid::new_v4(); let mut invite = fresh_pending(invite_id, Uuid::new_v4()); invite.status = "accepted".into(); invite.invited_remote_identity_id = Some(Uuid::new_v4()); let mut after = invite.clone(); after.status = "revoked".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![invite]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let updated = FediverseInviteService::revoke(&db, invite_id) .await .unwrap(); assert_eq!(updated.status, "revoked"); } #[tokio::test] async fn list_pending_for_team_returns_only_pending() { let team_id = Uuid::new_v4(); let p1 = fresh_pending(Uuid::new_v4(), team_id); let p2 = fresh_pending(Uuid::new_v4(), team_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![p1.clone(), p2.clone()]]) .into_connection(); let result = FediverseInviteService::list_pending_for_team(&db, team_id) .await .unwrap(); assert_eq!(result.len(), 2); assert!(result.iter().all(|r| r.status == "pending")); } }
GitRust