hyperhive/hive-c0re/src/dashboard/permissions.rs

456 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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());
}
}