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
31 changes: 31 additions & 0 deletions migrations/038_team_join_grants.sql
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 19 additions & 3 deletions src/last_seen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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"
);
}
Expand Down
33 changes: 31 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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;
Expand Down Expand Up @@ -184,6 +186,10 @@ async fn main() {
SessionCodeRateLimiter(RateLimiter::<uuid::Uuid>::new(30, Duration::from_secs(3600)));
let redeem_limiter =
RedeemRateLimiter(RateLimiter::<uuid::Uuid>::new(20, Duration::from_secs(3600)));
let grant_mint_limiter =
GrantMintRateLimiter(RateLimiter::<uuid::Uuid>::new(30, Duration::from_secs(3600)));
let grant_redeem_limiter =
GrantRedeemRateLimiter(RateLimiter::<uuid::Uuid>::new(20, Duration::from_secs(3600)));

// Lemon Squeezy live metrics cache (background refresh every 5 min).
let ls_cache = lemonsqueezy::LsCache::default();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()))
Expand Down
11 changes: 11 additions & 0 deletions src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ pub struct SessionCodeRateLimiter(pub RateLimiter<Uuid>);
#[derive(Clone)]
pub struct RedeemRateLimiter(pub RateLimiter<Uuid>);

/// 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<Uuid>);

/// 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<Uuid>);

/// Register endpoint: N registrations/day per IP.
pub async fn register_rate_limit(
axum::Extension(RegisterRateLimiter(limiter)): axum::Extension<RegisterRateLimiter>,
Expand Down
4 changes: 4 additions & 0 deletions src/routes/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ async fn fetch_current_subscription_id(pool: &PgPool, user_id: Uuid) -> Result<S
row.0.ok_or(StatusCode::NOT_FOUND)
}

// `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 create_checkout(
State(pool): State<PgPool>,
axum::Extension(auth): axum::Extension<AuthUser>,
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading