From bfb10b9ab2d98832d9778685b207d75fc56cf69c Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 13 Aug 2026 03:03:19 -0700 Subject: [PATCH] feat: token-authenticated approval API + structured webhook payload Adds POST /api/actions/:id/approve and /reject so a notifier (desktop popup, chat bot, phone) can resolve an action without driving the web UI or holding a login session. Auth is the per-action approve_token already minted at submit time: a UUIDv4 scoped to exactly one action and delivered only in the approval notification. The agent API key is deliberately NOT accepted on these routes -- the agent submits actions, so letting it approve them would defeat the human-in-the-loop guarantee. The token may be supplied in the JSON body or as ?token=, matching the existing approve link. Both endpoints return 401 for an unknown id/token and 409 (with the current status) if the action is no longer pending, so a notifier can tell "someone else already handled this" from "this failed". The notification webhook gains a structured "action" object alongside the existing "text" field, which is unchanged so current consumers keep working. Previously the id, command and approve URL were only available by regexing the human-readable blob. Also collapses three copies of the actions SELECT and its row mapping onto ACTION_COLUMNS/row_to_action, and extracts the execute and reject-chain logic into helpers now shared by the web and API handlers. Those helpers no longer .unwrap() DB errors while holding the connection mutex, which would poison it for the life of the process. --- src/models.rs | 8 ++ src/routes.rs | 357 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 256 insertions(+), 109 deletions(-) diff --git a/src/models.rs b/src/models.rs index 108d840..9263e8b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -36,6 +36,14 @@ pub struct ActionResult { pub result: Option, } +/// Body for the approval API. The `approve_token` is the per-action secret +/// minted at submit time and delivered in the approval notification. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct ApproveRequest { + pub approve_token: String, +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct LoginForm { diff --git a/src/routes.rs b/src/routes.rs index abca695..60f31f4 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -28,6 +28,11 @@ pub fn router(state: AppState) -> Router { // Agent API .route("/api/actions", post(submit_action)) .route("/api/actions/:id/result", get(get_result)) + // Approval API. Authenticated by the per-action approve_token, NOT by + // the agent API key -- an agent must not be able to approve its own + // actions, or the human-in-the-loop guarantee is worthless. + .route("/api/actions/:id/approve", post(api_approve_action)) + .route("/api/actions/:id/reject", post(api_reject_action)) .with_state(state) } @@ -99,6 +104,113 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool { }) } +/// Column list for `actions`, in the order `row_to_action` expects. +const ACTION_COLUMNS: &str = + "id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at"; + +fn row_to_action(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Action { + id: row.get(0)?, + chain_id: row.get(1)?, + sequence: row.get(2)?, + description: row.get(3)?, + command: row.get(4)?, + run_as: row.get(5)?, + status: row.get(6)?, + result: row.get(7)?, + approve_token: row.get(8)?, + submitted_at: row.get(9)?, + resolved_at: row.get(10)?, + }) +} + +fn fetch_action(state: &AppState, id: &str) -> Option { + let db = state.db.lock().ok()?; + db.query_row( + &format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1"), + [id], + row_to_action, + ) + .ok() +} + +/// Look up an action by id *and* its approve_token. Both must match, so a +/// bare action id is not enough to act on it. +fn fetch_action_with_token(state: &AppState, id: &str, token: &str) -> Option { + let db = state.db.lock().ok()?; + db.query_row( + &format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1 AND approve_token = ?2"), + [id, token], + row_to_action, + ) + .ok() +} + +/// Execute an approved action's command and record the outcome. +/// Returns the resulting status ("executed" or "failed") and the captured output. +fn execute_action(state: &AppState, action: &Action) -> (String, String) { + let output = match action.run_as.as_deref() { + Some("root") => Command::new("sudo") + .args(["-n", "sh", "-c", &action.command]) + .output(), + Some(user) => Command::new("sudo") + .args(["-n", "-u", user, "sh", "-c", &action.command]) + .output(), + None => Command::new("sh").args(["-c", &action.command]).output(), + }; + + let (status, result) = match output { + Ok(out) => { + let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout)); + let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr)); + let combined = if stderr.is_empty() { + stdout + } else { + format!("{}\nSTDERR:\n{}", stdout, stderr) + }; + if out.status.success() { + ("executed", combined) + } else { + ("failed", combined) + } + } + Err(e) => ("failed", e.to_string()), + }; + + if let Ok(db) = state.db.lock() { + let _ = db.execute( + "UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3", + [status, &result, &action.id], + ); + } + + (status.to_string(), result) +} + +/// Mark an action rejected, along with every still-pending action in its chain. +fn reject_action_and_chain(state: &AppState, id: &str) { + let Ok(db) = state.db.lock() else { return }; + + let chain_id: Option = db + .query_row("SELECT chain_id FROM actions WHERE id = ?1", [id], |row| { + row.get(0) + }) + .ok() + .flatten(); + + let _ = db.execute( + "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1", + [id], + ); + + if let Some(cid) = chain_id { + let _ = db.execute( + "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'", + [&cid], + ); + } +} + // ── Web UI ──────────────────────────────────────────────────────── async fn login_page() -> Html { @@ -190,28 +302,15 @@ async fn dashboard(State(state): State, jar: CookieJar) -> impl IntoRe let actions: Vec = { let db = state.db.lock().unwrap(); - let mut stmt = db.prepare( - "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at - FROM actions ORDER BY submitted_at DESC LIMIT 50" - ).unwrap(); - stmt.query_map([], |row| { - Ok(Action { - id: row.get(0)?, - chain_id: row.get(1)?, - sequence: row.get(2)?, - description: row.get(3)?, - command: row.get(4)?, - run_as: row.get(5)?, - status: row.get(6)?, - result: row.get(7)?, - approve_token: row.get(8)?, - submitted_at: row.get(9)?, - resolved_at: row.get(10)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() + let mut stmt = db + .prepare(&format!( + "SELECT {ACTION_COLUMNS} FROM actions ORDER BY submitted_at DESC LIMIT 50" + )) + .unwrap(); + stmt.query_map([], row_to_action) + .unwrap() + .filter_map(|r| r.ok()) + .collect() }; let rows: String = actions @@ -308,20 +407,7 @@ async fn action_detail( ) -> impl IntoResponse { // Allow approve via token link (no login required) if let Some(token) = q.token { - let action: Option = { - let db = state.db.lock().unwrap(); - db.query_row( - "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1 AND approve_token = ?2", - [&id, &token], - |row| Ok(Action { - id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?, - description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?, - status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?, - submitted_at: row.get(9)?, resolved_at: row.get(10)?, - }), - ).ok() - }; - if let Some(a) = action { + if let Some(a) = fetch_action_with_token(&state, &id, &token) { if a.status == "pending" { let content = format!( r#" @@ -364,56 +450,9 @@ async fn approve_action( return Redirect::to("/login").into_response(); } - let action: Option = { - let db = state.db.lock().unwrap(); - db.query_row( - "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1", - [&id], - |row| Ok(Action { - id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?, - description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?, - status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?, - submitted_at: row.get(9)?, resolved_at: row.get(10)?, - }), - ).ok() - }; - - if let Some(a) = action { + if let Some(a) = fetch_action(&state, &id) { if a.status == "pending" { - // Build command with run_as support - let output = match a.run_as.as_deref() { - Some("root") => Command::new("sudo") - .args(["-n", "sh", "-c", &a.command]) - .output(), - Some(user) => Command::new("sudo") - .args(["-n", "-u", user, "sh", "-c", &a.command]) - .output(), - None => Command::new("sh").args(["-c", &a.command]).output(), - }; - - let (status, result) = match output { - Ok(out) => { - let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout)); - let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr)); - let combined = if stderr.is_empty() { - stdout - } else { - format!("{}\nSTDERR:\n{}", stdout, stderr) - }; - if out.status.success() { - ("executed", combined) - } else { - ("failed", combined) - } - } - Err(e) => ("failed", e.to_string()), - }; - - let db = state.db.lock().unwrap(); - db.execute( - "UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3", - [status, &result, &a.id], - ).unwrap(); + execute_action(&state, &a); } } @@ -429,29 +468,7 @@ async fn reject_action( return Redirect::to("/login").into_response(); } - // Reject this action and all pending actions in same chain - let chain_id: Option = { - let db = state.db.lock().unwrap(); - db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| { - row.get(0) - }) - .ok() - .flatten() - }; - - let db = state.db.lock().unwrap(); - db.execute( - "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1", - [&id], - ) - .unwrap(); - - if let Some(cid) = chain_id { - db.execute( - "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'", - [&cid], - ).unwrap(); - } + reject_action_and_chain(&state, &id); Redirect::to("/").into_response() } @@ -495,12 +512,29 @@ async fn submit_action( let description = body.description.clone(); let command = body.command.clone(); let url = approve_url.clone(); + let action_id = id.clone(); + let run_as = body.run_as.clone(); + let approve_token_out = approve_token.clone(); + let api_base = state.config.base_url.clone(); tokio::spawn(async move { let client = reqwest::Client::new(); let mut req = client.post(&webhook) .json(&serde_json::json!({ + // `text` is kept verbatim so existing consumers keep working. "text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url), - "mode": "now" + "mode": "now", + // Structured form, so a notifier can render buttons and call + // the approval API without scraping the text blob above. + "action": { + "id": action_id, + "description": description, + "command": command, + "run_as": run_as, + "approve_url": url, + "approve_token": approve_token_out, + "api_approve": format!("{}/api/actions/{}/approve", api_base, action_id), + "api_reject": format!("{}/api/actions/{}/reject", api_base, action_id), + } })); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {}", t)); @@ -520,6 +554,111 @@ async fn submit_action( .into_response() } +// ── Approval API ────────────────────────────────────────────────── +// +// These endpoints exist so a notifier (desktop popup, chat bot, phone) can +// resolve an action without driving the web UI or holding a login session. +// +// Auth is the per-action `approve_token`: a UUIDv4 minted at submit time, +// scoped to exactly one action, and delivered only in the approval +// notification. The agent API key is deliberately NOT accepted here -- the +// agent submits actions, so letting it approve them too would defeat the +// entire point of the service. + +/// Resolve `token` from either the JSON body or the `?token=` query string, +/// so callers can use whichever is convenient. Body wins if both are present. +fn approve_token_from(body: &Option>, q: &ApproveQuery) -> Option { + body.as_ref() + .map(|b| b.approve_token.clone()) + .or_else(|| q.token.clone()) +} + +async fn api_approve_action( + State(state): State, + Path(id): Path, + Query(q): Query, + body: Option>, +) -> impl IntoResponse { + let Some(token) = approve_token_from(&body, &q) else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "missing approve_token"})), + ) + .into_response(); + }; + + let Some(action) = fetch_action_with_token(&state, &id, &token) else { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})), + ) + .into_response(); + }; + + if action.status != "pending" { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "action is no longer pending", + "id": action.id, + "status": action.status, + })), + ) + .into_response(); + } + + let (status, result) = execute_action(&state, &action); + + ( + StatusCode::OK, + Json(serde_json::json!({"id": action.id, "status": status, "result": result})), + ) + .into_response() +} + +async fn api_reject_action( + State(state): State, + Path(id): Path, + Query(q): Query, + body: Option>, +) -> impl IntoResponse { + let Some(token) = approve_token_from(&body, &q) else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "missing approve_token"})), + ) + .into_response(); + }; + + let Some(action) = fetch_action_with_token(&state, &id, &token) else { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})), + ) + .into_response(); + }; + + if action.status != "pending" { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "action is no longer pending", + "id": action.id, + "status": action.status, + })), + ) + .into_response(); + } + + reject_action_and_chain(&state, &action.id); + + ( + StatusCode::OK, + Json(serde_json::json!({"id": action.id, "status": "rejected"})), + ) + .into_response() +} + async fn get_result( State(state): State, headers: HeaderMap,