Public Access
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:
+2
-2
@@ -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,7 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Config {
|
||||
|
||||
+8
-1
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user