cm-api: quota — enforce max_active_runs on run enqueue

Adds a Quota.max_active_runs ceiling (queued + running topology runs at
once, per workspace) to stop a single workspace flooding the shared queue.
Free tier: 10, Pro: 25, Team: 100. Enforced at every /run enqueue site:
run_org, run_company, run_team, and the webhook trigger. Webhooks return
429 rather than 402 so external callers can back off — the guard is what
stops a leaked webhook token from being weaponized into a queue flood.

A single team run also spawns a tier-tree of children, so the practical
cap grows with the topology — this counts the outer runs, not every step.
This commit is contained in:
Omar Sobh
2026-07-05 18:49:03 -07:00
parent 0c57f52502
commit d2b1f0569e
5 changed files with 39 additions and 0 deletions
+1
View File
@@ -266,6 +266,7 @@ pub async fn run_company(
Json(body): Json<RunCompanyRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_tier(
&state.pool,
+1
View File
@@ -209,6 +209,7 @@ pub async fn run_org(
Json(body): Json<RunOrgRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?;
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_tier(
&state.pool,
+1
View File
@@ -371,6 +371,7 @@ pub async fn run_team(
Json(body): Json<RunTeamRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run(
&state.pool,
+6
View File
@@ -99,6 +99,12 @@ pub async fn trigger_hook(
.filter(|t| !t.trim().is_empty())
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
.unwrap_or_else(|| "webhook trigger".to_string());
// Webhooks are unauthenticated public endpoints — the enforce_new_run
// guard is what stops a leaked token from being weaponized into a queue
// flood. 429 (not 402) so external callers can back off.
if crate::quota::enforce_new_run(&state, ws).await.is_err() {
return StatusCode::TOO_MANY_REQUESTS;
}
let run_id = Uuid::now_v7();
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
.await