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
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod routes;
mod self_host;
mod single_instance;
mod session_grants;
mod team_authority;
mod team_join_grants;
mod team_plan;
mod vault_key_exposure;
Expand Down Expand Up @@ -443,6 +444,7 @@ async fn main() {
.layer(middleware::from_fn(features::require_team_invites)),
)
.route("/v1/teams/:team_id", delete(routes::teams::delete_team).patch(routes::teams::rename_team))
.route("/v1/teams/:team_id/billing-owner", put(routes::team_ownership::transfer_billing_owner))
.route(
"/v1/teams/:team_id/lock-policy",
put(routes::team_lock_policy::set_lock_policy).delete(routes::team_lock_policy::clear_lock_policy),
Expand Down
16 changes: 8 additions & 8 deletions src/routes/member_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ mod db_tests {
use super::*;
use crate::permissions::{PERM_INVITE_MEMBERS, PERM_MANAGE_MEMBERS};
use crate::test_pool_or_skip;
use crate::test_support::{add_member, member_with_role, seed_team, seed_user};
use crate::test_support::{add_member, inviter_with, member_with_role, seed_team, seed_user};

async fn wait_for_audit_rows(pool: &PgPool, team: Uuid, action: &str, expected: i64) {
for _ in 0..100 {
Expand Down Expand Up @@ -284,7 +284,7 @@ mod db_tests {
let pool = test_pool_or_skip!();
let owner = seed_user(&pool).await;
let team = seed_team(&pool, owner).await;
let inviter = member_with_role(&pool, team, PERM_INVITE_MEMBERS).await;
let inviter = inviter_with(&pool, team, PERM_INVITE_MEMBERS).await;
let plain = member_with_role(&pool, team, 0).await;
let target = seed_user(&pool).await;
add_member(&pool, team, target).await;
Expand Down Expand Up @@ -368,7 +368,7 @@ mod db_tests {
async fn invite_with_name_needs_manage_members() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let inviter = member_with_role(&pool, team, PERM_INVITE_MEMBERS).await;
let inviter = inviter_with(&pool, team, PERM_INVITE_MEMBERS).await;
let email = format!("{}@corp.test", Uuid::new_v4());

assert_eq!(invite_email(&pool, inviter, team, &email, Some("Jan")).await, Err(StatusCode::FORBIDDEN));
Expand All @@ -380,7 +380,7 @@ mod db_tests {
async fn invite_stores_name_and_reinvite_without_name_keeps_it() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let admin = member_with_role(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let admin = inviter_with(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let email = format!("{}@corp.test", Uuid::new_v4());

invite_email(&pool, admin, team, &email, Some("Jan Novák")).await.unwrap();
Expand All @@ -395,7 +395,7 @@ mod db_tests {
async fn add_member_by_id_carries_the_name_into_membership() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let admin = member_with_role(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let admin = inviter_with(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let invitee = seed_user(&pool).await;

let _ = add_member_route(
Expand Down Expand Up @@ -532,7 +532,7 @@ mod db_tests {
async fn reinvite_without_a_name_does_not_revive_the_first_invites_name() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let admin = member_with_role(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let admin = inviter_with(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let user = seed_user(&pool).await;
let email = crate::test_support::test_user_email(user);

Expand Down Expand Up @@ -584,7 +584,7 @@ mod db_tests {
async fn invite_audit_carries_the_name_on_every_path() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let admin = member_with_role(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let admin = inviter_with(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let existing = seed_user(&pool).await;
invite_email(&pool, admin, team, &format!("{}@corp.test", Uuid::new_v4()), Some("ByEmail")).await.unwrap();
invite_email(&pool, admin, team, &crate::test_support::test_user_email(existing), Some("ByKnownEmail")).await.unwrap();
Expand Down Expand Up @@ -615,7 +615,7 @@ mod db_tests {
async fn pending_list_returns_the_member_name() {
let pool = test_pool_or_skip!();
let (_, team) = team_for_invites(&pool).await;
let admin = member_with_role(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
let admin = inviter_with(&pool, team, PERM_MANAGE_MEMBERS | PERM_INVITE_MEMBERS).await;
invite_email(&pool, admin, team, &format!("{}@corp.test", Uuid::new_v4()), Some("Pat")).await.unwrap();
let Json(rows) = crate::routes::teams::list_pending_invitations(State(pool.clone()), Extension(AuthUser(admin)), Path(team))
.await
Expand Down
9 changes: 9 additions & 0 deletions src/routes/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub struct MetaResponse {
pub passkeys: bool,
/// `PUT /v1/auth/wrapped-user-secrets` honours `expected_wrapped_user_secrets`; absent on servers that predate it.
pub wrapped_secrets_cas: bool,
/// Only Owners confer the Owner role, and `PUT /v1/teams/:id/billing-owner` exists.
pub multi_owner: bool,
}

pub async fn get_meta(
Expand All @@ -44,6 +46,7 @@ pub async fn get_meta(
two_factor: cipher.available(),
passkeys: passkeys.available(),
wrapped_secrets_cas: true,
multi_owner: true,
})
}

Expand Down Expand Up @@ -95,4 +98,10 @@ mod tests {
let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests()), Extension(Passkeys::for_tests())).await;
assert_eq!(serde_json::to_value(&meta).unwrap()["wrapped_secrets_cas"], serde_json::json!(true));
}

#[tokio::test]
async fn meta_advertises_multi_owner() {
let Json(meta) = get_meta(Extension(Features::open()), Extension(SeedCipher::for_tests()), Extension(Passkeys::for_tests())).await;
assert_eq!(serde_json::to_value(&meta).unwrap()["multi_owner"], serde_json::json!(true));
}
}
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod team_lock_policy;
pub mod team_sync;
pub mod team_objects;
pub mod team_object_prefs;
pub mod team_ownership;
pub mod team_rule_sets;
pub mod teams;
pub mod terminal;
Expand Down
7 changes: 5 additions & 2 deletions src/routes/team_grants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub async fn create_grant(
warn!(team_id = %team_id, user_id = %auth.0, role = %role, "Rejected non-grantable role");
return Err(StatusCode::BAD_REQUEST);
}
crate::team_authority::require_may_confer_builtin(&pool, team_id, auth.0, &role).await?;

let max_uses = grants::clamp_max_uses(body.max_uses);
let ttl = grants::clamp_ttl(body.expires_in_secs);
Expand Down Expand Up @@ -390,7 +391,7 @@ mod tests {
use crate::sync_notifier::SyncEvent;
use crate::test_pool_or_skip;
use crate::test_support::{
member_with_role, seed_team_with_roles, seed_user, set_user_seats,
add_member, grant_builtin_role, member_with_role, seed_team_with_roles, seed_user, set_user_seats,
};
use crate::permissions::PERM_CONNECT;
use std::time::Duration;
Expand All @@ -408,7 +409,9 @@ mod tests {
async fn seed_team_with_manager(pool: &PgPool) -> (Uuid, Uuid, Uuid) {
let owner = seed_user(pool).await;
let team = seed_team_with_roles(pool, owner).await;
let manager = member_with_role(pool, team, PERM_INVITE_MEMBERS).await;
let manager = seed_user(pool).await;
add_member(pool, team, manager).await;
grant_builtin_role(pool, team, manager, "manager").await;
(team, owner, manager)
}

Expand Down
213 changes: 213 additions & 0 deletions src/routes/team_ownership.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use axum::{extract::{Path, State}, http::StatusCode, Extension, Json};
use serde::Deserialize;
use serde_json::json;
use sqlx::PgPool;
use tracing::{error, info, warn};
use uuid::Uuid;

use crate::auth::AuthUser;
use crate::entitlement::effective_tier_for_user;
use crate::routes::audit::write_audit_event;
use crate::routes::teams::{notify_team_members_changed, owner_seat_cap};
use crate::self_host;
use crate::sync_notifier::SyncNotifier;
use crate::team_authority::{holds_owner_role, lock_billing_owner};

#[derive(Deserialize)]
pub struct TransferBillingOwnerRequest {
pub user_id: Uuid,
}

fn plan_rank(tier: &str) -> u8 {
match tier {
"business" => 2,
"teams" => 1,
_ => 0,
}
}

/// 402 unless `target`'s own subscription can carry this team at its current plan and size.
async fn ensure_target_can_pay(pool: &PgPool, team_id: Uuid, current: Uuid, target: Uuid) -> Result<(), StatusCode> {
let needed = plan_rank(&effective_tier_for_user(pool, current).await).max(1);
if plan_rank(&effective_tier_for_user(pool, target).await) < needed {
return Err(StatusCode::PAYMENT_REQUIRED);
}
let Some(cap) = owner_seat_cap(pool, target).await? else {
return Ok(());
};
let seats_after = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT tm.user_id) FROM team_members tm JOIN teams t ON t.id = tm.team_id \
WHERE t.owner_id = $1 OR t.id = $2",
)
.bind(target)
.bind(team_id)
.fetch_one(pool)
.await
.map_err(|e| {
error!(error = %e, "Failed to count seats for billing transfer");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if seats_after > cap {
return Err(StatusCode::PAYMENT_REQUIRED);
}
Ok(())
}

/// Hand the team's billing to another Owner. On a self-hosted server any Owner may move it.
pub async fn transfer_billing_owner(
State(pool): State<PgPool>,
Extension(auth): Extension<AuthUser>,
Extension(notifier): Extension<SyncNotifier>,
Path(team_id): Path<Uuid>,
Json(body): Json<TransferBillingOwnerRequest>,
) -> Result<StatusCode, StatusCode> {
let self_hosted = self_host::is_self_hosted();
let db = |e: sqlx::Error| {
error!(error = %e, team_id = %team_id, "Failed to transfer billing owner");
StatusCode::INTERNAL_SERVER_ERROR
};
let mut tx = pool.begin().await.map_err(db)?;
let current = lock_billing_owner(&mut tx, team_id).await?;
let may_transfer = auth.0 == current || (self_hosted && holds_owner_role(&mut *tx, team_id, auth.0).await?);
if !may_transfer {
warn!(team_id = %team_id, user_id = %auth.0, "Refused billing transfer by a non-billing owner");
return Err(StatusCode::FORBIDDEN);
}
if body.user_id == current {
return Ok(StatusCode::NO_CONTENT);
}
if !holds_owner_role(&mut *tx, team_id, body.user_id).await? {
return Err(StatusCode::CONFLICT);
}
if !self_hosted {
ensure_target_can_pay(&pool, team_id, current, body.user_id).await?;
}
sqlx::query("UPDATE teams SET owner_id = $2 WHERE id = $1")
.bind(team_id)
.bind(body.user_id)
.execute(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;

info!(team_id = %team_id, from = %current, to = %body.user_id, "Billing owner transferred");
tokio::spawn(write_audit_event(
pool.clone(),
team_id,
auth.0,
"team.billing_owner_changed",
Some("user"),
Some(body.user_id.to_string()),
None,
Some(json!({ "from": current, "to": body.user_id })),
));
crate::team_plan::reconcile_team_plan(&pool, &notifier, team_id).await?;
notify_team_members_changed(&pool, &notifier, team_id).await;
Ok(StatusCode::NO_CONTENT)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_pool_or_skip;
use crate::test_support::*;

async fn transfer(pool: &PgPool, actor: Uuid, team: Uuid, to: Uuid) -> Result<StatusCode, StatusCode> {
transfer_billing_owner(
State(pool.clone()),
Extension(AuthUser(actor)),
Extension(SyncNotifier::new()),
Path(team),
Json(TransferBillingOwnerRequest { user_id: to }),
)
.await
}

async fn billing_owner(pool: &PgPool, team: Uuid) -> Uuid {
sqlx::query_scalar("SELECT owner_id FROM teams WHERE id = $1").bind(team).fetch_one(pool).await.unwrap()
}

async fn co_owner(pool: &PgPool, team: Uuid) -> Uuid {
let user = seed_user(pool).await;
add_member(pool, team, user).await;
grant_builtin_role(pool, team, user, "owner").await;
user
}

#[tokio::test]
async fn self_hosted_any_owner_can_take_billing() {
let _mode = BillingMode::self_hosted();
let pool = test_pool_or_skip!();
let creator = seed_user(&pool).await;
let team = seed_team_with_roles(&pool, creator).await;
let bob = co_owner(&pool, team).await;

assert_eq!(transfer(&pool, bob, team, bob).await, Ok(StatusCode::NO_CONTENT));
assert_eq!(billing_owner(&pool, team).await, bob);
}

#[tokio::test]
async fn hosted_only_the_billing_owner_can_transfer() {
let _mode = BillingMode::hosted();
let pool = test_pool_or_skip!();
let creator = seed_user(&pool).await;
set_user_tier(&pool, creator, "teams").await;
let team = seed_team_with_roles(&pool, creator).await;
let bob = co_owner(&pool, team).await;
set_user_tier(&pool, bob, "teams").await;

assert_eq!(transfer(&pool, bob, team, bob).await, Err(StatusCode::FORBIDDEN));
assert_eq!(transfer(&pool, creator, team, bob).await, Ok(StatusCode::NO_CONTENT));
assert_eq!(billing_owner(&pool, team).await, bob);
}

#[tokio::test]
async fn the_target_must_already_be_an_owner() {
let _mode = BillingMode::self_hosted();
let pool = test_pool_or_skip!();
let creator = seed_user(&pool).await;
let team = seed_team_with_roles(&pool, creator).await;
let manager = seed_user(&pool).await;
add_member(&pool, team, manager).await;
grant_builtin_role(&pool, team, manager, "manager").await;

assert_eq!(transfer(&pool, creator, team, manager).await, Err(StatusCode::CONFLICT));
assert_eq!(billing_owner(&pool, team).await, creator);
}

#[tokio::test]
async fn hosted_target_needs_a_plan_at_least_as_high() {
let _mode = BillingMode::hosted();
let pool = test_pool_or_skip!();
let creator = seed_user(&pool).await;
set_user_tier(&pool, creator, "business").await;
let team = seed_team_with_roles(&pool, creator).await;
let bob = co_owner(&pool, team).await;

for tier in ["free", "pro", "teams"] {
set_user_tier(&pool, bob, tier).await;
assert_eq!(transfer(&pool, creator, team, bob).await, Err(StatusCode::PAYMENT_REQUIRED), "{tier}");
}
set_user_tier(&pool, bob, "business").await;
assert_eq!(transfer(&pool, creator, team, bob).await, Ok(StatusCode::NO_CONTENT));
}

#[tokio::test]
async fn hosted_target_needs_seats_for_their_teams_plus_this_one() {
let _mode = BillingMode::hosted();
let pool = test_pool_or_skip!();
let creator = seed_user(&pool).await;
set_user_tier(&pool, creator, "teams").await;
let team = seed_team_with_roles(&pool, creator).await;
let bob = co_owner(&pool, team).await;
set_user_tier(&pool, bob, "teams").await;
let bobs_team = seed_team_with_roles(&pool, bob).await;
let other = seed_user(&pool).await;
add_member(&pool, bobs_team, other).await;

set_user_seats(&pool, bob, 2).await;
assert_eq!(transfer(&pool, creator, team, bob).await, Err(StatusCode::PAYMENT_REQUIRED));
set_user_seats(&pool, bob, 3).await;
assert_eq!(transfer(&pool, creator, team, bob).await, Ok(StatusCode::NO_CONTENT));
}
}
Loading
Loading