//! Tool-group permission endpoints for the dashboard. //! //! Read endpoints return the full set of known groups plus //! descriptions and the per-agent assignment map (the UI never hard-codes //! the lists). Write endpoints validate, then enqueue a `PermChange` so the //! JSON file write is serialised through the FIFO rebuild worker. use axum::{ extract::{Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use problem_details::ProblemDetails; use super::{AppState, Ident, guard_agent_name, strip_container_prefix}; #[derive(Serialize, ToSchema)] pub(super) struct ToolGroupsSnapshot { /// Ordered list of all known tool-group names. Drives the column /// headers in the tool-groups table — the UI does not hard-code them. groups: Vec<&'static str>, /// Short description for each group name. Keys match `groups` entries. descriptions: std::collections::BTreeMap<&'static str, &'static str>, /// Per-agent *explicit* assignment map. Absent agents use the role /// default — the UI uses presence here to badge an agent "(default)". assignments: std::collections::BTreeMap>, /// Every agent the operator can manage (live roster ∪ explicit keys), /// sorted. The UI lists rows from this so default-perms agents always /// appear without depending on a separately-loaded container list. agents: Vec, /// Per-agent *effective* groups: the explicit entry when present, else /// the role default the harness actually applies. Drives the checkbox /// state so default agents show their real groups, not blank. effective: std::collections::BTreeMap>, } /// Every known tool-group name + description, /// plus the per-agent explicit/effective assignment maps. #[utoipa::path( get, path = "/api/tool-groups", responses( (status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot), (status = 500, description = "tool-groups file unreadable"), ), tag = "permissions" )] pub(super) async fn get_tool_groups( State(state): State, ) -> Result, ProblemDetails> { let groups = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| <&str>::from(*g)) .collect(); let descriptions = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| (<&str>::from(*g), g.description())) .collect(); let assignments = crate::tool_groups::read().map_err(|e| unreadable(&e))?; let roster = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name); let (agents, effective) = roster_and_effective(roster, &assignments, &tool_group_default_names()); Ok(axum::Json(ToolGroupsSnapshot { groups, descriptions, assignments, agents, effective, })) } /// A permission file that exists but can't be read or parsed. Surfaced /// as a 500 rather than an empty table, which would read as "nobody has /// any grants". fn unreadable(e: &std::io::Error) -> ProblemDetails { ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR).with_detail(e.to_string()) } /// The role-default tool-group names the harness falls back to for an /// agent with no explicit entry (`ToolGroup::AGENT_DEFAULT`). Shared by /// the HTTP snapshot and the SSE emit so the effective values match what /// the container actually runs with. #[must_use] pub(crate) fn tool_group_default_names() -> Vec<&'static str> { hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT .iter() .map(|g| <&str>::from(*g)) .collect() } /// Build the `(agents, effective)` pair for a permissions snapshot. /// `agents` is the sorted union of the live roster and any agent that /// already has an explicit entry; `effective[agent]` is the explicit /// assignment when present, else `default` (the role fallback). Shared by /// the tool-groups snapshots on both the HTTP and SSE paths so the two /// surfaces never drift. #[must_use] pub(crate) fn roster_and_effective( roster: impl IntoIterator, explicit: &std::collections::BTreeMap>, default: &[&'static str], ) -> (Vec, std::collections::BTreeMap>) { let mut names: std::collections::BTreeSet = roster.into_iter().collect(); names.extend(explicit.keys().cloned()); let agents: Vec = names.into_iter().collect(); let effective = agents .iter() .map(|a| { let v = explicit .get(a) .cloned() .unwrap_or_else(|| default.iter().map(|s| (*s).to_owned()).collect::>()); (a.clone(), v) }) .collect(); (agents, effective) } #[derive(Deserialize, ToSchema)] pub(super) struct SetToolGroupsBody { groups: Vec, } /// Replace `agent`'s explicit tool-group /// assignment (JSON body `{"groups": [...]}`). #[utoipa::path( post, path = "/api/tool-groups/{agent}", params(("agent" = String, Path, description = "agent name")), request_body = SetToolGroupsBody, responses( (status = 200, description = "tool-groups queued for write", body = String), (status = 400, description = "unknown group name, or no such agent"), (status = 404, description = "no such agent"), ), tag = "permissions" )] pub(super) async fn post_tool_groups( State(state): State, AxumPath(name): AxumPath, axum::Json(body): axum::Json, ) -> Result { let logical = strip_container_prefix(&name); // `guard_agent_name` yields a ready-made rejection `Response`; pass it // through as `Ok` (axum sends it verbatim) rather than re-deriving a // `ProblemDetails` — the guard is shared with `-> Response` handlers. if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } // Validate group names before queuing — fail fast so the operator // sees the error immediately rather than waiting for the worker. if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("invalid tool-groups for {logical}: {e}"))); } // Submit a PermChange DAG: the JSON file write commits under // META_LOCK inside the WritePermFile node, so concurrent // batch-apply actions for different agents never race on the // shared tool-groups.json. state .coord .job_queue .insert_job(|b| { crate::job_queue::templates::perm_change( b, &logical, crate::job_queue::PermPayload::ToolGroups { groups: body.groups.clone(), }, ); Vec::new() }) .expect("template-declared shapes are acyclic"); state.coord.emit_rebuild_queue_snapshot(); tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); Ok((StatusCode::OK, "ok").into_response()) } /// One agent's slice of a batch permission change. An omitted /// `tool_groups` leaves the agent untouched, an empty array clears it, a /// populated array fully replaces it (same replace semantics as the /// per-agent endpoint). #[derive(Deserialize, ToSchema)] pub(super) struct PermChangeBody { agent: String, #[serde(default)] tool_groups: Option>, } #[derive(Deserialize, ToSchema)] pub(super) struct BatchPermsBody { changes: Vec, } /// Batch permission apply — `POST /api/permissions`. /// /// The save-all permissions UI sends only the agents whose groups /// changed; each gets one `PermChange`. The whole batch is atomic: every /// change is validated up front and on any validation error nothing is /// written or enqueued. #[utoipa::path( post, path = "/api/permissions", request_body = BatchPermsBody, responses( (status = 200, description = "batch permission change queued", body = String), (status = 400, description = "unknown tool-group name, or no such agent"), (status = 404, description = "no such agent"), ), tag = "permissions" )] pub(super) async fn post_permissions( State(state): State, axum::Json(body): axum::Json, ) -> Result { // Phase 1 — validate everything before touching any file or the // queue, so a bad entry fails the whole POST with zero side effects. // No-op rows (field omitted) are skipped, not errors. let mut staged: Vec<(String, Vec)> = Vec::new(); for change in &body.changes { let logical = strip_container_prefix(&change.agent); if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } if let Some(groups) = &change.tool_groups { if let Err(e) = crate::tool_groups::validate_groups(groups) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("invalid tool-groups for {logical}: {e}"))); } staged.push((logical, groups.clone())); } } // Phase 2 — submit one PermChange DAG per affected agent. for (logical, groups) in staged { state .coord .job_queue .insert_job(|b| { crate::job_queue::templates::perm_change( b, &logical, crate::job_queue::PermPayload::ToolGroups { groups }, ); Vec::new() }) .expect("template-declared shapes are acyclic"); state.coord.emit_rebuild_queue_snapshot(); tracing::info!(agent = %logical, "operator: batch perm change via dashboard"); } Ok((StatusCode::OK, "ok").into_response()) } /// Agent names that have explicit tool-group entries but are /// not in the live container roster AND not in the kept-state directory /// list (i.e. truly gone — renamed or destroyed agents whose JSON entries /// persisted). The client uses this to drive the "stale permission entries" /// sub-section in K3PT ST4T3 without having to fetch three separate /// endpoints and perform set arithmetic on the client side. #[derive(Serialize, ToSchema)] pub(super) struct StalePermsResponse { /// Ghost agent names, sorted. Empty list → no stale entries. stale: Vec, } /// Agent names with explicit permission /// entries but no matching live container or kept-state dir. #[utoipa::path( get, path = "/api/permissions/stale", responses( (status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse), (status = 500, description = "tool-groups file unreadable"), ), tag = "permissions" )] pub(super) async fn get_stale_permissions( State(state): State, ) -> Result, ProblemDetails> { // Live container names — includes stopped-but-configured containers. let live: std::collections::HashSet = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name) .collect(); // Kept-state directories: every agent that ever had a state dir on disk, // including both live containers (already in `live`) and soft-deleted // tombstones (destroyed but kept). A name present here is either live // or a properly-removed tombstone — neither is a ghost. let kept: std::collections::HashSet = crate::coordinator::Coordinator::kept_state_names() .into_iter() .map(hive_types::Ident::into_string) .collect(); // Known = live roster ∪ kept-state names. let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?; // `tgs` is a `BTreeMap`, so its keys are already sorted. let ghost_names: Vec = tgs.keys().filter(|n| !known.contains(n)).cloned().collect(); Ok(axum::Json(StalePermsResponse { stale: ghost_names })) } /// Clear all explicit permission entries for a named agent without /// requiring it to exist in the live roster. /// /// Used by the P3RM1SS10NS tab's "remove" button for agents that have /// stale explicit entries in `tool-groups.json` but are no longer /// running (e.g. an agent that was renamed or destroyed while its JSON /// entries persisted). /// /// Bypasses `guard_agent_name`'s live-roster check intentionally — /// the whole point is to remove entries for non-roster agents. Only /// the format check ([`Ident::parse`]) is applied. No rebuild is /// enqueued (the agent doesn't exist to rebuild); the SSE snapshot /// updates the P3RM1SS10NS tab live. #[utoipa::path( delete, path = "/api/permissions/{agent}", params(("agent" = String, Path, description = "agent name (need not be live)")), responses( (status = 200, description = "stale permission entries cleared", body = String), (status = 400, description = "bad agent name"), (status = 500, description = "tool-groups file write failed"), ), tag = "permissions" )] pub(super) async fn delete_agent_permissions( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = match Ident::parse(&strip_container_prefix(&name)) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); } }; let removed = crate::tool_groups::remove_agent(logical.as_str()); state.coord.emit_tool_groups_snapshot(); // Surface an I/O error as 500 so the frontend's `!resp.ok` path // fires and the operator sees a meaningful message rather than a // silent "success" followed by the row reappearing unchanged. if let Err(e) = removed { tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry"); return ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to remove permission entries for {logical}: {e}"), ) .into_response(); } tracing::info!(agent = %logical, "operator: cleared stale permission entries via dashboard"); (StatusCode::OK, "ok").into_response() } #[cfg(test)] mod tests { use super::roster_and_effective; use std::collections::BTreeMap; #[test] fn effective_unions_roster_and_explicit_and_applies_default() { let mut explicit: BTreeMap> = BTreeMap::new(); explicit.insert("iris".to_owned(), vec!["messaging".to_owned()]); // `ruth` has an explicit entry but isn't in the live roster (e.g. // momentarily not listed) — it must still appear and keep its // explicit value. explicit.insert( "ruth".to_owned(), vec!["messaging".to_owned(), "lifecycle".to_owned()], ); let roster = ["damocles".to_owned(), "iris".to_owned()]; let default = ["messaging", "meta", "inbox", "execution"]; let (agents, effective) = roster_and_effective(roster, &explicit, &default); // Sorted union of roster + explicit keys. assert_eq!(agents, vec!["damocles", "iris", "ruth"]); // Entry-less agent (damocles) → role default. assert_eq!( effective["damocles"], vec!["messaging", "meta", "inbox", "execution"] ); // Explicit entries kept verbatim. assert_eq!(effective["iris"], vec!["messaging"]); assert_eq!(effective["ruth"], vec!["messaging", "lifecycle"]); } #[test] fn empty_default_yields_empty_effective_for_entryless() { let explicit: BTreeMap> = BTreeMap::new(); let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]); assert_eq!(agents, vec!["atlas"]); assert!(effective["atlas"].is_empty()); } }