invite_routes.rs
588 lignes · 20935 octets
//! HTTP routes for cross-instance team invitations (RFC 0014). //! //! Six thin handlers around //! [`crate::services::fediverse_invite_service::FediverseInviteService`]. //! //! Inviter-side (`b.tld`) : //! - `POST /api/v1/teams/{team_id}/members/invite-fediverse` //! - `GET /api/v1/teams/{team_id}/invites` //! - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` //! //! IdP-side (`a.tld`) : //! - `GET /api/v1/fediverse/invites?handle=@user@domain` //! - `POST /api/v1/fediverse/invites/{invite_id}/accept` //! - `POST /api/v1/fediverse/invites/{invite_id}/reject` //! use std::time::Duration; use axum::{ Extension, Json, Router, extract::{Path, Query}, http::StatusCode, routing::{get, post}, }; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::error::FediverseError; use crate::services::fediverse_invite_service::FediverseInviteService; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::error::AppError; use rustwarden_core::services::team_service::TeamRole; /// Per-router state injected into the invite handlers. #[derive(Clone)] pub struct InviteRoutesState { /// TTL applied to every freshly-issued invite. Defaults to 7 days /// when the consumer does not override (set by /// [`crate::routes::router_with_auth`]). pub default_ttl: Duration, } impl std::fmt::Debug for InviteRoutesState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InviteRoutesState") .field("default_ttl", &self.default_ttl) .finish_non_exhaustive() } } // ─── Bodies / responses ────────────────────────────────────────────── /// `POST /api/v1/teams/{team_id}/members/invite-fediverse` body. #[derive(Debug, Deserialize)] pub struct InviteBody { /// `@user@domain` to invite. pub handle: String, /// `"member"` or `"admin"`. pub role: String, } /// `POST /api/v1/teams/{team_id}/members/invite-fediverse` 201 response. #[derive(Debug, Serialize)] pub struct InviteResponse { /// Stable invite identifier. pub invite_id: Uuid, /// Canonical AP activity id (used by `b.tld` when sending the /// `Invite{TeamMembership}` activity to the remote inbox). pub activity_id: String, /// RFC3339 expiry timestamp. pub expires_at: String, } /// One row in a `GET /…/invites` listing. #[derive(Debug, Serialize)] pub struct InviteRow { /// Stable invite identifier. pub id: Uuid, /// `@user@domain` invited. pub handle: String, /// `"member"` or `"admin"`. pub role: String, /// Lifecycle state. pub status: String, /// RFC3339 creation timestamp. pub created_at: String, /// RFC3339 expiry timestamp. pub expires_at: String, } /// Query params for the IdP listing endpoint. #[derive(Debug, Deserialize)] pub struct IdpListQuery { /// Handle whose pending invites should be returned. pub handle: String, } // ─── Handlers ──────────────────────────────────────────────────────── async fn invite( Extension(state): Extension<InviteRoutesState>, Extension(db): Extension<DatabaseConnection>, Extension(claims): Extension<Claims>, Path(team_id): Path<Uuid>, Json(body): Json<InviteBody>, ) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> { let role = TeamRole::parse(&body.role) .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?; // ANSSI F-NET-3 — Claims are now mandatory (Axum returns 401 when // the JWT middleware did not inject them). The inviter is always // the authenticated admin's user id. let inviter = claims.sub; tracing::info!( admin_id = %inviter, team_id = %team_id, handle = %body.handle, role = %body.role, "fediverse invite issued", ); let model = FediverseInviteService::invite_remote( &db, team_id, &body.handle, role, inviter, state.default_ttl, ) .await?; Ok(( StatusCode::CREATED, Json(InviteResponse { invite_id: model.id, activity_id: model.activity_id, expires_at: model.expires_at.to_rfc3339(), }), )) } async fn list_for_team( Extension(db): Extension<DatabaseConnection>, Extension(_claims): Extension<Claims>, Path(team_id): Path<Uuid>, ) -> Result<Json<Vec<InviteRow>>, FediverseError> { // ANSSI F-NET-3 — Claims are mandatory; future RFC will add RBAC // membership check before listing. let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?; Ok(Json(rows.into_iter().map(into_row).collect())) } async fn revoke( Extension(db): Extension<DatabaseConnection>, Extension(claims): Extension<Claims>, Path((_team_id, invite_id)): Path<(Uuid, Uuid)>, ) -> Result<Json<InviteRow>, FediverseError> { // ANSSI F-NET-3 / FUI-AUTH-1 — audit-trail destructive action. tracing::info!( admin_id = %claims.sub, invite_id = %invite_id, "fediverse invite revoked", ); let model = FediverseInviteService::revoke(&db, invite_id).await?; Ok(Json(into_row(model))) } async fn idp_list( Extension(db): Extension<DatabaseConnection>, Extension(_claims): Extension<Claims>, Query(query): Query<IdpListQuery>, ) -> Result<Json<Vec<InviteRow>>, FediverseError> { // ANSSI F-NET-3 — IdP-side listing is now authenticated; future RFC // will narrow `handle` to the caller's own bound identity. let rows = FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?; Ok(Json(rows.into_iter().map(into_row).collect())) } #[derive(Debug, Deserialize)] struct AcceptBody { /// FK of the resolved `remote_identities.id` that the IdP user maps /// to. In `debug_local_federation` mode the IdP UI / API picks this /// from the same DB; in production a follow-up RFC will let the /// IdP-side mirror the activity and resolve the row automatically. remote_identity_id: Uuid, } async fn idp_accept( Extension(db): Extension<DatabaseConnection>, Extension(claims): Extension<Claims>, Path(invite_id): Path<Uuid>, Json(body): Json<AcceptBody>, ) -> Result<StatusCode, FediverseError> { // ANSSI F-NET-3 — authenticated user accepts the invite under // their own JWT identity (audit trail). tracing::info!( user_id = %claims.sub, invite_id = %invite_id, remote_identity_id = %body.remote_identity_id, "fediverse invite accepted (idp-side)", ); FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?; Ok(StatusCode::OK) } async fn idp_reject( Extension(db): Extension<DatabaseConnection>, Extension(claims): Extension<Claims>, Path(invite_id): Path<Uuid>, ) -> Result<StatusCode, FediverseError> { // ANSSI F-NET-3 — authenticated user rejects the invite. tracing::info!( user_id = %claims.sub, invite_id = %invite_id, "fediverse invite rejected (idp-side)", ); FediverseInviteService::reject(&db, invite_id).await?; Ok(StatusCode::OK) } fn into_row(m: crate::models::fediverse_pending_invite::Model) -> InviteRow { InviteRow { id: m.id, handle: m.invited_handle, role: m.role, status: m.status, created_at: m.created_at.to_rfc3339(), expires_at: m.expires_at.to_rfc3339(), } } /// Build the invite-routes router. pub fn router(state: InviteRoutesState) -> Router<DatabaseConnection> { Router::new() .route( "/api/v1/teams/{team_id}/members/invite-fediverse", post(invite), ) .route("/api/v1/teams/{team_id}/invites", get(list_for_team)) .route( "/api/v1/teams/{team_id}/invites/{invite_id}/revoke", post(revoke), ) .route("/api/v1/fediverse/invites", get(idp_list)) .route( "/api/v1/fediverse/invites/{invite_id}/accept", post(idp_accept), ) .route( "/api/v1/fediverse/invites/{invite_id}/reject", post(idp_reject), ) .layer(Extension(state)) } // `From<AppError>` for `FediverseError` is provided via the `App` // variant; nothing extra to wire here. #[allow(dead_code)] fn _ensure_app_error_conversion(err: AppError) -> FediverseError { err.into() } #[cfg(test)] mod tests { use super::*; use crate::models::fediverse_pending_invite; use axum::body::Body; use axum::http::Request; use chrono::{Duration as ChronoDuration, Utc}; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; use tower::ServiceExt; fn fresh_invite(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_claims() -> rustwarden_core::auth::jwt::Claims { rustwarden_core::auth::jwt::Claims { sub: Uuid::new_v4(), jti: "test-jti".into(), aud: "test".into(), username: "admin".into(), roles: vec!["admin".into()], permissions: vec![], exp: i64::MAX / 2, iat: 0, iss: "rustwarden-test".into(), identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local, remote_id: None, } } fn build_router(db: DatabaseConnection) -> Router { let state = InviteRoutesState { default_ttl: Duration::from_secs(7 * 86400), }; router(state) .layer(Extension(db)) .layer(Extension(test_claims())) .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) } fn req_post(uri: &str, body: &str) -> Request<Body> { Request::builder() .uri(uri) .method("POST") .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap() } fn req_get(uri: &str) -> Request<Body> { Request::builder() .uri(uri) .method("GET") .body(Body::empty()) .unwrap() } // ─── Inviter side ─────────────────────────────────────────────── #[tokio::test] async fn invite_returns_201_with_invite_id() { let team_id = Uuid::new_v4(); let row = fresh_invite(Uuid::new_v4(), team_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let body = serde_json::json!({"handle":"alice@a.tld","role":"member"}).to_string(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), &body, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } #[tokio::test] async fn invite_returns_400_for_invalid_role() { let team_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let body = serde_json::json!({"handle":"alice@a.tld","role":"superuser"}).to_string(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), &body, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn list_pending_returns_200_with_array() { let team_id = Uuid::new_v4(); let r1 = fresh_invite(Uuid::new_v4(), team_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![r1]]) .into_connection(); let resp = build_router(db) .oneshot(req_get(&format!("/api/v1/teams/{team_id}/invites"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn revoke_returns_200_for_pending_invite() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let row = fresh_invite(invite_id, team_id); let mut after = row.clone(); after.status = "revoked".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), "", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn revoke_returns_404_for_unknown_invite() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) .into_connection(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), "", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // ─── IdP side ────────────────────────────────────────────────── #[tokio::test] async fn idp_list_returns_200_with_invites_for_handle() { let team_id = Uuid::new_v4(); let r1 = fresh_invite(Uuid::new_v4(), team_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![r1]]) .into_connection(); let resp = build_router(db) .oneshot(req_get("/api/v1/fediverse/invites?handle=alice@a.tld")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn idp_list_returns_empty_array_when_no_invites() { let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) .into_connection(); let resp = build_router(db) .oneshot(req_get( "/api/v1/fediverse/invites?handle=ghost@nowhere.tld", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn idp_accept_returns_200_for_pending() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let row = fresh_invite(invite_id, team_id); let mut after_invite = row.clone(); after_invite.status = "accepted".into(); after_invite.invited_remote_identity_id = Some(remote_id); let now = Utc::now(); let new_member = rustwarden_core::models::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) .append_query_results([vec![row]]) .append_query_results([vec![after_invite]]) .append_query_results([vec![new_member]]) .append_exec_results([ MockExecResult { last_insert_id: 0, rows_affected: 1, }, MockExecResult { last_insert_id: 0, rows_affected: 1, }, ]) .into_connection(); let body = serde_json::json!({"remote_identity_id":remote_id}).to_string(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/fediverse/invites/{invite_id}/accept"), &body, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn idp_accept_returns_400_for_already_accepted() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let mut row = fresh_invite(invite_id, team_id); row.status = "accepted".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let body = serde_json::json!({"remote_identity_id":Uuid::new_v4()}).to_string(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/fediverse/invites/{invite_id}/accept"), &body, )) .await .unwrap(); assert!( resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, "expected 400/422, got {}", resp.status() ); } #[tokio::test] async fn idp_reject_returns_200_for_pending() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let row = fresh_invite(invite_id, team_id); let mut after = row.clone(); after.status = "rejected".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/fediverse/invites/{invite_id}/reject"), "", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn idp_reject_returns_400_for_already_decided() { let team_id = Uuid::new_v4(); let invite_id = Uuid::new_v4(); let mut row = fresh_invite(invite_id, team_id); row.status = "rejected".into(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/fediverse/invites/{invite_id}/reject"), "", )) .await .unwrap(); assert!( resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, ); } #[tokio::test] async fn invite_uses_default_ttl_from_state() { // The `default_ttl` flows through the service; the mock // doesn't reflect it back to the response, so we only // assert status 201 and that the row was inserted. let team_id = Uuid::new_v4(); let row = fresh_invite(Uuid::new_v4(), team_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let body = serde_json::json!({"handle":"alice@a.tld","role":"admin"}).to_string(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), &body, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } }
GitRust