Merge pull request 'feat: token-authenticated approval API + structured webhook payload' (!4) from feat/approve-api into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
oc
2026-08-21 10:12:10 -07:00
4 changed files with 258 additions and 113 deletions
+2 -2
View File
@@ -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);
-1
View File
@@ -1,7 +1,6 @@
use serde::Deserialize;
use std::fs;
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Config {
+8 -1
View File
@@ -27,7 +27,6 @@ pub struct SubmitAction {
pub run_as: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct ActionResult {
@@ -36,6 +35,14 @@ pub struct ActionResult {
pub result: Option<String>,
}
/// 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 {
+248 -109
View File
@@ -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<Action> {
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<Action> {
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<Action> {
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<String> = 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<String> {
@@ -190,28 +302,15 @@ async fn dashboard(State(state): State<AppState>, jar: CookieJar) -> impl IntoRe
let actions: Vec<Action> = {
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<Action> = {
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<Action> = {
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<String> = {
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<Json<ApproveRequest>>, q: &ApproveQuery) -> Option<String> {
body.as_ref()
.map(|b| b.approve_token.clone())
.or_else(|| q.token.clone())
}
async fn api_approve_action(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> 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<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> 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<AppState>,
headers: HeaderMap,