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.
131 lines
4.4 KiB
Rust
131 lines
4.4 KiB
Rust
//! Inbound webhook triggers (Triggered deploy mode). A team gets a token whose
|
|
//! public URL, when POSTed, enqueues the team's topology run — reusing the same
|
|
//! durable-run path as the scheduler and the UI "Run team" button.
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{ApiError, AppState, Authed};
|
|
|
|
/// `POST /api/teams/{id}/webhooks` — mint a webhook token for the team.
|
|
pub async fn create_webhook(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(team_id): Path<Uuid>,
|
|
body: Option<Json<CreateBody>>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
// Scope check: the team must belong to the caller's workspace.
|
|
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
|
|
let default_task = body.map(|b| b.0.task).unwrap_or_default();
|
|
let id = Uuid::now_v7();
|
|
let token = Uuid::now_v7();
|
|
sqlx::query("INSERT INTO webhook_tokens (id, workspace_id, team_id, token, task) VALUES ($1, $2, $3, $4, $5)")
|
|
.bind(id)
|
|
.bind(user.workspace_id.as_uuid())
|
|
.bind(team_id)
|
|
.bind(token)
|
|
.bind(&default_task)
|
|
.execute(&state.pool)
|
|
.await
|
|
.map_err(|_| ApiError::Internal)?;
|
|
Ok(Json(
|
|
json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") }),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct CreateBody {
|
|
#[serde(default)]
|
|
pub task: String,
|
|
}
|
|
|
|
/// `GET /api/teams/{id}/webhooks` — list the team's webhook tokens.
|
|
pub async fn list_webhooks(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(team_id): Path<Uuid>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
|
|
let rows = sqlx::query_as::<_, (Uuid,)>(
|
|
"SELECT token FROM webhook_tokens WHERE team_id = $1 ORDER BY created_at DESC",
|
|
)
|
|
.bind(team_id)
|
|
.fetch_all(&state.pool)
|
|
.await
|
|
.map_err(|_| ApiError::Internal)?;
|
|
let hooks: Vec<Value> = rows
|
|
.into_iter()
|
|
.map(|(t,)| json!({ "token": t.to_string(), "url": format!("/api/hooks/{t}") }))
|
|
.collect();
|
|
Ok(Json(json!({ "webhooks": hooks })))
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct HookBody {
|
|
#[serde(default)]
|
|
task: String,
|
|
}
|
|
|
|
/// `POST /api/hooks/{token}` — PUBLIC. Fire the bound team's topology run. Body
|
|
/// `{task?}` overrides the team's default task. Mirrors the Slack/Stripe inbound
|
|
/// pattern (no session auth; the unguessable token is the credential).
|
|
pub async fn trigger_hook(
|
|
State(state): State<AppState>,
|
|
Path(token): Path<Uuid>,
|
|
body: axum::body::Bytes,
|
|
) -> StatusCode {
|
|
let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
|
|
"SELECT workspace_id, team_id, task FROM webhook_tokens WHERE token = $1",
|
|
)
|
|
.bind(token)
|
|
.fetch_optional(&state.pool)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some((ws_id, team_id, default_task)) = row else {
|
|
return StatusCode::NOT_FOUND;
|
|
};
|
|
let ws = cm_domain::WorkspaceId::from(ws_id);
|
|
let Ok(team) = cm_db::repo::teams::get_team(&state.pool, team_id, ws).await else {
|
|
return StatusCode::NOT_FOUND;
|
|
};
|
|
let task = serde_json::from_slice::<HookBody>(&body)
|
|
.ok()
|
|
.map(|b| b.task)
|
|
.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
|
|
.is_err()
|
|
{
|
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
|
}
|
|
let _ = sqlx::query("UPDATE webhook_tokens SET last_fired_at = now() WHERE token = $1")
|
|
.bind(token)
|
|
.execute(&state.pool)
|
|
.await;
|
|
let _ = cm_db::repo::audit::append(
|
|
&state.pool,
|
|
ws,
|
|
cm_db::repo::audit::Actor::System,
|
|
"topology.webhook_triggered",
|
|
"webhook",
|
|
&token.to_string(),
|
|
json!({ "team_id": team_id, "run_id": run_id }),
|
|
)
|
|
.await;
|
|
StatusCode::ACCEPTED
|
|
}
|