Watch
0
0
Fork
You've already forked hyperhive
0

grant manage_root_agent by stored permission only, not by the name ruth

Permission::default_for and hive-sh4re's MANAGER_AGENT are gone: every
agent with no permissions key holds Permission::DEFAULT, ruth included.
The agent-name parameters that only fed default_for go with it
(permissions::effective, HiveCopy::before_read/after_read and their
callers).

swarm-controller seeds the grant ruth held by name: at start, a declared
ruth whose permissions are unset gets DEFAULT plus manage_root_agent
written into her key, once, logged. A set key is never touched. It is a
one-shot to delete once every swarm has passed it.

The swarm UI setup assistant opens the create form with
manage_root_agent checked, so a new ruth is created with the grant.
permissions_import no longer adds ruth to the import or splits her
default. Docs and help text no longer say ruth holds the grant by
default; swarmctl-cli.md regenerated.
This commit is contained in:
atlas 2026-10-16 23:10:05 +02:00 • committed by mara
commit 7b5400ecb8
26 changed files with 380 additions and 216 deletions

View file

@ -348,6 +348,21 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
}
tokio::time::sleep(RETRY).await;
};
tokio::spawn({
let (store, wanted, hives) = (store.clone(), Arc::clone(&wanted), hives.clone());
async move {
loop {
match seed_ruth_grant(&store, &wanted, &hives).await {
Ok(()) => break,
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
"agent permissions: seeding ruth's grant failed; retrying"
),
}
tokio::time::sleep(RETRY).await;
}
}
});
let subject = agent_permissions::import_subject_wildcard();
loop {
match client.subscribe(subject.clone()).await {
@ -407,6 +422,60 @@ async fn answer_import(
}
}
/// The agent [`seed_into`] seeds.
const SEEDED_AGENT: &str = "ruth";
/// One-shot migration, run at every start; expect to delete it (with
/// [`seed_into`] and its call in [`spawn`]) once every swarm has passed it.
/// Builds before it granted `manage_root_agent` by name to an agent called
/// `ruth` whose permissions were unset; this writes that grant into her key
/// so she keeps her recovery mounts. Idempotent: a set key is never touched.
async fn seed_ruth_grant(
store: &async_nats::jetstream::kv::Store,
wanted: &WantedWriter,
hives: &[String],
) -> anyhow::Result<()> {
let mut declarations = Vec::with_capacity(hives.len());
for hive in hives {
if let Some(d) = wanted.view(hive).await? {
declarations.push(d);
}
}
seed_into(store, &crate::agent_renewal::live_agents(&declarations)).await?;
Ok(())
}
/// Write [`Permission::DEFAULT`] plus `manage_root_agent` under
/// [`SEEDED_AGENT`]'s key when `declared` holds her and her `permissions`
/// are unset (no key, a deleted key, or `{}`). `true` when it wrote.
async fn seed_into(
store: &async_nats::jetstream::kv::Store,
declared: &BTreeSet<String>,
) -> anyhow::Result<bool> {
if !declared.contains(SEEDED_AGENT) {
return Ok(false);
}
let (current, revision, undecodable) =
read_or_default::<AgentPermissions>(store, SEEDED_AGENT).await?;
if current.permissions.is_some() || undecodable {
return Ok(false);
}
let mut perms = Permission::DEFAULT.to_vec();
perms.push(Permission::ManageRootAgent);
let value = AgentPermissions {
permissions: Some(perms),
};
let written = write(store, SEEDED_AGENT, &value, revision).await?;
if written {
tracing::info!(
agent = SEEDED_AGENT,
?value,
"agent permissions: one-shot seed of the grant ruth held by name"
);
}
Ok(written)
}
/// Write each agent's imported permissions under its key when it has none,
/// for the agents in `declared` only.
async fn import_into(
@ -440,7 +509,7 @@ async fn import_into(
mod tests {
use super::{
Permissions, delete_agent_permissions, get_agent_permissions, import_into,
put_agent_permissions,
put_agent_permissions, seed_into,
};
use crate::test_nats::TestNats;
use axum::extract::{Path, State};
@ -636,6 +705,81 @@ mod tests {
assert_eq!(published(&client, "iris").await, None);
}
const SEEDED: &str =
r#"{"permissions":["messaging","meta","inbox","execution","manage_root_agent"]}"#;
/// A declared `ruth` whose permissions are unset (no key, or a deleted
/// one, or `{}`) gets the default plus `manage_root_agent`, once.
#[tokio::test]
async fn the_seed_writes_an_unset_ruth_grant() {
let nats = TestNats::start();
let client = nats.client().await;
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
assert!(seed_into(&store, &declared).await.expect("seeded"));
assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED));
assert_eq!(published(&client, "atlas").await, None, "only ruth");
assert!(!seed_into(&store, &declared).await.expect("idempotent"));
store.delete("ruth").await.expect("delete");
assert!(seed_into(&store, &declared).await.expect("seeded"));
assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED));
store
.put("ruth", b"{}".to_vec().into())
.await
.expect("unset");
assert!(seed_into(&store, &declared).await.expect("seeded"));
assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED));
}
/// A ruth key the swarm already set, even to nothing, is left as it is;
/// so is one this build cannot decode.
#[tokio::test]
async fn the_seed_leaves_a_set_ruth_key_alone() {
let nats = TestNats::start();
let client = nats.client().await;
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
let declared = ["ruth".to_owned()].into();
for set in [
r#"{"permissions":[]}"#,
r#"{"permissions":["web_tools"]}"#,
r#"{"permissions":["fly_to_the_moon"]}"#,
] {
store
.put("ruth", set.as_bytes().to_vec().into())
.await
.expect("set");
assert!(!seed_into(&store, &declared).await.expect("read"), "{set}");
assert_eq!(published(&client, "ruth").await.as_deref(), Some(set));
}
}
/// With no `ruth` declared nothing is written; declaring her is what
/// makes the same call write.
#[tokio::test]
async fn the_seed_writes_nothing_without_a_declared_ruth() {
let nats = TestNats::start();
let client = nats.client().await;
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
let declared = ["atlas".to_owned()].into();
assert!(!seed_into(&store, &declared).await.expect("read"));
assert_eq!(published(&client, "ruth").await, None);
assert_eq!(published(&client, "atlas").await, None);
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
assert!(seed_into(&store, &declared).await.expect("seeded"));
assert_eq!(published(&client, "ruth").await.as_deref(), Some(SEEDED));
}
/// `POST /api/agents` with `permissions` stores them under the new agent's
/// key in the same call; a list naming one twice is refused before
/// anything is stored or queued.