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:
parent
9258563f9c
commit
7b5400ecb8
26 changed files with 380 additions and 216 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue