agent_permissions, agent_term and subagent_term take their wire contracts from swarm-wire
This commit is contained in:
parent
c06b1325e7
commit
839efd1642
27 changed files with 661 additions and 607 deletions
|
|
@ -1,102 +1,10 @@
|
|||
//! The per-agent permissions KV bucket: one key per agent, holding the
|
||||
//! [`hive_types::Permission`] set the swarm grants it.
|
||||
//!
|
||||
//! **swarm-controller is the only writer**, and creates the bucket when it
|
||||
//! starts. A missing key or an unset `permissions` field leaves the agent on
|
||||
//! the compiled-in default (`Permission::DEFAULT`); an empty list grants
|
||||
//! nothing.
|
||||
//!
|
||||
//! **Two readers, each enforcing its own part.** The hive running the agent
|
||||
//! applies `manage_root_agent` as bind mounts at container start; its grant
|
||||
//! covers every key of this bucket, read-only, for the reason
|
||||
//! `swarm_wire::agent_limits` gives. The agent itself (its harness and subagent
|
||||
//! daemon) applies the tool permissions; its grant covers only its own key.
|
||||
//! Both read with one `DIRECT.GET` on the agent's key
|
||||
//! ([`crate::agent_permissions::key_subject`]).
|
||||
//!
|
||||
//! Keyed by the agent alone, like `swarm_wire::agent_limits`: an agent can move
|
||||
//! between hives, and its permissions go with it.
|
||||
//!
|
||||
//! Also carries the one-shot import a hive sends of the grants its meta repo
|
||||
//! held ([`crate::agent_permissions::import_subject`]), answered by the
|
||||
//! controller.
|
||||
//! Opening and reading the per-agent permissions KV bucket, and the copy
|
||||
//! hive-c0re keeps of a record for the agent's own processes. The names, the
|
||||
//! stored type and the import messages are `swarm_wire::agent_permissions`;
|
||||
//! reading needs the `agent-permissions` feature and writing needs `kv`.
|
||||
|
||||
/// The KV bucket permissions are published into, keyed by agent name.
|
||||
pub const BUCKET: &str = "agent-permissions";
|
||||
|
||||
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
|
||||
pub const STREAM: &str = "KV_agent-permissions";
|
||||
|
||||
/// The subject `agent`'s entry is stored under.
|
||||
#[must_use]
|
||||
pub fn key_subject(agent: &str) -> String {
|
||||
format!("$KV.{BUCKET}.{agent}")
|
||||
}
|
||||
|
||||
const IMPORT_SUBJECT_PREFIX: &str = "$SWARM.permissions-import";
|
||||
|
||||
/// The subject `hive` sends its import on. One per hive, so the hive's grant
|
||||
/// names only its own, and the controller takes the hive from the subject
|
||||
/// rather than from the payload.
|
||||
#[must_use]
|
||||
pub fn import_subject(hive: &str) -> String {
|
||||
format!("{IMPORT_SUBJECT_PREFIX}.{hive}")
|
||||
}
|
||||
|
||||
/// Every [`import_subject`] at once, for the controller's subscription.
|
||||
#[must_use]
|
||||
pub fn import_subject_wildcard() -> String {
|
||||
import_subject("*")
|
||||
}
|
||||
|
||||
/// The hive an [`import_subject`] names, or `None` for any other subject.
|
||||
#[must_use]
|
||||
pub fn hive_from_import_subject(subject: &str) -> Option<&str> {
|
||||
let hive = subject
|
||||
.strip_prefix(IMPORT_SUBJECT_PREFIX)?
|
||||
.strip_prefix('.')?;
|
||||
(!hive.is_empty() && !hive.contains('.')).then_some(hive)
|
||||
}
|
||||
|
||||
/// How long a hive waits for the controller's answer to an import, and the
|
||||
/// queue lets the controller answer.
|
||||
pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// One agent's permissions: the value under `BUCKET`/`<agent>`. `None` is
|
||||
/// "not set by the swarm", which a hive reads as its default.
|
||||
///
|
||||
/// No `deny_unknown_fields`: a field a newer controller adds must not cost an
|
||||
/// older hive the fields it knows.
|
||||
#[cfg(feature = "agent-permissions")]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AgentPermissions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<hive_types::Permission>>,
|
||||
}
|
||||
|
||||
/// What a hive sends once: the permissions its meta repo held, by agent.
|
||||
#[cfg(feature = "agent-permissions")]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Import {
|
||||
pub permissions: std::collections::BTreeMap<String, Vec<hive_types::Permission>>,
|
||||
}
|
||||
|
||||
/// The controller's answer to an [`Import`].
|
||||
#[cfg(feature = "agent-permissions")]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "kebab-case")]
|
||||
pub enum ImportReply {
|
||||
/// Every agent was handled. `written` had no key and got the hive's value;
|
||||
/// `kept` already had a key, which was left alone; `skipped` is not an
|
||||
/// agent the sending hive's wanted state declares.
|
||||
Imported {
|
||||
written: Vec<String>,
|
||||
kept: Vec<String>,
|
||||
skipped: Vec<String>,
|
||||
},
|
||||
/// Nothing is known to be written; the hive sends again later.
|
||||
Failed { error: String },
|
||||
}
|
||||
use swarm_wire::agent_permissions::AgentPermissions;
|
||||
|
||||
/// Open the permissions bucket, creating it if nothing has yet.
|
||||
///
|
||||
|
|
@ -108,7 +16,7 @@ pub async fn open_or_create(
|
|||
let js = async_nats::jetstream::new(client.clone());
|
||||
crate::kv::open_or_create(
|
||||
&js,
|
||||
BUCKET,
|
||||
swarm_wire::agent_permissions::BUCKET,
|
||||
"agent-permissions",
|
||||
"Permissions the swarm controller sets for each agent".to_owned(),
|
||||
)
|
||||
|
|
@ -209,8 +117,13 @@ pub async fn read(
|
|||
use async_nats::jetstream::stream::DirectGetErrorKind;
|
||||
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
let stream = js.get_stream_no_info(STREAM).await?;
|
||||
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
|
||||
let stream = js
|
||||
.get_stream_no_info(swarm_wire::agent_permissions::STREAM)
|
||||
.await?;
|
||||
let message = match stream
|
||||
.direct_get_last_for_subject(swarm_wire::agent_permissions::key_subject(agent))
|
||||
.await
|
||||
{
|
||||
Ok(message) => message,
|
||||
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
|
||||
return Ok(Read::Current(AgentPermissions::default()));
|
||||
|
|
@ -245,53 +158,10 @@ fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<AgentPe
|
|||
serde_json::from_slice(payload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "agent-permissions"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the grants in `swarm-nats-auth` are built from.
|
||||
#[test]
|
||||
fn names_match_the_queue_grant() {
|
||||
assert_eq!(STREAM, format!("KV_{BUCKET}"));
|
||||
assert_eq!(key_subject("iris"), "$KV.agent-permissions.iris");
|
||||
assert_eq!(import_subject("pr1ma"), "$SWARM.permissions-import.pr1ma");
|
||||
assert_eq!(import_subject_wildcard(), "$SWARM.permissions-import.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hive_is_read_back_from_its_import_subject_only() {
|
||||
assert_eq!(
|
||||
hive_from_import_subject(&import_subject("pr1ma")),
|
||||
Some("pr1ma")
|
||||
);
|
||||
for other in [
|
||||
"$SWARM.permissions-import",
|
||||
"$SWARM.permissions-import.",
|
||||
"$SWARM.permissions-import.a.b",
|
||||
"$SWARM.permissions-importx.pr1ma",
|
||||
"$SWARM.deploy.pr1ma",
|
||||
] {
|
||||
assert_eq!(hive_from_import_subject(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unset is left out on the wire, `{}` is unset, and an empty list stays
|
||||
/// an empty list: "grants nothing" is not "not set".
|
||||
#[cfg(feature = "agent-permissions")]
|
||||
#[test]
|
||||
fn unset_and_empty_are_different_values() {
|
||||
use hive_types::Permission;
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentPermissions::default()).unwrap(),
|
||||
"{}"
|
||||
);
|
||||
let none: AgentPermissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
|
||||
assert_eq!(none.permissions, Some(Vec::new()));
|
||||
let ruth: AgentPermissions =
|
||||
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
|
||||
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
|
||||
}
|
||||
|
||||
/// Before a read the stored list stands; after one, a set key wins and an
|
||||
/// unset key is the default unless the import is pending.
|
||||
#[cfg(feature = "agent-permissions")]
|
||||
|
|
|
|||
Loading…
Reference in a new issue