456 lines
18 KiB
Rust
456 lines
18 KiB
Rust
//! Tool-group + capability permission endpoints for the dashboard.
|
||
//!
|
||
//! Read endpoints return the full set of known groups/capabilities 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 problem_details::ProblemDetails;
|
||
|
||
use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
|
||
|
||
#[derive(Serialize)]
|
||
pub(super) struct ToolGroupsSnapshot {
|
||
/// Ordered list of all known tool-group names. Drives the column
|
||
/// headers in the capabilities 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<String, Vec<String>>,
|
||
/// 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<String>,
|
||
/// 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<String, Vec<String>>,
|
||
}
|
||
|
||
pub(super) async fn get_tool_groups(
|
||
State(state): State<AppState>,
|
||
) -> axum::Json<ToolGroupsSnapshot> {
|
||
let groups = hive_sh4re::ToolGroup::ALL
|
||
.iter()
|
||
.map(|g| g.as_str())
|
||
.collect();
|
||
let descriptions = hive_sh4re::ToolGroup::ALL
|
||
.iter()
|
||
.map(|g| (g.as_str(), g.description()))
|
||
.collect();
|
||
let assignments = crate::tool_groups::read();
|
||
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());
|
||
axum::Json(ToolGroupsSnapshot {
|
||
groups,
|
||
descriptions,
|
||
assignments,
|
||
agents,
|
||
effective,
|
||
})
|
||
}
|
||
|
||
/// 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::ToolGroup::AGENT_DEFAULT
|
||
.iter()
|
||
.map(|g| g.as_str())
|
||
.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 capabilities + 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<Item = String>,
|
||
explicit: &std::collections::BTreeMap<String, Vec<String>>,
|
||
default: &[&'static str],
|
||
) -> (Vec<String>, std::collections::BTreeMap<String, Vec<String>>) {
|
||
let mut names: std::collections::BTreeSet<String> = roster.into_iter().collect();
|
||
names.extend(explicit.keys().cloned());
|
||
let agents: Vec<String> = 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::<Vec<_>>());
|
||
(a.clone(), v)
|
||
})
|
||
.collect();
|
||
(agents, effective)
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub(super) struct SetToolGroupsBody {
|
||
groups: Vec<String>,
|
||
}
|
||
|
||
pub(super) async fn post_tool_groups(
|
||
State(state): State<AppState>,
|
||
AxumPath(name): AxumPath<String>,
|
||
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
||
) -> Result<Response, ProblemDetails> {
|
||
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.
|
||
crate::job_queue::submit::perm_change(
|
||
&state.coord,
|
||
&logical,
|
||
crate::job_queue::Source::Manual,
|
||
"tool-group change via permissions UI".to_owned(),
|
||
crate::job_queue::PermPayload::ToolGroups {
|
||
groups: body.groups.clone(),
|
||
},
|
||
);
|
||
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
||
Ok((StatusCode::OK, "ok").into_response())
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub(super) struct CapabilitiesSnapshot {
|
||
/// Ordered list of all known capability names. Drives the column
|
||
/// headers in the capabilities table — the UI does not hard-code them.
|
||
caps: Vec<&'static str>,
|
||
/// Short description for each capability name. Keys match `caps` entries.
|
||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||
/// Per-agent *explicit* capability grant map. Absent agents have no
|
||
/// extra caps — the UI uses presence here to badge "(default)".
|
||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||
/// Every agent the operator can manage (live roster ∪ explicit keys),
|
||
/// sorted — so default agents always list.
|
||
agents: Vec<String>,
|
||
/// Per-agent *effective* caps: explicit entry when present, else the
|
||
/// default (no caps). Keeps the snapshot shape symmetric with
|
||
/// tool-groups; the default here is always empty.
|
||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||
}
|
||
|
||
pub(super) async fn get_capabilities(
|
||
State(state): State<AppState>,
|
||
) -> axum::Json<CapabilitiesSnapshot> {
|
||
use hive_sh4re::Capability;
|
||
let caps = Capability::ALL.iter().map(|c| c.as_str()).collect();
|
||
let descriptions = Capability::ALL
|
||
.iter()
|
||
.map(|c| (c.as_str(), c.description()))
|
||
.collect();
|
||
let assignments = crate::capabilities::read();
|
||
let roster = state
|
||
.coord
|
||
.containers_snapshot()
|
||
.await
|
||
.into_iter()
|
||
.map(|c| c.name);
|
||
// Capability default is "no extra caps" — empty default slice.
|
||
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
|
||
axum::Json(CapabilitiesSnapshot {
|
||
caps,
|
||
descriptions,
|
||
assignments,
|
||
agents,
|
||
effective,
|
||
})
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub(super) struct SetCapabilitiesBody {
|
||
caps: Vec<String>,
|
||
}
|
||
|
||
pub(super) async fn post_capabilities(
|
||
State(state): State<AppState>,
|
||
AxumPath(name): AxumPath<String>,
|
||
axum::Json(body): axum::Json<SetCapabilitiesBody>,
|
||
) -> Result<Response, ProblemDetails> {
|
||
let logical = strip_container_prefix(&name);
|
||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||
return Ok(reject);
|
||
}
|
||
let known: Vec<&str> = hive_sh4re::Capability::ALL
|
||
.iter()
|
||
.map(|c| c.as_str())
|
||
.collect();
|
||
for cap in &body.caps {
|
||
if !known.contains(&cap.as_str()) {
|
||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||
.with_detail(format!("unknown capability: {cap}")));
|
||
}
|
||
}
|
||
// 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 capabilities.json.
|
||
crate::job_queue::submit::perm_change(
|
||
&state.coord,
|
||
&logical,
|
||
crate::job_queue::Source::Manual,
|
||
"capability change via dashboard".to_owned(),
|
||
crate::job_queue::PermPayload::Capabilities {
|
||
caps: body.caps.clone(),
|
||
},
|
||
);
|
||
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
||
Ok((StatusCode::OK, "ok").into_response())
|
||
}
|
||
|
||
/// One agent's slice of a batch permission change. Sparse: an omitted
|
||
/// field leaves that perm-type untouched, an empty array clears it, a
|
||
/// populated array fully replaces it (same replace semantics as the
|
||
/// per-agent endpoints).
|
||
#[derive(Deserialize)]
|
||
pub(super) struct PermChangeBody {
|
||
agent: String,
|
||
#[serde(default)]
|
||
tool_groups: Option<Vec<String>>,
|
||
#[serde(default)]
|
||
capabilities: Option<Vec<String>>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub(super) struct BatchPermsBody {
|
||
changes: Vec<PermChangeBody>,
|
||
}
|
||
|
||
/// A validated, non-empty change staged for enqueue:
|
||
/// `(logical agent, new groups?, new caps?)`.
|
||
type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
|
||
|
||
/// Batch permission apply — `POST /api/permissions`. The save-all
|
||
/// permissions UI sends only the perm-types that actually changed per
|
||
/// agent; each affected agent gets ONE combined `PermChange`, so the
|
||
/// dedup key collapses to `(kind, agent)` and an agent whose caps AND
|
||
/// groups both changed rebuilds once, not twice. The whole batch is
|
||
/// atomic: every change is validated up front and on any validation
|
||
/// error nothing is written or enqueued.
|
||
pub(super) async fn post_permissions(
|
||
State(state): State<AppState>,
|
||
axum::Json(body): axum::Json<BatchPermsBody>,
|
||
) -> Result<Response, ProblemDetails> {
|
||
let known_caps: Vec<&str> = hive_sh4re::Capability::ALL
|
||
.iter()
|
||
.map(|c| c.as_str())
|
||
.collect();
|
||
// 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 (both fields omitted) are skipped, not errors.
|
||
let mut staged: Vec<StagedPerm> = 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
|
||
&& 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}")));
|
||
}
|
||
if let Some(caps) = &change.capabilities {
|
||
for cap in caps {
|
||
if !known_caps.contains(&cap.as_str()) {
|
||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||
.with_detail(format!("unknown capability for {logical}: {cap}")));
|
||
}
|
||
}
|
||
}
|
||
if change.tool_groups.is_some() || change.capabilities.is_some() {
|
||
staged.push((
|
||
logical,
|
||
change.tool_groups.clone(),
|
||
change.capabilities.clone(),
|
||
));
|
||
}
|
||
}
|
||
// Phase 2 — submit one combined PermChange DAG per affected agent.
|
||
for (logical, groups, caps) in staged {
|
||
crate::job_queue::submit::perm_change(
|
||
&state.coord,
|
||
&logical,
|
||
crate::job_queue::Source::Manual,
|
||
"batch permission change via permissions UI".to_owned(),
|
||
crate::job_queue::PermPayload::Combined { groups, caps },
|
||
);
|
||
tracing::info!(agent = %logical, "operator: batch perm change via dashboard");
|
||
}
|
||
Ok((StatusCode::OK, "ok").into_response())
|
||
}
|
||
|
||
/// Agent names that have explicit capability/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)]
|
||
pub(super) struct StalePermsResponse {
|
||
/// Ghost agent names, sorted. Empty list → no stale entries.
|
||
stale: Vec<String>,
|
||
}
|
||
|
||
pub(super) async fn get_stale_permissions(
|
||
State(state): State<AppState>,
|
||
) -> axum::Json<StalePermsResponse> {
|
||
// Live container names — includes stopped-but-configured containers.
|
||
let live: std::collections::HashSet<String> = 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<String> =
|
||
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();
|
||
// Explicit entries in either JSON file.
|
||
let caps = crate::capabilities::read();
|
||
let tgs = crate::tool_groups::read();
|
||
let mut ghost_names: Vec<String> = caps
|
||
.keys()
|
||
.chain(tgs.keys())
|
||
.filter(|n| !known.contains(n))
|
||
.cloned()
|
||
.collect::<std::collections::HashSet<_>>()
|
||
.into_iter()
|
||
.collect();
|
||
ghost_names.sort();
|
||
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` / `capabilities.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 snapshots
|
||
/// update the P3RM1SS10NS tab live.
|
||
pub(super) async fn delete_agent_permissions(
|
||
State(state): State<AppState>,
|
||
AxumPath(name): AxumPath<String>,
|
||
) -> 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();
|
||
}
|
||
};
|
||
// Run both removals regardless so we clean up as much as possible
|
||
// even on partial I/O errors. Collect errors to surface below.
|
||
let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err();
|
||
if let Some(ref e) = tg_err {
|
||
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
|
||
}
|
||
let cap_err = crate::capabilities::remove_agent(logical.as_str()).err();
|
||
if let Some(ref e) = cap_err {
|
||
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
|
||
}
|
||
// Emit live snapshots even on partial failure so the UI stays as
|
||
// accurate as possible — the surviving table gets updated immediately.
|
||
state.coord.emit_tool_groups_snapshot();
|
||
state.coord.emit_capabilities_snapshot();
|
||
// Surface any 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 Some(e) = tg_err.or(cap_err) {
|
||
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<String, Vec<String>> = 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<String, Vec<String>> = BTreeMap::new();
|
||
let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]);
|
||
assert_eq!(agents, vec!["atlas"]);
|
||
assert!(effective["atlas"].is_empty());
|
||
}
|
||
}
|