feat(dashboard): batch POST /api/permissions for save-all perms (#1719)

This commit is contained in:
damocles 2026-06-17 18:08:19 +02:00
commit 8e24814efe
5 changed files with 163 additions and 0 deletions

View file

@ -223,6 +223,86 @@ pub(super) async fn post_capabilities(
(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>,
) -> Response {
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 reject;
}
if let Some(groups) = &change.tool_groups
&& let Err(e) = crate::tool_groups::validate_groups(groups)
{
return error_response(&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 error_response(&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 — enqueue one combined PermChange per affected agent.
for (logical, groups, caps) in staged {
state.coord.rebuild_queue.enqueue_with_perm(
logical.clone(),
crate::rebuild_queue::QueueSource::Manual,
"batch permission change via permissions UI".to_owned(),
crate::rebuild_queue::PermPayload::Combined { groups, caps },
);
tracing::info!(agent = %logical, "operator: batch perm change via dashboard");
}
state.coord.emit_rebuild_queue_snapshot();
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]
mod tests {
use super::roster_and_effective;