Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/routes/invitations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ pub(crate) async fn accept_pending_invitation(
user_email: Option<String>,
via: &str,
) -> Result<(), StatusCode> {
let inviter = invitation.invited_by.ok_or(StatusCode::GONE)?;
crate::routes::teams::require_inviter_still_entitled(pool, invitation.team_id, inviter, &invitation.role, &invitation.role_ids)
.await
.map_err(|status| {
if status.is_server_error() {
return status;
}
warn!(invitation_id = %invitation.id, inviter = %inviter, ?status, "Invitation outlived its sender's authority");
StatusCode::GONE
})?;

let mut tx = pool.begin().await.map_err(|e| {
error!(error = %e, "Failed to begin transaction for invitation acceptance");
StatusCode::INTERNAL_SERVER_ERROR
Expand Down Expand Up @@ -742,3 +753,143 @@ mod admit_tests {
assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner));
}
}

#[cfg(test)]
mod inviter_recheck_tests {
//! Acceptance re-checks that the sender may still grant what the invitation carries.
use super::*;
use crate::auth::AuthUser;
use crate::sync_notifier::SyncNotifier;
use crate::test_support::{
add_member, grant_builtin_role, seed_invitation_from, seed_team_with_roles, seed_user, test_user_email,
};
use axum::extract::{Path, State};
use axum::Extension;

struct Fixture {
pool: PgPool,
team: Uuid,
inviter: Uuid,
recipient: Uuid,
invitation: Uuid,
}

async fn invited_as(role: &str, inviter_role: &str) -> Option<Fixture> {
let pool = crate::test_support::test_pool().await?;
let primary = seed_user(&pool).await;
let team = seed_team_with_roles(&pool, primary).await;
let inviter = seed_user(&pool).await;
add_member(&pool, team, inviter).await;
grant_builtin_role(&pool, team, inviter, inviter_role).await;
let recipient = seed_user(&pool).await;
let (invitation, _) =
seed_invitation_from(&pool, team, inviter, &test_user_email(recipient), role, Some(recipient)).await;
Some(Fixture { pool, team, inviter, recipient, invitation })
}

async fn accept(f: &Fixture) -> Result<StatusCode, StatusCode> {
accept_my_pending_invitation(
State(f.pool.clone()),
Extension(AuthUser(f.recipient)),
Extension(SyncNotifier::new()),
Path(f.invitation),
)
.await
}

async fn holds(f: &Fixture, user: Uuid, role: &str) -> bool {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM team_member_roles tmr JOIN team_roles tr ON tr.id = tmr.role_id
WHERE tmr.team_id = $1 AND tmr.user_id = $2 AND tr.is_builtin AND tr.name = $3)",
)
.bind(f.team)
.bind(user)
.bind(role)
.fetch_one(&f.pool)
.await
.unwrap()
}

async fn is_member(f: &Fixture) -> bool {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)")
.bind(f.team)
.bind(f.recipient)
.fetch_one(&f.pool)
.await
.unwrap()
}

async fn strip_roles(f: &Fixture) {
sqlx::query("DELETE FROM team_member_roles WHERE team_id = $1 AND user_id = $2")
.bind(f.team)
.bind(f.inviter)
.execute(&f.pool)
.await
.unwrap();
}

#[tokio::test]
async fn an_owner_invite_from_a_current_owner_makes_an_owner() {
let Some(f) = invited_as("owner", "owner").await else { return };
assert_eq!(accept(&f).await, Ok(StatusCode::NO_CONTENT));
assert!(holds(&f, f.recipient, "owner").await);
}

#[tokio::test]
async fn an_owner_invite_dies_when_its_sender_stops_being_owner() {
let Some(f) = invited_as("owner", "owner").await else { return };
strip_roles(&f).await;
grant_builtin_role(&f.pool, f.team, f.inviter, "manager").await;

assert_eq!(accept(&f).await, Err(StatusCode::GONE));
assert!(!is_member(&f).await);
}

#[tokio::test]
async fn an_invite_dies_when_its_sender_leaves_the_team() {
let Some(f) = invited_as("member", "manager").await else { return };
strip_roles(&f).await;
sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
.bind(f.team)
.bind(f.inviter)
.execute(&f.pool)
.await
.unwrap();

assert_eq!(accept(&f).await, Err(StatusCode::GONE));
assert!(!is_member(&f).await);
}

#[tokio::test]
async fn an_invite_dies_when_its_sender_deleted_their_account() {
let Some(f) = invited_as("member", "manager").await else { return };
sqlx::query("UPDATE pending_invitations SET invited_by = NULL WHERE id = $1")
.bind(f.invitation)
.execute(&f.pool)
.await
.unwrap();

assert_eq!(accept(&f).await, Err(StatusCode::GONE));
assert!(!is_member(&f).await);
}

#[tokio::test]
async fn a_resend_by_a_current_owner_revives_it() {
let Some(f) = invited_as("owner", "owner").await else { return };
strip_roles(&f).await;
let primary: Uuid = sqlx::query_scalar("SELECT owner_id FROM teams WHERE id = $1")
.bind(f.team)
.fetch_one(&f.pool)
.await
.unwrap();
sqlx::query("UPDATE pending_invitations SET invited_by = $2 WHERE id = $1")
.bind(f.invitation)
.bind(primary)
.execute(&f.pool)
.await
.unwrap();

assert_eq!(accept(&f).await, Ok(StatusCode::NO_CONTENT));
assert!(holds(&f, f.recipient, "owner").await);
}
}
48 changes: 32 additions & 16 deletions src/routes/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,14 +447,7 @@ pub async fn add_member(
axum::extract::Path(team_id): axum::extract::Path<Uuid>,
Json(body): Json<AddMemberRequest>,
) -> Result<(StatusCode, Json<InviteMemberResponse>), StatusCode> {
let can_invite = crate::permissions::has_team_permission(
&pool, team_id, auth.0, crate::permissions::PERM_INVITE_MEMBERS,
)
.await?;
if !can_invite {
warn!(team_id = %team_id, user_id = %auth.0, "Insufficient permission to invite members");
return Err(StatusCode::FORBIDDEN);
}
require_can_invite(&pool, team_id, auth.0).await?;

let member_name = crate::routes::member_names::invite_member_name(&pool, team_id, auth.0, body.name.as_deref()).await?;

Expand Down Expand Up @@ -1589,6 +1582,36 @@ impl InvitedRoles {
}
}

async fn require_can_invite(pool: &PgPool, team_id: Uuid, actor: Uuid) -> Result<(), StatusCode> {
if crate::permissions::has_team_permission(pool, team_id, actor, crate::permissions::PERM_INVITE_MEMBERS).await? {
return Ok(());
}
warn!(team_id = %team_id, user_id = %actor, "Insufficient permission to invite members");
Err(StatusCode::FORBIDDEN)
}

/// The send-time checks, run again at acceptance against whoever sent the invitation.
/// Roles deleted since are left out, as `admit_member` skips them.
pub(crate) async fn require_inviter_still_entitled(
pool: &PgPool,
team_id: Uuid,
inviter: Uuid,
role: &str,
role_ids: &[Uuid],
) -> Result<(), StatusCode> {
require_can_invite(pool, team_id, inviter).await?;
let live = sqlx::query_scalar::<_, Uuid>("SELECT id FROM team_roles WHERE team_id = $1 AND id = ANY($2)")
.bind(team_id)
.bind(role_ids)
.fetch_all(pool)
.await
.map_err(|e| {
error!(error = %e, "Failed to read invited roles");
StatusCode::INTERNAL_SERVER_ERROR
})?;
invited_roles(pool, team_id, inviter, Some(role), Some(&live)).await.map(drop)
}

async fn invited_roles(
pool: &PgPool,
team_id: Uuid,
Expand Down Expand Up @@ -1690,14 +1713,7 @@ pub async fn invite_member(
axum::extract::Path(team_id): axum::extract::Path<Uuid>,
Json(body): Json<InviteMemberRequest>,
) -> Result<Json<InviteMemberResponse>, StatusCode> {
let can_invite = crate::permissions::has_team_permission(
&pool, team_id, auth.0, crate::permissions::PERM_INVITE_MEMBERS,
)
.await?;
if !can_invite {
warn!(team_id = %team_id, user_id = %auth.0, "Insufficient permission to invite members");
return Err(StatusCode::FORBIDDEN);
}
require_can_invite(&pool, team_id, auth.0).await?;

let member_name = crate::routes::member_names::invite_member_name(&pool, team_id, auth.0, body.name.as_deref()).await?;

Expand Down
42 changes: 38 additions & 4 deletions src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,27 +447,61 @@ pub fn test_user_email(id: Uuid) -> String {
}

/// Insert a pending invitation and return `(invitation_id, token)`. `user_id` is the
/// in-app recipient binding (NULL for link-only invites). The token is a fresh UUID
/// hex string so tests can look the invitation up by token.
/// in-app recipient binding (NULL for link-only invites). The team owner sends it and
/// is made a member holding the Owner role, since acceptance re-checks the sender.
pub async fn seed_invitation(
pool: &PgPool,
team: Uuid,
email: &str,
role: &str,
user_id: Option<Uuid>,
) -> (Uuid, String) {
let owner: Uuid = sqlx::query_scalar("SELECT owner_id FROM teams WHERE id = $1")
.bind(team)
.fetch_one(pool)
.await
.expect("team owner");
sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(team)
.bind(owner)
.execute(pool)
.await
.expect("owner membership");
seed_builtin_roles(pool, team).await;
sqlx::query(
"INSERT INTO team_member_roles (team_id, user_id, role_id)
SELECT $1, $2, id FROM team_roles WHERE team_id = $1 AND is_builtin AND name = 'owner'
ON CONFLICT DO NOTHING",
)
.bind(team)
.bind(owner)
.execute(pool)
.await
.expect("owner role");
seed_invitation_from(pool, team, owner, email, role, user_id).await
}

pub async fn seed_invitation_from(
pool: &PgPool,
team: Uuid,
inviter: Uuid,
email: &str,
role: &str,
user_id: Option<Uuid>,
) -> (Uuid, String) {
let id = Uuid::new_v4();
let token = Uuid::new_v4().simple().to_string();
sqlx::query(
"INSERT INTO pending_invitations (id, team_id, email, role, token, user_id)
VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO pending_invitations (id, team_id, email, role, token, user_id, invited_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(id)
.bind(team)
.bind(email)
.bind(role)
.bind(&token)
.bind(user_id)
.bind(inviter)
.execute(pool)
.await
.expect("seed invitation");
Expand Down
Loading