diff --git a/src/cli_main.rs b/src/cli_main.rs index 99f3a7b..9b65e30 100644 --- a/src/cli_main.rs +++ b/src/cli_main.rs @@ -65,7 +65,7 @@ fn main() { if users.is_empty() { println!("No users."); } else { - println!("{:<20} {}", "Username", "Created"); + println!("{:<20} Created", "Username"); println!("{}", "-".repeat(40)); for (u, c) in users { println!("{:<20} {}", u, c); @@ -115,7 +115,7 @@ fn main() { if keys.is_empty() { println!("No API keys."); } else { - println!("{:<20} {}", "Label", "Created"); + println!("{:<20} Created", "Label"); println!("{}", "-".repeat(40)); for (l, c) in keys { println!("{:<20} {}", l, c); diff --git a/src/config.rs b/src/config.rs index 72aef85..a31e702 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,6 @@ use serde::Deserialize; use std::fs; -#[allow(dead_code)] #[derive(Debug, Deserialize, Clone)] #[allow(dead_code)] pub struct Config { diff --git a/src/models.rs b/src/models.rs index 108d840..6d3ba1c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -27,7 +27,6 @@ pub struct SubmitAction { pub run_as: Option, } -#[allow(dead_code)] #[derive(Debug, Serialize)] #[allow(dead_code)] pub struct ActionResult { @@ -36,6 +35,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,