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
26 changes: 26 additions & 0 deletions migrations/037_terminal_session_grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
CREATE TABLE terminal_session_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES terminal_sessions(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('legacy_token','short_code','guest')),
secret_hash BYTEA NOT NULL,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redeemed_by UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX idx_tsg_secret ON terminal_session_grants(secret_hash);

-- One live short code per session. Regeneration revokes the old row inside the
-- same transaction, so this index is what makes the swap race-safe.
CREATE UNIQUE INDEX idx_tsg_one_live_code
ON terminal_session_grants(session_id)
WHERE kind = 'short_code' AND revoked_at IS NULL;

CREATE INDEX idx_tsg_session ON terminal_session_grants(session_id);

INSERT INTO terminal_session_grants (session_id, kind, secret_hash, created_by)
SELECT id, 'legacy_token', sha256(convert_to(invite_token, 'UTF8')), host_user_id
FROM terminal_sessions
WHERE invite_token IS NOT NULL AND ended_at IS NULL;
92 changes: 92 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,97 @@ pub async fn create_pool() -> PgPool {
});
info!("Migrations applied successfully");

reconcile_legacy_grants(&pool).await;

pool
}

/// Migration 037's backfill is one-shot: a rollback-then-forward-again cycle
/// can leave live invite_link sessions with an `invite_token` but no grant,
/// since nothing reads that column outside the grants path any more. Runs
/// every boot and is idempotent — the anti-join only ever inserts missing rows.
/// ON CONFLICT DO NOTHING on idx_tsg_secret: a rolling deploy can start two
/// instances close enough together that their anti-joins both see the same
/// orphan under READ COMMITTED; the loser must not crash the boot.
async fn reconcile_legacy_grants(pool: &PgPool) {
let result = sqlx::query(
"INSERT INTO terminal_session_grants (session_id, kind, secret_hash, created_by) \
SELECT ts.id, 'legacy_token', sha256(convert_to(ts.invite_token, 'UTF8')), ts.host_user_id \
FROM terminal_sessions ts \
LEFT JOIN terminal_session_grants g \
ON g.session_id = ts.id AND g.kind = 'legacy_token' \
WHERE ts.invite_token IS NOT NULL AND ts.ended_at IS NULL AND g.id IS NULL \
ON CONFLICT (secret_hash) DO NOTHING",
)
.execute(pool)
.await;

match result {
Ok(res) => info!(
reconciled = res.rows_affected(),
"Reconciled legacy invite-token grants"
),
Err(err) => {
error!(error = %err, "Failed to reconcile legacy invite-token grants");
panic!("Failed to reconcile legacy invite-token grants");
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_pool_or_skip;
use crate::test_support::{seed_session, seed_user};
use uuid::Uuid;

#[tokio::test]
async fn reconciliation_backfills_a_grant_for_a_token_with_none() {
let pool = test_pool_or_skip!();
let host = seed_user(&pool).await;
let session = seed_session(&pool, host, "invite_link").await;
let token = format!("fake-rollback-orphan-{}", Uuid::new_v4());

sqlx::query("UPDATE terminal_sessions SET invite_token = $1 WHERE id = $2")
.bind(&token)
.bind(session)
.execute(&pool)
.await
.unwrap();

reconcile_legacy_grants(&pool).await;

assert!(
crate::session_grants::resolve_join_grant(&pool, session, &token).await,
"reconciliation must mint a resolvable grant"
);
}

#[tokio::test]
async fn reconciliation_is_idempotent() {
let pool = test_pool_or_skip!();
let host = seed_user(&pool).await;
let session = seed_session(&pool, host, "invite_link").await;
let token = format!("fake-rollback-orphan-{}", Uuid::new_v4());

sqlx::query("UPDATE terminal_sessions SET invite_token = $1 WHERE id = $2")
.bind(&token)
.bind(session)
.execute(&pool)
.await
.unwrap();

reconcile_legacy_grants(&pool).await;
reconcile_legacy_grants(&pool).await;

let count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM terminal_session_grants \
WHERE session_id = $1 AND kind = 'legacy_token'",
)
.bind(session)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1, "second run must insert nothing new");
}
}
22 changes: 20 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod permissions;
mod rate_limit;
mod routes;
mod self_host;
mod session_grants;
mod sync_notifier;
mod terminal_manager;
#[cfg(test)]
Expand All @@ -22,8 +23,8 @@ use axum::{
};
use dashmap::{DashMap, DashSet};
use rate_limit::{
InviteRateLimiter, KnockRateLimiter, RateLimiter, RegisterRateLimiter, SearchRateLimiter,
SyncRateLimiter, WaitlistRateLimiter,
InviteRateLimiter, KnockRateLimiter, RateLimiter, RedeemRateLimiter, RegisterRateLimiter,
SearchRateLimiter, SessionCodeRateLimiter, SyncRateLimiter, WaitlistRateLimiter,
};
use routes::audit::AuditClientRateLimiter;
use std::net::SocketAddr;
Expand Down Expand Up @@ -179,6 +180,10 @@ async fn main() {
SearchRateLimiter(RateLimiter::<uuid::Uuid>::new(search_rate, Duration::from_secs(60)));
let knock_limiter =
KnockRateLimiter(RateLimiter::<uuid::Uuid>::new(knock_per_hour, Duration::from_secs(3600)));
let session_code_limiter =
SessionCodeRateLimiter(RateLimiter::<uuid::Uuid>::new(30, Duration::from_secs(3600)));
let redeem_limiter =
RedeemRateLimiter(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 @@ -440,6 +445,17 @@ async fn main() {
"/v1/terminal-sessions",
post(routes::terminal::create_session),
)
// `redeem` is listed before the `:id` routes by convention, matching
// the literal `me` invitee route; axum's matchit prefers a static
// segment over a param regardless of registration order.
.route(
"/v1/terminal-sessions/redeem",
post(routes::session_codes::redeem_code),
)
.route(
"/v1/terminal-sessions/:id/code",
post(routes::session_codes::create_code),
)
.route(
"/v1/terminal-sessions/:id/my-key",
get(routes::terminal::get_my_session_key),
Expand Down Expand Up @@ -493,6 +509,8 @@ async fn main() {
.layer(Extension(sync_limiter))
.layer(Extension(search_limiter))
.layer(Extension(knock_limiter))
.layer(Extension(session_code_limiter))
.layer(Extension(redeem_limiter))
.layer(middleware::from_fn(auth::auth_middleware))
.layer(Extension(notifier.clone()))
.layer(Extension(terminal_manager.clone()))
Expand Down
56 changes: 39 additions & 17 deletions src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use std::collections::HashMap;
use std::hash::Hash;
use std::net::IpAddr;
Expand Down Expand Up @@ -104,6 +99,15 @@ pub struct SearchRateLimiter(pub RateLimiter<Uuid>);
#[derive(Clone)]
pub struct KnockRateLimiter(pub RateLimiter<Uuid>);

/// Short-code mints per host. Regenerate-spam must not become a mint oracle.
#[derive(Clone)]
pub struct SessionCodeRateLimiter(pub RateLimiter<Uuid>);

/// Code redemptions per user. Keyed by user because the endpoint is
/// authenticated, so brute force costs accounts, not just addresses.
#[derive(Clone)]
pub struct RedeemRateLimiter(pub RateLimiter<Uuid>);

/// Register endpoint: N registrations/day per IP.
pub async fn register_rate_limit(
axum::Extension(RegisterRateLimiter(limiter)): axum::Extension<RegisterRateLimiter>,
Expand All @@ -118,18 +122,41 @@ pub async fn register_rate_limit(
Ok(next.run(req).await)
}

/// Checks a per-user limiter, warning and returning 429 on exhaustion. The
/// one place this shape is expressed; middlewares and handlers alike call it.
pub async fn check_user_budget(
limiter: &RateLimiter<Uuid>,
user: Uuid,
label: &str,
) -> Result<(), StatusCode> {
if !limiter.check(user).await {
warn!(user_id = %user, limiter = label, "Rate limit exceeded");
return Err(StatusCode::TOO_MANY_REQUESTS);
}
Ok(())
}

/// Shared body for the per-user middlewares below: check the limiter keyed on
/// the authenticated caller, or reject with 429.
async fn user_keyed_limit(
limiter: &RateLimiter<Uuid>,
user: Uuid,
label: &str,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
check_user_budget(limiter, user, label).await?;
Ok(next.run(req).await)
}

/// Invite endpoint: N invitations/hour per user (auth_middleware must run first).
pub async fn invite_rate_limit(
axum::Extension(InviteRateLimiter(limiter)): axum::Extension<InviteRateLimiter>,
axum::Extension(auth): axum::Extension<crate::auth::AuthUser>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
if !limiter.check(auth.0).await {
warn!(user_id = %auth.0, "Invite rate limit exceeded");
return Err(StatusCode::TOO_MANY_REQUESTS);
}
Ok(next.run(req).await)
user_keyed_limit(&limiter, auth.0, "invite", req, next).await
}

/// Sync endpoints: N requests/hour per user (auth_middleware must run first).
Expand All @@ -139,11 +166,7 @@ pub async fn sync_rate_limit(
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
if !limiter.check(auth.0).await {
warn!(user_id = %auth.0, "Sync rate limit exceeded");
return Err(StatusCode::TOO_MANY_REQUESTS);
}
Ok(next.run(req).await)
user_keyed_limit(&limiter, auth.0, "sync", req, next).await
}

/// Auth endpoints: 10 requests/minute per IP.
Expand Down Expand Up @@ -175,4 +198,3 @@ pub async fn waitlist_rate_limit(
}
Ok(next.run(req).await)
}

1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod billing;
pub mod invitations;
pub mod meta;
pub mod presence;
pub mod session_codes;
pub mod sync;
pub mod team_sync;
pub mod team_objects;
Expand Down
Loading