From 8cbacbdf4f5a6451affc774d1bbdad132b2cc920 Mon Sep 17 00:00:00 2001 From: kipavy Date: Tue, 25 Aug 2026 16:18:43 +0000 Subject: [PATCH 1/3] feat(teams): revocable, expiring, multi-use team join grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the server half of VoltiusApp/voltius#68: a grant object that lets a link confer *membership* in a team. It cannot confer vault access — the team vault key is wrapped per member with X25519, so there is no recipient public key at link-creation time. The key follows separately once an online key-holder runs the client's reconcileTeamVaultKeys, which is why redemption fires the team_members SSE event. POST /v1/teams/:team_id/grants {role, max_uses, expires_in_secs} GET /v1/teams/:team_id/grants DELETE /v1/teams/:team_id/grants/:id POST /v1/grants/:id/preview {secret} POST /v1/grants/:id/redeem {secret, public_key} Only sha256 of the secret is stored; the secret is minted server-side and returned exactly once. Create, list and revoke sit behind PERM_INVITE_MEMBERS, the same gate as POST /v1/teams/:team_id/invite. Preview and redeem are authenticated as the caller, and the caller's own token decides who joins — the request body carries no identity and no role. Redemption locks the grant row and validates it in one transaction, so two clients racing the last use cannot both succeed. Revoked and expired are re-checked against the freshly locked row on the live path; nothing caches a resolved grant. Redeeming while already a member is a success that consumes no use. Resolution is scoped to this table and to a caller-supplied grant id, with no lookup helper shared with terminal session grants. Audit rows are written for create, revoke and redeem, awaited rather than spawned so a failure cannot pass silently. Factors the team-owner and seat-cap lookups out of invite_member so grant redemption enforces the same cap rather than opening a way around it. --- migrations/038_team_join_grants.sql | 31 + src/main.rs | 33 +- src/rate_limit.rs | 11 + src/routes/mod.rs | 1 + src/routes/team_grants.rs | 1143 +++++++++++++++++++++++++++ src/routes/teams.rs | 78 +- src/team_join_grants.rs | 294 +++++++ src/test_support.rs | 40 + 8 files changed, 1597 insertions(+), 34 deletions(-) create mode 100644 migrations/038_team_join_grants.sql create mode 100644 src/routes/team_grants.rs create mode 100644 src/team_join_grants.rs diff --git a/migrations/038_team_join_grants.sql b/migrations/038_team_join_grants.sql new file mode 100644 index 0000000..c55365c --- /dev/null +++ b/migrations/038_team_join_grants.sql @@ -0,0 +1,31 @@ +-- Revocable, expiring, multi-use grants that admit a redeemer into a team as a +-- member. The role is baked in at creation: the redeemer supplies only the +-- secret, never a role, so a link can never be replayed for more privilege +-- than its creator chose. +-- +-- Modelled on 037_terminal_session_grants: hashed secret, expires_at, +-- revoked_at, created_by. Deliberately NOT modelled on it in one respect — +-- there is no "one live grant per team" partial unique index. A team is meant +-- to have several live links at once (different roles, different audiences), +-- so nothing here needs the race-safe regeneration swap that index provides. +CREATE TABLE team_join_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + secret_hash BYTEA NOT NULL, + role TEXT NOT NULL, + max_uses INTEGER NOT NULL CHECK (max_uses > 0), + uses INTEGER NOT NULL DEFAULT 0 CHECK (uses >= 0), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- The last line of defence behind the conditional UPDATE that consumes a + -- use. If a future caller ever increments without the `uses < max_uses` + -- guard, the write fails rather than over-issuing the link. + CONSTRAINT team_join_grants_uses_within_max CHECK (uses <= max_uses) +); + +CREATE UNIQUE INDEX idx_tjg_secret ON team_join_grants(secret_hash); + +-- Serves the list endpoint, which only ever shows live grants. +CREATE INDEX idx_tjg_team_live ON team_join_grants(team_id) WHERE revoked_at IS NULL; diff --git a/src/main.rs b/src/main.rs index c6e1a70..aeb6dff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod rate_limit; mod routes; mod self_host; mod session_grants; +mod team_join_grants; mod sync_notifier; mod terminal_manager; #[cfg(test)] @@ -23,8 +24,9 @@ use axum::{ }; use dashmap::{DashMap, DashSet}; use rate_limit::{ - InviteRateLimiter, KnockRateLimiter, RateLimiter, RedeemRateLimiter, RegisterRateLimiter, - SearchRateLimiter, SessionCodeRateLimiter, SyncRateLimiter, WaitlistRateLimiter, + GrantMintRateLimiter, GrantRedeemRateLimiter, InviteRateLimiter, KnockRateLimiter, RateLimiter, + RedeemRateLimiter, RegisterRateLimiter, SearchRateLimiter, SessionCodeRateLimiter, + SyncRateLimiter, WaitlistRateLimiter, }; use routes::audit::AuditClientRateLimiter; use std::net::SocketAddr; @@ -184,6 +186,10 @@ async fn main() { SessionCodeRateLimiter(RateLimiter::::new(30, Duration::from_secs(3600))); let redeem_limiter = RedeemRateLimiter(RateLimiter::::new(20, Duration::from_secs(3600))); + let grant_mint_limiter = + GrantMintRateLimiter(RateLimiter::::new(30, Duration::from_secs(3600))); + let grant_redeem_limiter = + GrantRedeemRateLimiter(RateLimiter::::new(20, Duration::from_secs(3600))); // Lemon Squeezy live metrics cache (background refresh every 5 min). let ls_cache = lemonsqueezy::LsCache::default(); @@ -379,6 +385,27 @@ async fn main() { "/v1/teams/:team_id/roles/:role_id", delete(routes::teams::delete_role), ) + // Team join grants (link-borne membership; never vault access) + .route( + "/v1/teams/:team_id/grants", + post(routes::team_grants::create_grant), + ) + .route( + "/v1/teams/:team_id/grants", + get(routes::team_grants::list_grants), + ) + .route( + "/v1/teams/:team_id/grants/:grant_id", + delete(routes::team_grants::revoke_grant), + ) + .route( + "/v1/grants/:grant_id/preview", + post(routes::team_grants::preview_grant), + ) + .route( + "/v1/grants/:grant_id/redeem", + post(routes::team_grants::redeem_grant), + ) // Team vault sync .route( "/v1/teams/:team_id/vault-key", @@ -511,6 +538,8 @@ async fn main() { .layer(Extension(knock_limiter)) .layer(Extension(session_code_limiter)) .layer(Extension(redeem_limiter)) + .layer(Extension(grant_mint_limiter)) + .layer(Extension(grant_redeem_limiter)) .layer(middleware::from_fn(auth::auth_middleware)) .layer(Extension(notifier.clone())) .layer(Extension(terminal_manager.clone())) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 1a92a7c..f991a5d 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -108,6 +108,17 @@ pub struct SessionCodeRateLimiter(pub RateLimiter); #[derive(Clone)] pub struct RedeemRateLimiter(pub RateLimiter); +/// Team join-grant mints per creator. A grant is unattended credential +/// material, so minting is budgeted the same way short codes are. +#[derive(Clone)] +pub struct GrantMintRateLimiter(pub RateLimiter); + +/// Join-grant previews and redemptions per user. Kept separate from +/// [`RedeemRateLimiter`] so exhausting one path cannot lock a user out of the +/// other — they are different features that merely share a verb. +#[derive(Clone)] +pub struct GrantRedeemRateLimiter(pub RateLimiter); + /// Register endpoint: N registrations/day per IP. pub async fn register_rate_limit( axum::Extension(RegisterRateLimiter(limiter)): axum::Extension, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f828ee8..6b890fc 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod meta; pub mod presence; pub mod session_codes; pub mod sync; +pub mod team_grants; pub mod team_sync; pub mod team_objects; pub mod team_object_prefs; diff --git a/src/routes/team_grants.rs b/src/routes/team_grants.rs new file mode 100644 index 0000000..fef9234 --- /dev/null +++ b/src/routes/team_grants.rs @@ -0,0 +1,1143 @@ +//! Team-vault join grants (the server half of VoltiusApp/voltius#68). +//! +//! A grant confers *membership*, never vault access: the vault key is wrapped +//! per member with X25519, so it can only follow once an online key-holder +//! runs the client's `reconcileTeamVaultKeys`. Redemption's job is therefore +//! to insert the membership row, make sure the roster carries the joiner's +//! public key, and fire the `team_members` event that wakes those key-holders. +//! +//! `account_id` appears nowhere in this module. Despite the name it is the KDF +//! salt passed to `derive_keys`; emitting it would hand an attacker offline +//! precompute against that user's password. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::permissions::{require_all_team_permissions, PERM_INVITE_MEMBERS}; +use crate::rate_limit::{check_user_budget, GrantMintRateLimiter, GrantRedeemRateLimiter}; +use crate::routes::audit::write_audit_event; +use crate::routes::invitations::admit_member; +use crate::routes::teams::{notify_team_members_changed, owner_seat_cap, owner_seats_used, team_owner}; +use crate::sync_notifier::SyncNotifier; +use crate::team_join_grants::{self as grants, GrantRejection, GrantRow}; + +/// Length ceiling on a submitted X25519 public key. Base64 of 32 bytes is 44 +/// characters; the slack is for future key formats, not for arbitrary blobs. +const MAX_PUBLIC_KEY_LEN: usize = 256; + +/// The create/list/revoke gate, in one place: the same permission bit that +/// guards `POST /v1/teams/:team_id/invite`. Preview and redeem are deliberately +/// not gated on it — they are authenticated as whoever holds the link. +async fn require_grant_admin(pool: &PgPool, team_id: Uuid, user: Uuid) -> Result<(), StatusCode> { + require_all_team_permissions(pool, team_id, user, &[PERM_INVITE_MEMBERS]).await +} + +fn reject(rejection: GrantRejection) -> StatusCode { + match rejection { + // Wrong id and wrong secret answer alike: nothing distinguishes a real + // grant from a guess. + GrantRejection::NotFound => StatusCode::NOT_FOUND, + // The remaining variants are only reachable by someone who already + // presented the correct secret, so naming the reason leaks nothing and + // lets the client say something true. + GrantRejection::Revoked | GrantRejection::Expired => StatusCode::GONE, + GrantRejection::Exhausted => StatusCode::CONFLICT, + } +} + +// ─── Create ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct CreateGrantRequest { + pub role: Option, + pub max_uses: Option, + pub expires_in_secs: Option, +} + +#[derive(Serialize)] +pub struct CreateGrantResponse { + pub id: Uuid, + /// Returned exactly once. Only its sha256 is stored, so this response is + /// the sole opportunity to read it — there is no endpoint that can show it + /// again. + pub secret: String, + pub role: String, + pub max_uses: i32, + pub uses: i32, + pub expires_at: DateTime, +} + +/// Hand-written so the secret can never reach a log through a stray `{:?}`. +/// The response body is the only place it is ever meant to appear. +impl std::fmt::Debug for CreateGrantResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CreateGrantResponse") + .field("id", &self.id) + .field("secret", &"") + .field("role", &self.role) + .field("max_uses", &self.max_uses) + .field("uses", &self.uses) + .field("expires_at", &self.expires_at) + .finish() + } +} + +pub async fn create_grant( + State(pool): State, + Extension(auth): Extension, + Extension(notifier): Extension, + Extension(GrantMintRateLimiter(limiter)): Extension, + Path(team_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), StatusCode> { + check_user_budget(&limiter, auth.0, "team_grant_mint").await?; + require_grant_admin(&pool, team_id, auth.0).await?; + + let role = body.role.as_deref().unwrap_or("member").to_string(); + if !grants::is_grantable_role(&role) { + warn!(team_id = %team_id, user_id = %auth.0, role = %role, "Rejected non-grantable role"); + return Err(StatusCode::BAD_REQUEST); + } + + let max_uses = grants::clamp_max_uses(body.max_uses); + let ttl = grants::clamp_ttl(body.expires_in_secs); + + let (grant, secret) = grants::create(&pool, team_id, &role, max_uses, ttl, auth.0) + .await + .map_err(|e| { + error!(error = %e, team_id = %team_id, "Failed to create team join grant"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + info!(team_id = %team_id, grant_id = %grant.id, role = %role, max_uses, "Team join grant created"); + write_audit_event( + pool.clone(), + team_id, + auth.0, + "join_grant.created", + Some("join_grant"), + Some(grant.id.to_string()), + None, + Some(json!({ "role": role, "max_uses": max_uses, "expires_at": grant.expires_at })), + ) + .await; + + // Managers watching the roster see the new link without a refetch prompt. + notify_team_members_changed(&pool, ¬ifier, team_id).await; + + Ok(( + StatusCode::CREATED, + Json(CreateGrantResponse { + id: grant.id, + secret, + role: grant.role, + max_uses: grant.max_uses, + uses: grant.uses, + expires_at: grant.expires_at, + }), + )) +} + +// ─── List ───────────────────────────────────────────────────────────────────── + +pub async fn list_grants( + State(pool): State, + Extension(auth): Extension, + Path(team_id): Path, +) -> Result>, StatusCode> { + require_grant_admin(&pool, team_id, auth.0).await?; + + grants::list_live(&pool, team_id).await.map(Json).map_err(|e| { + error!(error = %e, team_id = %team_id, "Failed to list team join grants"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +// ─── Revoke ─────────────────────────────────────────────────────────────────── + +pub async fn revoke_grant( + State(pool): State, + Extension(auth): Extension, + Extension(notifier): Extension, + Path((team_id, grant_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_grant_admin(&pool, team_id, auth.0).await?; + + let revoked = grants::revoke(&pool, team_id, grant_id).await.map_err(|e| { + error!(error = %e, team_id = %team_id, grant_id = %grant_id, "Failed to revoke team join grant"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !revoked { + return Err(StatusCode::NOT_FOUND); + } + + info!(team_id = %team_id, grant_id = %grant_id, "Team join grant revoked"); + write_audit_event( + pool.clone(), + team_id, + auth.0, + "join_grant.revoked", + Some("join_grant"), + Some(grant_id.to_string()), + None, + None, + ) + .await; + + notify_team_members_changed(&pool, ¬ifier, team_id).await; + Ok(StatusCode::NO_CONTENT) +} + +// ─── Preview ────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct SecretRequest { + pub secret: String, +} + +#[derive(Debug, Serialize)] +pub struct PreviewResponse { + pub team_name: String, + pub role: String, + pub inviter_handle: Option, +} + +pub async fn preview_grant( + State(pool): State, + Extension(auth): Extension, + Extension(GrantRedeemRateLimiter(limiter)): Extension, + Path(grant_id): Path, + Json(body): Json, +) -> Result, StatusCode> { + check_user_budget(&limiter, auth.0, "team_grant_preview").await?; + + let preview = grants::preview(&pool, grant_id, &body.secret) + .await + .map_err(reject)?; + + Ok(Json(PreviewResponse { + team_name: preview.team_name, + role: preview.role, + inviter_handle: preview.inviter_handle, + })) +} + +// ─── Redeem ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct RedeemGrantRequest { + pub secret: String, + /// The redeemer's X25519 public key, so the roster row a key-holder reads + /// can be wrapped for immediately. There is deliberately no user field: + /// who joins is decided by the bearer token, never by the body. + pub public_key: Option, +} + +#[derive(Debug, Serialize)] +pub struct RedeemGrantResponse { + pub team_id: Uuid, + pub team_name: String, + pub role: String, +} + +pub async fn redeem_grant( + State(pool): State, + Extension(auth): Extension, + Extension(notifier): Extension, + Extension(GrantRedeemRateLimiter(limiter)): Extension, + Path(grant_id): Path, + Json(body): Json, +) -> Result, StatusCode> { + check_user_budget(&limiter, auth.0, "team_grant_redeem").await?; + + if let Some(key) = body.public_key.as_deref() { + if key.trim().is_empty() || key.len() > MAX_PUBLIC_KEY_LEN { + return Err(StatusCode::BAD_REQUEST); + } + } + + let mut tx = pool.begin().await.map_err(|e| { + error!(error = %e, "Failed to begin transaction for grant redemption"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let locked = grants::lock_for_redemption(&mut tx, grant_id, &body.secret, auth.0) + .await + .map_err(reject)?; + + let response = RedeemGrantResponse { + team_id: locked.team_id, + team_name: locked.team_name.clone(), + role: locked.role.clone(), + }; + + // Already a member: a success, and not a consumed use. Someone re-opening + // their own link must not burn a seat on the next person. + if locked.already_member { + tx.commit().await.map_err(|e| { + error!(error = %e, "Failed to commit no-op grant redemption"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + info!(user_id = %auth.0, team_id = %locked.team_id, "Join grant redeemed by an existing member; no-op"); + return Ok(Json(response)); + } + + // A link must not be a way around the owner's seat cap. Membership in any + // other team of the same owner already occupies the seat, so those users + // are exempt. + let owner_id = team_owner(&pool, locked.team_id).await?; + if let Some(effective_cap) = owner_seat_cap(&pool, owner_id).await? { + let holds_a_seat = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM team_members tm JOIN teams t ON t.id = tm.team_id \ + WHERE t.owner_id = $1 AND tm.user_id = $2)", + ) + .bind(owner_id) + .bind(auth.0) + .fetch_one(&pool) + .await + .map_err(|e| { error!(error = %e, "Failed to check seat occupancy"); StatusCode::INTERNAL_SERVER_ERROR })?; + + if !holds_a_seat { + let used = owner_seats_used(&pool, owner_id).await?; + if used >= effective_cap { + warn!(owner_id = %owner_id, effective_cap, used, "Seat limit reached on grant redemption"); + return Err(StatusCode::PAYMENT_REQUIRED); + } + } + } + + // Validity and consumption are the same statement. Losing this race means + // another redeemer took the last use between the lock and here, which the + // lock makes impossible — it stays as the backstop that keeps the invariant + // true if the locking above is ever weakened. + if !grants::consume_use(&mut tx, grant_id).await.map_err(|e| { + error!(error = %e, grant_id = %grant_id, "Failed to consume join grant use"); + StatusCode::INTERNAL_SERVER_ERROR + })? { + return Err(reject(GrantRejection::Exhausted)); + } + + // `invited_by` is the grant's creator, so the roster attributes the joiner + // to whoever minted the link. + admit_member(&mut tx, locked.team_id, auth.0, Some(locked.created_by), &locked.role).await?; + + // Fill a missing key only. Overwriting a key a user already published + // would orphan every vault key already wrapped to it, in this team and + // every other; rotation belongs to PUT /v1/auth/public-key. + if let Some(key) = body.public_key.as_deref() { + sqlx::query( + "UPDATE users SET public_key = $1, updated_at = now() \ + WHERE id = $2 AND (public_key IS NULL OR public_key = '')", + ) + .bind(key) + .bind(auth.0) + .execute(&mut *tx) + .await + .map_err(|e| { + error!(error = %e, "Failed to record redeemer public key"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + } + + // Without a key on the roster row, no key-holder can wrap for this member + // and the joiner lands in a vault that can never fill. Refuse the join + // rather than create that state. + let has_key = sqlx::query_scalar::<_, bool>( + "SELECT public_key IS NOT NULL AND public_key <> '' FROM users WHERE id = $1", + ) + .bind(auth.0) + .fetch_one(&mut *tx) + .await + .map_err(|e| { error!(error = %e, "Failed to verify redeemer public key"); StatusCode::INTERNAL_SERVER_ERROR })?; + + if !has_key { + warn!(user_id = %auth.0, "Join grant redemption without a public key"); + return Err(StatusCode::BAD_REQUEST); + } + + tx.commit().await.map_err(|e| { + error!(error = %e, "Failed to commit grant redemption"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let joiner_handle = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE id = $1") + .bind(auth.0) + .fetch_optional(&pool) + .await + .unwrap_or(None); + + info!(user_id = %auth.0, team_id = %locked.team_id, role = %locked.role, "Team joined via join grant"); + write_audit_event( + pool.clone(), + locked.team_id, + auth.0, + "member.joined", + Some("user"), + Some(auth.0.to_string()), + joiner_handle, + Some(json!({ "role": locked.role, "via": "join_grant", "grant_id": grant_id })), + ) + .await; + + // The joiner's own devices refetch their team list; every member — the + // joiner included — gets `team_members:`, which is the event an + // online key-holder's reconcileTeamVaultKeys listens for. + notifier.notify_membership_changed(auth.0); + notify_team_members_changed(&pool, ¬ifier, locked.team_id).await; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rate_limit::RateLimiter; + 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, + }; + use crate::permissions::PERM_CONNECT; + use std::time::Duration; + + fn mint_budget() -> GrantMintRateLimiter { + GrantMintRateLimiter(RateLimiter::new(100, Duration::from_secs(3600))) + } + + fn redeem_budget() -> GrantRedeemRateLimiter { + GrantRedeemRateLimiter(RateLimiter::new(100, Duration::from_secs(3600))) + } + + /// A team with builtin roles, its owner, and a manager who holds + /// PERM_INVITE_MEMBERS — the same gate `POST /v1/teams/:id/invite` uses. + 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; + (team, owner, manager) + } + + async fn mint( + pool: &PgPool, + team: Uuid, + actor: Uuid, + role: &str, + max_uses: i32, + ) -> CreateGrantResponse { + let (_, Json(created)) = create_grant( + State(pool.clone()), + Extension(AuthUser(actor)), + Extension(SyncNotifier::new()), + Extension(mint_budget()), + Path(team), + Json(CreateGrantRequest { + role: Some(role.to_string()), + max_uses: Some(max_uses), + expires_in_secs: Some(3600), + }), + ) + .await + .expect("mint grant"); + created + } + + async fn redeem( + pool: &PgPool, + grant_id: Uuid, + secret: &str, + user: Uuid, + ) -> Result, StatusCode> { + redeem_with_notifier(pool, grant_id, secret, user, SyncNotifier::new()).await + } + + async fn redeem_with_notifier( + pool: &PgPool, + grant_id: Uuid, + secret: &str, + user: Uuid, + notifier: SyncNotifier, + ) -> Result, StatusCode> { + redeem_grant( + State(pool.clone()), + Extension(AuthUser(user)), + Extension(notifier), + Extension(redeem_budget()), + Path(grant_id), + Json(RedeemGrantRequest { + secret: secret.to_string(), + public_key: Some("test-pubkey".to_string()), + }), + ) + .await + } + + async fn assigned_role(pool: &PgPool, team: Uuid, user: Uuid) -> Option { + sqlx::query_scalar::<_, String>( + "SELECT tr.name 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", + ) + .bind(team) + .bind(user) + .fetch_optional(pool) + .await + .unwrap() + } + + async fn uses_of(pool: &PgPool, grant_id: Uuid) -> i32 { + sqlx::query_scalar::<_, i32>("SELECT uses FROM team_join_grants WHERE id = $1") + .bind(grant_id) + .fetch_one(pool) + .await + .unwrap() + } + + async fn audit_actions(pool: &PgPool, team: Uuid) -> Vec { + sqlx::query_scalar::<_, String>( + "SELECT action FROM audit_logs WHERE team_id = $1 ORDER BY created_at ASC, id ASC", + ) + .bind(team) + .fetch_all(pool) + .await + .unwrap() + } + + // ─── Gating ────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn creating_listing_and_revoking_need_the_invite_permission() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + // A member with a permission bit that is not PERM_INVITE_MEMBERS. + let plain = member_with_role(&pool, team, PERM_CONNECT).await; + + for actor in [plain, seed_user(&pool).await] { + let err = create_grant( + State(pool.clone()), + Extension(AuthUser(actor)), + Extension(SyncNotifier::new()), + Extension(mint_budget()), + Path(team), + Json(CreateGrantRequest { role: None, max_uses: None, expires_in_secs: None }), + ) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::FORBIDDEN); + + assert_eq!( + list_grants(State(pool.clone()), Extension(AuthUser(actor)), Path(team)) + .await + .unwrap_err(), + StatusCode::FORBIDDEN + ); + } + + let created = mint(&pool, team, manager, "member", 1).await; + let err = revoke_grant( + State(pool.clone()), + Extension(AuthUser(plain)), + Extension(SyncNotifier::new()), + Path((team, created.id)), + ) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn a_grant_can_never_confer_ownership() { + // A link that mints owners is a privilege-escalation primitive, and the + // create gate is held by managers who are not owners themselves. + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + + let err = create_grant( + State(pool.clone()), + Extension(AuthUser(manager)), + Extension(SyncNotifier::new()), + Extension(mint_budget()), + Path(team), + Json(CreateGrantRequest { + role: Some("owner".to_string()), + max_uses: None, + expires_in_secs: None, + }), + ) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn revoking_a_grant_from_another_team_is_not_found() { + let pool = test_pool_or_skip!(); + let (team, _o, manager) = seed_team_with_manager(&pool).await; + let (other_team, _oo, other_manager) = seed_team_with_manager(&pool).await; + + let created = mint(&pool, team, manager, "member", 1).await; + + let err = revoke_grant( + State(pool.clone()), + Extension(AuthUser(other_manager)), + Extension(SyncNotifier::new()), + Path((other_team, created.id)), + ) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::NOT_FOUND); + assert!(grants::preview(&pool, created.id, &created.secret).await.is_ok()); + } + + // ─── The role is fixed at creation ─────────────────────────────────────── + + #[tokio::test] + async fn the_redeemer_cannot_override_the_baked_in_role() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + + let created = mint(&pool, team, manager, "member", 1).await; + + // A body that tries to name its own role. The request type has no such + // field, so serde drops it; this asserts that stays true. + let body: RedeemGrantRequest = serde_json::from_value(serde_json::json!({ + "secret": created.secret, + "public_key": "test-pubkey", + "role": "owner", + "user_id": Uuid::new_v4(), + })) + .expect("extra fields are ignored, not rejected"); + + let Json(redeemed) = redeem_grant( + State(pool.clone()), + Extension(AuthUser(joiner)), + Extension(SyncNotifier::new()), + Extension(redeem_budget()), + Path(created.id), + Json(body), + ) + .await + .expect("redeem"); + + assert_eq!(redeemed.role, "member"); + assert_eq!(assigned_role(&pool, team, joiner).await.as_deref(), Some("member")); + } + + #[tokio::test] + async fn the_caller_token_decides_who_joins_not_the_body() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + let victim = seed_user(&pool).await; + + let created = mint(&pool, team, manager, "member", 1).await; + let body: RedeemGrantRequest = serde_json::from_value(serde_json::json!({ + "secret": created.secret, + "public_key": "test-pubkey", + "user_id": victim, + })) + .unwrap(); + + let _ = redeem_grant( + State(pool.clone()), + Extension(AuthUser(joiner)), + Extension(SyncNotifier::new()), + Extension(redeem_budget()), + Path(created.id), + Json(body), + ) + .await + .expect("redeem"); + + assert!(assigned_role(&pool, team, joiner).await.is_some()); + assert!( + assigned_role(&pool, team, victim).await.is_none(), + "a body field must never be able to enrol someone else" + ); + } + + // ─── Expiry, revocation, exhaustion ────────────────────────────────────── + + #[tokio::test] + async fn an_expired_grant_neither_previews_nor_redeems() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + + let created = mint(&pool, team, manager, "member", 5).await; + sqlx::query("UPDATE team_join_grants SET expires_at = now() - interval '1 second' WHERE id = $1") + .bind(created.id) + .execute(&pool) + .await + .unwrap(); + + assert_eq!( + grants::preview(&pool, created.id, &created.secret).await.unwrap_err(), + GrantRejection::Expired + ); + assert_eq!( + redeem(&pool, created.id, &created.secret, joiner).await.unwrap_err(), + StatusCode::GONE + ); + assert!(assigned_role(&pool, team, joiner).await.is_none()); + assert_eq!(uses_of(&pool, created.id).await, 0); + } + + #[tokio::test] + async fn revocation_takes_effect_on_the_live_redeem_path() { + // The failure this guards against: a revoked-in-DB grant that still + // authorises because something upstream resolved it earlier. + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let first = seed_user(&pool).await; + let second = seed_user(&pool).await; + + let created = mint(&pool, team, manager, "member", 5).await; + assert!(redeem(&pool, created.id, &created.secret, first).await.is_ok()); + + assert_eq!( + revoke_grant( + State(pool.clone()), + Extension(AuthUser(manager)), + Extension(SyncNotifier::new()), + Path((team, created.id)), + ) + .await + .unwrap(), + StatusCode::NO_CONTENT + ); + + assert_eq!( + redeem(&pool, created.id, &created.secret, second).await.unwrap_err(), + StatusCode::GONE + ); + assert_eq!( + grants::preview(&pool, created.id, &created.secret).await.unwrap_err(), + GrantRejection::Revoked + ); + assert!(assigned_role(&pool, team, second).await.is_none()); + assert_eq!(uses_of(&pool, created.id).await, 1, "the refused redemption consumed nothing"); + + // Revoked grants leave the list; a second revoke has nothing to do. + let Json(live) = list_grants(State(pool.clone()), Extension(AuthUser(manager)), Path(team)) + .await + .unwrap(); + assert!(live.iter().all(|g| g.id != created.id)); + } + + #[tokio::test] + async fn uses_are_exhausted_at_max_uses() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 2).await; + + for _ in 0..2 { + let joiner = seed_user(&pool).await; + assert!(redeem(&pool, created.id, &created.secret, joiner).await.is_ok()); + } + + let late = seed_user(&pool).await; + assert_eq!( + redeem(&pool, created.id, &created.secret, late).await.unwrap_err(), + StatusCode::CONFLICT + ); + assert!(assigned_role(&pool, team, late).await.is_none()); + assert_eq!(uses_of(&pool, created.id).await, 2); + } + + #[tokio::test] + async fn two_clients_racing_the_last_use_cannot_both_succeed() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 1).await; + + let a = seed_user(&pool).await; + let b = seed_user(&pool).await; + + let (ra, rb) = tokio::join!( + redeem(&pool, created.id, &created.secret, a), + redeem(&pool, created.id, &created.secret, b), + ); + + let winners = [&ra, &rb].iter().filter(|r| r.is_ok()).count(); + assert_eq!(winners, 1, "exactly one redemption may take the last use"); + for loser in [&ra, &rb].into_iter().filter(|r| r.is_err()) { + assert_eq!(*loser.as_ref().unwrap_err(), StatusCode::CONFLICT); + } + + assert_eq!(uses_of(&pool, created.id).await, 1); + let members: i64 = sqlx::query_scalar( + "SELECT count(*) FROM team_members WHERE team_id = $1 AND user_id = ANY($2)", + ) + .bind(team) + .bind(vec![a, b]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(members, 1); + } + + // ─── Already a member ──────────────────────────────────────────────────── + + #[tokio::test] + async fn redeeming_as_an_existing_member_succeeds_without_consuming_a_use() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 1).await; + + // The manager is already on the team. + let Json(redeemed) = redeem(&pool, created.id, &created.secret, manager) + .await + .expect("an existing member gets a success, not an error"); + assert_eq!(redeemed.team_id, team); + assert_eq!(uses_of(&pool, created.id).await, 0); + + // The single use is therefore still available to a real joiner. + let joiner = seed_user(&pool).await; + assert!(redeem(&pool, created.id, &created.secret, joiner).await.is_ok()); + assert_eq!(uses_of(&pool, created.id).await, 1); + } + + #[tokio::test] + async fn an_existing_member_keeps_the_role_they_already_have() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "connect-only", 5).await; + let before = assigned_role(&pool, team, manager).await; + + let _ = redeem(&pool, created.id, &created.secret, manager).await.unwrap(); + + assert_eq!( + assigned_role(&pool, team, manager).await, + before, + "a no-op redemption must not re-role an existing member" + ); + } + + // ─── Secrets, previews and lookups ─────────────────────────────────────── + + #[tokio::test] + async fn the_secret_is_stored_only_as_a_hash() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 1).await; + + let matches: i64 = sqlx::query_scalar( + "SELECT count(*) FROM team_join_grants \ + WHERE id = $1 AND secret_hash = sha256(convert_to($2, 'UTF8'))", + ) + .bind(created.id) + .bind(&created.secret) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(matches, 1, "the column holds sha256(secret), nothing else"); + + // Nothing on the read paths can hand the secret back. + let Json(live) = list_grants(State(pool.clone()), Extension(AuthUser(manager)), Path(team)) + .await + .unwrap(); + let listed = serde_json::to_string(&live).unwrap(); + assert!(!listed.contains(&created.secret)); + assert!(!listed.contains("account_id")); + } + + #[tokio::test] + async fn a_wrong_secret_is_indistinguishable_from_an_unknown_grant() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + let created = mint(&pool, team, manager, "member", 1).await; + + for (id, secret) in [ + (created.id, "wrong-secret"), + (Uuid::new_v4(), created.secret.as_str()), + ] { + assert_eq!( + preview_grant( + State(pool.clone()), + Extension(AuthUser(joiner)), + Extension(redeem_budget()), + Path(id), + Json(SecretRequest { secret: secret.to_string() }), + ) + .await + .unwrap_err(), + StatusCode::NOT_FOUND + ); + assert_eq!( + redeem(&pool, id, secret, joiner).await.unwrap_err(), + StatusCode::NOT_FOUND + ); + } + } + + #[tokio::test] + async fn preview_names_the_team_role_and_inviter_without_joining() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + let created = mint(&pool, team, manager, "editor", 3).await; + + let Json(preview) = preview_grant( + State(pool.clone()), + Extension(AuthUser(joiner)), + Extension(redeem_budget()), + Path(created.id), + Json(SecretRequest { secret: created.secret.clone() }), + ) + .await + .unwrap(); + + assert_eq!(preview.team_name, "test-team"); + assert_eq!(preview.role, "editor"); + let manager_handle = + sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE id = $1") + .bind(manager) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(preview.inviter_handle.as_deref(), Some(manager_handle.as_str())); + + assert!(assigned_role(&pool, team, joiner).await.is_none()); + assert_eq!(uses_of(&pool, created.id).await, 0); + } + + // ─── Key distribution ──────────────────────────────────────────────────── + + #[tokio::test] + async fn redemption_records_a_missing_public_key_but_never_replaces_one() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 5).await; + + let keyless = seed_user(&pool).await; + sqlx::query("UPDATE users SET public_key = NULL WHERE id = $1") + .bind(keyless) + .execute(&pool) + .await + .unwrap(); + + let _ = redeem_grant( + State(pool.clone()), + Extension(AuthUser(keyless)), + Extension(SyncNotifier::new()), + Extension(redeem_budget()), + Path(created.id), + Json(RedeemGrantRequest { + secret: created.secret.clone(), + public_key: Some("fresh-x25519-key".to_string()), + }), + ) + .await + .unwrap(); + + let stored = sqlx::query_scalar::<_, Option>("SELECT public_key FROM users WHERE id = $1") + .bind(keyless) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored.as_deref(), Some("fresh-x25519-key")); + + // A user who already published a key keeps it: overwriting would orphan + // every vault key already wrapped to it. + let established = seed_user(&pool).await; + let _ = redeem_grant( + State(pool.clone()), + Extension(AuthUser(established)), + Extension(SyncNotifier::new()), + Extension(redeem_budget()), + Path(created.id), + Json(RedeemGrantRequest { + secret: created.secret.clone(), + public_key: Some("a-different-key".to_string()), + }), + ) + .await + .unwrap(); + + let unchanged = sqlx::query_scalar::<_, Option>("SELECT public_key FROM users WHERE id = $1") + .bind(established) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(unchanged.as_deref(), Some("test-pubkey")); + } + + #[tokio::test] + async fn a_keyless_redeemer_who_supplies_no_key_is_refused() { + // Admitting them would produce a member no key-holder can ever wrap + // for: an account permanently staring at an empty vault. + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 5).await; + + let keyless = seed_user(&pool).await; + sqlx::query("UPDATE users SET public_key = NULL WHERE id = $1") + .bind(keyless) + .execute(&pool) + .await + .unwrap(); + + let err = redeem_grant( + State(pool.clone()), + Extension(AuthUser(keyless)), + Extension(SyncNotifier::new()), + Extension(redeem_budget()), + Path(created.id), + Json(RedeemGrantRequest { secret: created.secret.clone(), public_key: None }), + ) + .await + .unwrap_err(); + + assert_eq!(err, StatusCode::BAD_REQUEST); + assert!(assigned_role(&pool, team, keyless).await.is_none()); + assert_eq!(uses_of(&pool, created.id).await, 0, "a refused join consumes nothing"); + } + + #[tokio::test] + async fn redemption_fires_the_team_members_event_key_holders_listen_for() { + // This is what stops a joiner sitting in an empty vault: an online + // key-holder's reconcileTeamVaultKeys wakes on `team_members:`. + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let created = mint(&pool, team, manager, "member", 1).await; + let joiner = seed_user(&pool).await; + + let notifier = SyncNotifier::new(); + let mut rx = notifier.subscribe(); + + let _ = redeem_with_notifier(&pool, created.id, &created.secret, joiner, notifier) + .await + .unwrap(); + + let mut roster_events = Vec::new(); + let mut membership_events = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + SyncEvent::BlobPushed { user_id, device_id } + if device_id == format!("team_members:{team}") => + { + roster_events.push(user_id) + } + SyncEvent::MembershipChanged { user_id } => membership_events.push(user_id), + _ => {} + } + } + + assert!( + roster_events.contains(&manager), + "the existing key-holder must be told the roster changed" + ); + assert!( + roster_events.contains(&joiner), + "the joiner must be told too, so their client can wait for a key" + ); + assert_eq!(membership_events, vec![joiner]); + } + + // ─── Seats ─────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn a_link_cannot_carry_a_team_past_the_owners_seat_cap() { + let pool = test_pool_or_skip!(); + let (team, owner, manager) = seed_team_with_manager(&pool).await; + // Owner and manager already occupy both seats. + set_user_seats(&pool, owner, 2).await; + + let created = mint(&pool, team, manager, "member", 5).await; + let joiner = seed_user(&pool).await; + + assert_eq!( + redeem(&pool, created.id, &created.secret, joiner).await.unwrap_err(), + StatusCode::PAYMENT_REQUIRED + ); + assert_eq!(uses_of(&pool, created.id).await, 0); + + // An existing seat-holder is exempt: they consume no new seat. + let second_team = seed_team_with_roles(&pool, owner).await; + let seat_holder = manager; + let second_grant = mint(&pool, second_team, owner, "member", 5).await; + assert!( + redeem(&pool, second_grant.id, &second_grant.secret, seat_holder).await.is_ok(), + "a user already holding one of this owner's seats may join another of their teams" + ); + } + + // ─── Audit ─────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn create_revoke_and_redeem_each_land_an_audit_row() { + let pool = test_pool_or_skip!(); + let (team, _owner, manager) = seed_team_with_manager(&pool).await; + let joiner = seed_user(&pool).await; + + assert!(audit_actions(&pool, team).await.is_empty()); + + let created = mint(&pool, team, manager, "editor", 5).await; + let _ = redeem(&pool, created.id, &created.secret, joiner).await.unwrap(); + revoke_grant( + State(pool.clone()), + Extension(AuthUser(manager)), + Extension(SyncNotifier::new()), + Path((team, created.id)), + ) + .await + .unwrap(); + + assert_eq!( + audit_actions(&pool, team).await, + vec!["join_grant.created", "member.joined", "join_grant.revoked"], + "all three rows must actually reach the database" + ); + + let (target_id, metadata) = sqlx::query_as::<_, (Option, Option)>( + "SELECT target_id, metadata FROM audit_logs WHERE team_id = $1 AND action = 'member.joined'", + ) + .bind(team) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(target_id.as_deref(), Some(joiner.to_string().as_str())); + let metadata = metadata.unwrap(); + assert_eq!(metadata["via"], "join_grant"); + assert_eq!(metadata["role"], "editor"); + assert_eq!(metadata["grant_id"], created.id.to_string()); + assert!(metadata.get("account_id").is_none()); + } + + // ─── Clamps ────────────────────────────────────────────────────────────── + + #[test] + fn ttl_and_use_counts_are_clamped_into_range() { + assert_eq!(grants::clamp_max_uses(None), 1); + assert_eq!(grants::clamp_max_uses(Some(0)), 1); + assert_eq!(grants::clamp_max_uses(Some(-5)), 1); + assert_eq!(grants::clamp_max_uses(Some(1_000_000)), grants::MAX_USES_CEILING); + + assert_eq!(grants::clamp_ttl(None).num_seconds(), grants::DEFAULT_TTL_SECS); + assert_eq!(grants::clamp_ttl(Some(-1)).num_seconds(), 60); + assert_eq!( + grants::clamp_ttl(Some(i64::MAX)).num_seconds(), + grants::MAX_TTL_SECS, + "an unattended link always expires" + ); + } + + #[test] + fn generated_secrets_are_url_safe_and_high_entropy() { + let secret = grants::generate_secret(); + assert_eq!(secret.len(), 43, "43 base64url characters is 256 bits"); + assert!(secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + assert_ne!(secret, grants::generate_secret()); + } +} diff --git a/src/routes/teams.rs b/src/routes/teams.rs index 395bdab..cd577aa 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -34,17 +34,54 @@ pub(crate) async fn notify_team_members_changed(pool: &PgPool, notifier: &SyncNo notify_team_members(pool, notifier, team_id, format!("team_members:{team_id}")).await; } +// ─── Team owner and seat helpers ────────────────────────────────────────────── + +pub(crate) async fn team_owner(pool: &PgPool, team_id: Uuid) -> Result { + sqlx::query_scalar::<_, Uuid>("SELECT owner_id FROM teams WHERE id = $1") + .bind(team_id) + .fetch_one(pool) + .await + .map_err(|e| { error!(error = %e, "Failed to fetch team owner"); StatusCode::INTERNAL_SERVER_ERROR }) +} + +/// The owner's effective seat cap, or `None` when uncapped. An active trial +/// clamps the cap to 10 however many seats were purchased. +pub(crate) async fn owner_seat_cap(pool: &PgPool, owner_id: Uuid) -> Result, StatusCode> { + let (seat_count, trial_ends_at) = sqlx::query_as::<_, (Option, Option>)>( + "SELECT seat_count, trial_ends_at FROM users WHERE id = $1", + ) + .bind(owner_id) + .fetch_one(pool) + .await + .map_err(|e| { error!(error = %e, "Failed to fetch seat count"); StatusCode::INTERNAL_SERVER_ERROR })?; + + Ok(seat_count.map(|seats| { + let effective = if trial_ends_at.is_some() { seats.min(10) } else { seats }; + effective as i64 + })) +} + +/// Distinct users occupying a seat across every team this owner owns. +pub(crate) async fn owner_seats_used(pool: &PgPool, owner_id: Uuid) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(DISTINCT tm.user_id) + FROM team_members tm + JOIN teams t ON tm.team_id = t.id + WHERE t.owner_id = $1", + ) + .bind(owner_id) + .fetch_one(pool) + .await + .map_err(|e| { error!(error = %e, "Failed to count used seats"); StatusCode::INTERNAL_SERVER_ERROR }) +} + // ─── Plan tier helper ───────────────────────────────────────────────────────── async fn require_business_tier(pool: &PgPool, team_id: Uuid) -> Result<(), StatusCode> { if self_host::is_self_hosted() { return Ok(()); } - let owner_id = sqlx::query_scalar::<_, Uuid>("SELECT owner_id FROM teams WHERE id = $1") - .bind(team_id) - .fetch_one(pool) - .await - .map_err(|e| { error!(error = %e, "Failed to fetch team owner"); StatusCode::INTERNAL_SERVER_ERROR })?; + let owner_id = team_owner(pool, team_id).await?; let tier = sqlx::query_scalar::<_, String>("SELECT subscription_tier FROM users WHERE id = $1") .bind(owner_id) @@ -1196,34 +1233,11 @@ pub async fn invite_member( let role = body.role.as_deref().unwrap_or("member").to_string(); - let owner_id = sqlx::query_scalar::<_, Uuid>("SELECT owner_id FROM teams WHERE id = $1") - .bind(team_id) - .fetch_one(&pool) - .await - .map_err(|e| { error!(error = %e, "Failed to fetch team owner"); StatusCode::INTERNAL_SERVER_ERROR })?; - - let (seat_count, trial_ends_at) = sqlx::query_as::<_, (Option, Option>)>( - "SELECT seat_count, trial_ends_at FROM users WHERE id = $1", - ) - .bind(owner_id) - .fetch_one(&pool) - .await - .map_err(|e| { error!(error = %e, "Failed to fetch seat count"); StatusCode::INTERNAL_SERVER_ERROR })?; - - if let Some(seats) = seat_count { - let effective_cap = if trial_ends_at.is_some() { seats.min(10) } else { seats }; - let used = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(DISTINCT tm.user_id) - FROM team_members tm - JOIN teams t ON tm.team_id = t.id - WHERE t.owner_id = $1", - ) - .bind(owner_id) - .fetch_one(&pool) - .await - .map_err(|e| { error!(error = %e, "Failed to count used seats"); StatusCode::INTERNAL_SERVER_ERROR })?; + let owner_id = team_owner(&pool, team_id).await?; - if used >= effective_cap as i64 { + if let Some(effective_cap) = owner_seat_cap(&pool, owner_id).await? { + let used = owner_seats_used(&pool, owner_id).await?; + if used >= effective_cap { warn!(owner_id = %owner_id, effective_cap, used, "Seat limit reached on invite"); return Err(StatusCode::PAYMENT_REQUIRED); } diff --git a/src/team_join_grants.rs b/src/team_join_grants.rs new file mode 100644 index 0000000..345ca48 --- /dev/null +++ b/src/team_join_grants.rs @@ -0,0 +1,294 @@ +//! Team join grants: server-side, revocable, expiring, multi-use objects that +//! admit a redeemer into a team as a member. +//! +//! Why a server-side object and not a self-contained token: a team vault is +//! end-to-end encrypted and its key is wrapped per member with X25519, so a +//! link can never carry vault *access* — only *membership*, with the key +//! following separately once an online key-holder wraps it. A self-contained +//! token would also be unrevocable, and a revoked-in-DB grant that still +//! authorised has bitten this project before. +//! +//! Resolution here is deliberately kind-specific: every lookup is scoped to +//! `team_join_grants` AND to a caller-supplied grant id. There is no +//! "find any grant by secret" helper, and none is shared with +//! [`crate::session_grants`] — a code minted for one purpose being redeemable +//! on another path is exactly the bug that produced that rule. + +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use sqlx::PgPool; +use uuid::Uuid; + +// Only the pure sha256 of a secret is shared with session grants. Sharing a +// hash function is not sharing a resolver: nothing in this module can reach a +// `terminal_session_grants` row, and nothing there can reach one of ours. +use crate::session_grants::hash_secret; + +/// Ceiling on a grant's lifetime. A link is unattended credential material, so +/// it expires whether or not the creator remembers to revoke it. +pub const MAX_TTL_SECS: i64 = 30 * 24 * 3600; +pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 3600; +pub const MAX_USES_CEILING: i32 = 500; + +/// Builtin roles a grant may confer. `owner` is absent on purpose: a link that +/// mints owners is a privilege-escalation primitive, and the creator gate +/// (PERM_INVITE_MEMBERS) is held by managers who are not owners themselves. +pub const GRANTABLE_ROLES: &[&str] = &["manager", "editor", "member", "connect-only"]; + +pub fn is_grantable_role(role: &str) -> bool { + GRANTABLE_ROLES.contains(&role) +} + +/// 256 bits of URL-safe randomness. Longer than the session-grant token +/// because this one travels in a shareable link with a multi-day life. +pub fn generate_secret() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Clamp a requested TTL into `[60, MAX_TTL_SECS]`, defaulting when absent. +pub fn clamp_ttl(requested: Option) -> Duration { + let secs = requested + .unwrap_or(DEFAULT_TTL_SECS) + .clamp(60, MAX_TTL_SECS); + Duration::seconds(secs) +} + +/// Clamp a requested use count into `[1, MAX_USES_CEILING]`, defaulting to 1. +pub fn clamp_max_uses(requested: Option) -> i32 { + requested.unwrap_or(1).clamp(1, MAX_USES_CEILING) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct GrantRow { + pub id: Uuid, + pub role: String, + pub max_uses: i32, + pub uses: i32, + pub expires_at: DateTime, + pub created_by: Uuid, +} + +pub async fn create( + pool: &PgPool, + team_id: Uuid, + role: &str, + max_uses: i32, + ttl: Duration, + created_by: Uuid, +) -> Result<(GrantRow, String), sqlx::Error> { + let secret = generate_secret(); + let expires_at = Utc::now() + ttl; + + let (id, created_at_role): (Uuid, String) = sqlx::query_as( + "INSERT INTO team_join_grants \ + (team_id, secret_hash, role, max_uses, expires_at, created_by) \ + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, role", + ) + .bind(team_id) + .bind(hash_secret(&secret)) + .bind(role) + .bind(max_uses) + .bind(expires_at) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(( + GrantRow { + id, + role: created_at_role, + max_uses, + uses: 0, + expires_at, + created_by, + }, + secret, + )) +} + +/// Live grants only: revoked and expired rows are history, not offers. +pub async fn list_live(pool: &PgPool, team_id: Uuid) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (Uuid, String, i32, i32, DateTime, Uuid)>( + "SELECT id, role, max_uses, uses, expires_at, created_by \ + FROM team_join_grants \ + WHERE team_id = $1 AND revoked_at IS NULL AND expires_at > now() \ + ORDER BY created_at DESC", + ) + .bind(team_id) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(id, role, max_uses, uses, expires_at, created_by)| GrantRow { + id, + role, + max_uses, + uses, + expires_at, + created_by, + }) + .collect()) +} + +/// Returns true when this call was the one that revoked it. Scoped to +/// `team_id` so a grant id alone can never be revoked from another team. +pub async fn revoke(pool: &PgPool, team_id: Uuid, grant_id: Uuid) -> Result { + let result = sqlx::query( + "UPDATE team_join_grants SET revoked_at = now() \ + WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", + ) + .bind(grant_id) + .bind(team_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// What a grant looks like to someone holding its secret, before they commit +/// to joining. Carries no member list, no key material and no account_id. +#[derive(Debug)] +pub struct GrantPreview { + pub team_name: String, + pub role: String, + pub inviter_handle: Option, +} + +/// Why the live path refused. Kept distinct from "no such grant" so a holder +/// of a real secret gets an honest reason; a non-holder cannot reach these at +/// all, since every variant requires the secret hash to have matched. +#[derive(Debug, PartialEq, Eq)] +pub enum GrantRejection { + /// Wrong id, wrong secret, or both. Deliberately indistinguishable. + NotFound, + Revoked, + Expired, + Exhausted, +} + +/// Read-only resolution for preview. Revocation and expiry are re-checked here +/// and again at redemption inside the consuming transaction — nothing caches a +/// resolved grant between the two. +pub async fn preview( + pool: &PgPool, + grant_id: Uuid, + presented: &str, +) -> Result { + let row = sqlx::query_as::<_, (String, String, Option, Option>, DateTime, i32, i32)>( + "SELECT t.name, g.role, u.handle, g.revoked_at, g.expires_at, g.uses, g.max_uses \ + FROM team_join_grants g \ + JOIN teams t ON t.id = g.team_id \ + LEFT JOIN users u ON u.id = g.created_by \ + WHERE g.id = $1 AND g.secret_hash = $2", + ) + .bind(grant_id) + .bind(hash_secret(presented)) + .fetch_optional(pool) + .await + .map_err(|_| GrantRejection::NotFound)? + .ok_or(GrantRejection::NotFound)?; + + let (team_name, role, inviter_handle, revoked_at, expires_at, uses, max_uses) = row; + + if revoked_at.is_some() { + return Err(GrantRejection::Revoked); + } + if expires_at <= Utc::now() { + return Err(GrantRejection::Expired); + } + if uses >= max_uses { + return Err(GrantRejection::Exhausted); + } + + Ok(GrantPreview { + team_name, + role, + inviter_handle, + }) +} + +/// A grant that has been locked for redemption. `already_member` decides +/// whether the caller consumes a use or takes the no-op path. +pub struct LockedGrant { + pub team_id: Uuid, + pub team_name: String, + pub role: String, + pub created_by: Uuid, + pub already_member: bool, +} + +/// Lock the grant row and validate it, inside the caller's transaction. +/// +/// `SELECT ... FOR UPDATE` is what makes the last use safe: two redemptions of +/// the same grant serialize on this lock, and the loser re-reads the row after +/// the winner commits, so it sees `uses = max_uses` and is refused. Reading +/// without the lock would let both pass the check and both consume. +pub async fn lock_for_redemption( + tx: &mut sqlx::PgConnection, + grant_id: Uuid, + presented: &str, + redeemer: Uuid, +) -> Result { + let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>, DateTime, i32, i32, bool)>( + "SELECT g.team_id, t.name, g.role, g.created_by, g.revoked_at, g.expires_at, g.uses, g.max_uses, \ + EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = g.team_id AND m.user_id = $3) \ + FROM team_join_grants g \ + JOIN teams t ON t.id = g.team_id \ + WHERE g.id = $1 AND g.secret_hash = $2 \ + FOR UPDATE OF g", + ) + .bind(grant_id) + .bind(hash_secret(presented)) + .bind(redeemer) + .fetch_optional(&mut *tx) + .await + .map_err(|_| GrantRejection::NotFound)? + .ok_or(GrantRejection::NotFound)?; + + let (team_id, team_name, role, created_by, revoked_at, expires_at, uses, max_uses, already_member) = + row; + + // Checked on the live path, every time, against the freshly locked row — + // not against anything a resolver or cache decided earlier. + if revoked_at.is_some() { + return Err(GrantRejection::Revoked); + } + if expires_at <= Utc::now() { + return Err(GrantRejection::Expired); + } + // An existing member consumes nothing, so exhaustion does not apply to + // them: their redemption is a no-op success, not an offer being taken. + if !already_member && uses >= max_uses { + return Err(GrantRejection::Exhausted); + } + + Ok(LockedGrant { + team_id, + team_name, + role, + created_by, + already_member, + }) +} + +/// Consume one use of a locked grant. The conditions are re-stated in the +/// UPDATE itself so validity and consumption are one statement: there is no +/// window in which a grant is judged good and then incremented separately. +pub async fn consume_use( + tx: &mut sqlx::PgConnection, + grant_id: Uuid, +) -> Result { + let result = sqlx::query( + "UPDATE team_join_grants SET uses = uses + 1 \ + WHERE id = $1 AND revoked_at IS NULL AND expires_at > now() AND uses < max_uses", + ) + .bind(grant_id) + .execute(&mut *tx) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/src/test_support.rs b/src/test_support.rs index 0a33b08..5704964 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -145,6 +145,46 @@ pub async fn seed_team(pool: &PgPool, owner: Uuid) -> Uuid { id } +/// Seed a team's builtin roles exactly as `create_team` does. `seed_team` +/// writes only the `teams` row, so any test whose code path assigns a builtin +/// role (invitation acceptance, grant redemption) needs these rows present. +pub async fn seed_builtin_roles(pool: &PgPool, team: Uuid) { + for (name, permissions, position) in crate::permissions::BUILTIN_ROLES { + sqlx::query( + "INSERT INTO team_roles (team_id, name, permissions, is_builtin, position) + VALUES ($1, $2, $3, TRUE, $4) ON CONFLICT DO NOTHING", + ) + .bind(team) + .bind(*name) + .bind(*permissions) + .bind(*position) + .execute(pool) + .await + .expect("seed builtin roles"); + } +} + +/// A team the way `create_team` leaves it: builtin roles seeded, the owner a +/// member, and the owner holding the builtin `owner` role. `seed_team` alone +/// gives none of that, so any test whose code path reads permissions or +/// assigns a builtin role needs this instead. +pub async fn seed_team_with_roles(pool: &PgPool, owner: Uuid) -> Uuid { + let team = seed_team(pool, owner).await; + seed_builtin_roles(pool, team).await; + add_member(pool, team, owner).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 name = 'owner' AND is_builtin = TRUE", + ) + .bind(team) + .bind(owner) + .execute(pool) + .await + .expect("assign owner role"); + team +} + /// Insert a role with the given permission bits and return its id. pub async fn seed_role(pool: &PgPool, team: Uuid, name: &str, permissions: i64) -> Uuid { let id = Uuid::new_v4(); From 72e8889fc1459ae71130271bff172730af6bb303 Mon Sep 17 00:00:00 2001 From: kipavy Date: Tue, 25 Aug 2026 16:25:48 +0000 Subject: [PATCH 2/3] test(last_seen): stop pinning a whole-table count this test cannot own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activity_counts_bucket_users_by_recency` asserted a whole-table delta on `never_seen`. LAST_SEEN_LOCK serializes everything that writes `last_seen_on`, but every `seed_user` in the suite inserts a row with the column NULL without holding that lock, and each one lands in that bucket — so any test seeding a user concurrently inflated the delta. Latent since the count shipped; the join-grant tests seed enough users to make it fire. The two active-window deltas stay on `activity_counts` (a freshly seeded user is NULL, so it moves neither); the never-seen assertion now applies the same predicate to exactly the four users this test creates. --- src/last_seen.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/last_seen.rs b/src/last_seen.rs index ae9e3e5..9e67852 100644 --- a/src/last_seen.rs +++ b/src/last_seen.rs @@ -111,7 +111,7 @@ mod tests { set_last_seen_days_ago(&pool, last_week, 10).await; let long_ago = seed_user(&pool).await; set_last_seen_days_ago(&pool, long_ago, 400).await; - let _unseen = seed_user(&pool).await; // last_seen_on stays NULL + let unseen = seed_user(&pool).await; // last_seen_on stays NULL let after = activity_counts(&pool).await.expect("counts"); @@ -121,9 +121,25 @@ mod tests { 2, "today + 10-days-ago are 30d-active; 400-days-ago is not" ); + + // `never_seen` is the one bucket whose whole-table delta this test + // cannot own. LAST_SEEN_LOCK serializes everything that *writes* the + // column, but every `seed_user` anywhere in the suite inserts a row + // with `last_seen_on` NULL without holding it, and each one lands in + // this count. Assert the same predicate over exactly the four users + // seeded here, which says what the bucket means without depending on + // how many users the rest of the suite happens to create meanwhile. + let never_seen_among_ours = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FILTER (WHERE last_seen_on IS NULL) FROM users \ + WHERE id = ANY($1) AND deleted_at IS NULL", + ) + .bind(vec![today, last_week, long_ago, unseen]) + .fetch_one(&pool) + .await + .expect("scoped never-seen count"); + assert_eq!( - after.never_seen - before.never_seen, - 1, + never_seen_among_ours, 1, "the unstamped user counts as never seen, the 400-day one does not" ); } From 8bc930f4bc6c974d8f8d0f58d5d76d6ede8f0184 Mon Sep 17 00:00:00 2001 From: kipavy Date: Tue, 25 Aug 2026 16:29:59 +0000 Subject: [PATCH 3/3] chore(clippy): allow result_large_err on the two typed-error handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust stable reached 1.98 on the CI runners, whose clippy flags `create_checkout` and `claim_handle` under `result_large_err`. Both are pre-existing and unrelated to this branch; they fail `main` too, and only went unnoticed because no run has landed since the toolchain moved. `Response` in the Err slot is deliberate — these two answer with a typed JSON error body rather than a bare status, and `email_not_verified_response` exists so a client can tell that refusal from every other 403. The lint's suggested fix, boxing the response, would cost the `IntoResponse` impl axum requires of a handler's error type, so the allow is scoped to these two functions. --- src/routes/billing.rs | 4 ++++ src/routes/users.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/routes/billing.rs b/src/routes/billing.rs index 3c90f80..77dd21e 100644 --- a/src/routes/billing.rs +++ b/src/routes/billing.rs @@ -213,6 +213,10 @@ async fn fetch_current_subscription_id(pool: &PgPool, user_id: Uuid) -> Result, axum::Extension(auth): axum::Extension, diff --git a/src/routes/users.rs b/src/routes/users.rs index f05f921..635e852 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -124,6 +124,10 @@ pub(crate) async fn claim_handle_inner( Ok(()) } +// `Response` in the Err slot is the point: these handlers answer with a typed +// JSON error body, not a bare status. Boxing it, as the lint suggests, would +// cost the `IntoResponse` impl axum requires of a handler's error type. +#[allow(clippy::result_large_err)] pub async fn claim_handle( State(pool): State, Extension(auth): Extension,