swarm: unrestricted_internet flag on the agent permissions record, unset reads as on, create writes false
This commit is contained in:
parent
e77459d2cb
commit
8699857024
1 changed files with 381 additions and 107 deletions
|
|
@ -36,14 +36,13 @@ use crate::wanted::WantedWriter;
|
|||
/// `wanted::MAX_ATTEMPTS`.
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
/// The permissions to store, or why they are refused.
|
||||
fn into_stored(req: Permissions) -> Result<AgentPermissions, String> {
|
||||
if let Some(perms) = &req.permissions {
|
||||
unique(perms)?;
|
||||
}
|
||||
Ok(AgentPermissions {
|
||||
/// The permissions to store for `req`, whose list is already checked. The list
|
||||
/// replaces the stored one; `unrestricted_internet` left out keeps the one in `current`.
|
||||
fn into_stored(req: Permissions, current: &AgentPermissions) -> AgentPermissions {
|
||||
AgentPermissions {
|
||||
permissions: req.permissions,
|
||||
})
|
||||
unrestricted_internet: req.unrestricted_internet.or(current.unrestricted_internet),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a list that names a permission twice.
|
||||
|
|
@ -62,6 +61,7 @@ pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
|
|||
fn view(v: AgentPermissions) -> Permissions {
|
||||
Permissions {
|
||||
permissions: v.permissions,
|
||||
unrestricted_internet: v.unrestricted_internet,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +127,8 @@ pub(crate) async fn get_agent_permissions(
|
|||
|
||||
/// Set the permissions the swarm grants one agent.
|
||||
///
|
||||
/// The value replaces the stored one whole. The hive running the agent reads
|
||||
/// The permission list replaces the stored one; `unrestricted_internet` left
|
||||
/// out keeps the stored flag. The hive running the agent reads
|
||||
/// it within 30 seconds and restarts the agent, after its current turn, when
|
||||
/// `manage_root_agent` changed; the agent applies the rest at its next turn.
|
||||
#[utoipa::path(
|
||||
|
|
@ -156,8 +157,9 @@ pub(crate) async fn put_agent_permissions(
|
|||
let Json(req) =
|
||||
req.map_err(|rejection| refuse(error_problem(rejection.status(), &rejection.body_text())))?;
|
||||
let agent = agent_name(&name).map_err(|r| refuse(refused(r)))?;
|
||||
let value =
|
||||
into_stored(req).map_err(|why| refuse(error_problem(StatusCode::BAD_REQUEST, &why)))?;
|
||||
if let Some(perms) = &req.permissions {
|
||||
unique(perms).map_err(|why| refuse(error_problem(StatusCode::BAD_REQUEST, &why)))?;
|
||||
}
|
||||
let client = queue(&state).map_err(|r| refuse(refused(r)))?;
|
||||
if !live(&state).await.map_err(refuse)?.contains(&agent) {
|
||||
return Err(refuse(error_problem(
|
||||
|
|
@ -170,6 +172,7 @@ pub(crate) async fn put_agent_permissions(
|
|||
let (current, revision, undecodable) = read_or_default::<AgentPermissions>(&store, &agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
let value = into_stored(req.clone(), ¤t);
|
||||
if (current == value && !undecodable)
|
||||
|| write(&store, &agent, &value, revision)
|
||||
.await
|
||||
|
|
@ -189,7 +192,8 @@ pub(crate) async fn put_agent_permissions(
|
|||
path = "/api/agents/{name}/permissions",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 204, description = "cleared; the hive's default applies again"),
|
||||
(status = 204, description = "cleared; the hive's default applies again, and a stored \
|
||||
`unrestricted_internet` is kept"),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
||||
not connected (problem+json)", body = String),
|
||||
|
|
@ -206,17 +210,37 @@ pub(crate) async fn delete_agent_permissions(
|
|||
let agent = agent_name(&name).map_err(|r| refuse(refused(r)))?;
|
||||
let client = queue(&state).map_err(|r| refuse(refused(r)))?;
|
||||
let store = open_or_create(&client).await?;
|
||||
store
|
||||
.delete(&agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
|
||||
tracing::info!(%agent, "cleared agent permissions");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (current, revision, _) = read_or_default::<AgentPermissions>(&store, &agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
// Only the flag outlives a clear: dropping it would turn an agent
|
||||
// created with `false` on.
|
||||
let kept = AgentPermissions {
|
||||
unrestricted_internet: current.unrestricted_internet,
|
||||
..AgentPermissions::default()
|
||||
};
|
||||
if kept == AgentPermissions::default() {
|
||||
store
|
||||
.delete(&agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
|
||||
} else if !write(&store, &agent, &kept, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::info!(%agent, "cleared agent permissions");
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
"gave up clearing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Delete a destroyed agent's key. A name is free again once its agent is
|
||||
/// destroyed, and a create left on the default stores nothing, so a key left
|
||||
/// here would hand the next agent of that name this one's grant.
|
||||
/// destroyed, so it starts with no record.
|
||||
///
|
||||
/// Like the credential revoke it runs beside, a failure is logged and does
|
||||
/// not stop the destroy; declaring the agent destroyed again re-runs it.
|
||||
|
|
@ -236,9 +260,9 @@ pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str)
|
|||
hive,
|
||||
agent,
|
||||
error = %format!("{e:#}"),
|
||||
"clearing this agent's permissions FAILED; it was destroyed anyway, so a new \
|
||||
agent of the same name created on the default gets its grant. Declaring the \
|
||||
agent destroyed again re-runs this."
|
||||
"clearing this agent's permissions FAILED; it was destroyed anyway, so a read \
|
||||
of that name finds the old record until a create writes a new one. \
|
||||
Declaring the agent destroyed again re-runs this."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -267,13 +291,21 @@ pub(crate) async fn get_permission_catalogue() -> Json<Catalogue> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The value `POST /api/agents` stores for `perms`, or why it is refused.
|
||||
/// The value `POST /api/agents` stores, or why it is refused. `perms` left
|
||||
/// out keeps the default set; `unrestricted_internet` left out is `false`, so
|
||||
/// every new agent has an explicit value and only older records read as on.
|
||||
/// The agent is not declared on its hive yet, so the "declared by a hive"
|
||||
/// check of [`put_agent_permissions`] does not apply.
|
||||
pub(crate) fn at_create(perms: Vec<Permission>) -> Result<AgentPermissions, String> {
|
||||
unique(&perms)?;
|
||||
pub(crate) fn at_create(
|
||||
perms: Option<Vec<Permission>>,
|
||||
unrestricted_internet: Option<bool>,
|
||||
) -> Result<AgentPermissions, String> {
|
||||
if let Some(perms) = &perms {
|
||||
unique(perms)?;
|
||||
}
|
||||
Ok(AgentPermissions {
|
||||
permissions: Some(perms),
|
||||
permissions: perms,
|
||||
unrestricted_internet: Some(unrestricted_internet.unwrap_or(false)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -484,6 +516,7 @@ async fn seed_into(
|
|||
perms.push(Permission::ManageRootAgent);
|
||||
let value = AgentPermissions {
|
||||
permissions: Some(perms),
|
||||
..AgentPermissions::default()
|
||||
};
|
||||
let written = write(store, SEEDED_AGENT, &value, revision).await?;
|
||||
if written {
|
||||
|
|
@ -511,6 +544,7 @@ async fn import_into(
|
|||
}
|
||||
let value = AgentPermissions {
|
||||
permissions: Some(caps),
|
||||
..AgentPermissions::default()
|
||||
};
|
||||
if write(store, &agent, &value, None).await? {
|
||||
written.push(agent);
|
||||
|
|
@ -551,6 +585,7 @@ mod tests {
|
|||
fn granted() -> Permissions {
|
||||
Permissions {
|
||||
permissions: Some(vec![Permission::ManageRootAgent]),
|
||||
unrestricted_internet: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,6 +629,7 @@ mod tests {
|
|||
|
||||
let none = Permissions {
|
||||
permissions: Some(Vec::new()),
|
||||
unrestricted_internet: None,
|
||||
};
|
||||
let _ = put(&state, "atlas", none.clone()).await.expect("set");
|
||||
assert_eq!(
|
||||
|
|
@ -609,6 +645,70 @@ mod tests {
|
|||
assert_eq!(get(&state, "atlas").await, Permissions::default());
|
||||
}
|
||||
|
||||
/// An agent stored with `unrestricted_internet: false` keeps it through a
|
||||
/// `PUT` that leaves it out and through `DELETE`; a `PUT` that names it
|
||||
/// changes it. Without the flag, `DELETE` still removes the key.
|
||||
#[tokio::test]
|
||||
async fn put_and_delete_keep_the_internet_flag_unless_named() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let off = r#"{"unrestricted_internet":false}"#;
|
||||
crate::agent_permissions::store(
|
||||
&client,
|
||||
"atlas",
|
||||
&super::at_create(None, None).expect("valid"),
|
||||
)
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(published(&client, "atlas").await.as_deref(), Some(off));
|
||||
|
||||
let stored = put(&state, "atlas", granted()).await.expect("set").0;
|
||||
assert_eq!(stored.unrestricted_internet, Some(false));
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"permissions":["manage_root_agent"],"unrestricted_internet":false}"#)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
);
|
||||
assert_eq!(published(&client, "atlas").await.as_deref(), Some(off));
|
||||
assert_eq!(
|
||||
get(&state, "atlas").await.unrestricted_internet,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let on = Permissions {
|
||||
unrestricted_internet: Some(true),
|
||||
..granted()
|
||||
};
|
||||
let stored = put(&state, "atlas", on).await.expect("set").0;
|
||||
assert_eq!(stored.unrestricted_internet, Some(true));
|
||||
assert_eq!(
|
||||
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
);
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"unrestricted_internet":true}"#)
|
||||
);
|
||||
|
||||
// Control: an agent that never had the flag has nothing left after a
|
||||
// clear, and a `PUT` does not invent one.
|
||||
crate::agent_permissions::store(&client, "atlas", &super::AgentPermissions::default())
|
||||
.await
|
||||
.expect("an older record");
|
||||
let _ = put(&state, "atlas", granted()).await.expect("set");
|
||||
assert_eq!(get(&state, "atlas").await.unrestricted_internet, None);
|
||||
assert_eq!(
|
||||
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
);
|
||||
assert_eq!(published(&client, "atlas").await, None);
|
||||
}
|
||||
|
||||
/// A name no hive declares, or a name that is not an identifier, is a 400
|
||||
/// and writes nothing.
|
||||
#[tokio::test]
|
||||
|
|
@ -903,7 +1003,7 @@ mod tests {
|
|||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let create = |name: &str, permissions| {
|
||||
let create = |name: &str, permissions, unrestricted_internet| {
|
||||
crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(swarm_controller_api::agents::CreateAgentRequest {
|
||||
|
|
@ -912,6 +1012,7 @@ mod tests {
|
|||
limits: None,
|
||||
inference: None,
|
||||
permissions,
|
||||
unrestricted_internet,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
|
|
@ -921,6 +1022,7 @@ mod tests {
|
|||
let problem = create(
|
||||
"iris",
|
||||
Some(vec![Permission::Messaging, Permission::Messaging]),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("a duplicate is refused");
|
||||
|
|
@ -935,21 +1037,31 @@ mod tests {
|
|||
.count();
|
||||
assert_eq!(queued, 0, "nothing is queued for a refused create");
|
||||
|
||||
let _ = create("iris", Some(vec![Permission::Messaging]))
|
||||
let _ = create("iris", Some(vec![Permission::Messaging]), None)
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "iris").await.as_deref(),
|
||||
Some(r#"{"permissions":["messaging"]}"#)
|
||||
Some(r#"{"permissions":["messaging"],"unrestricted_internet":false}"#)
|
||||
);
|
||||
|
||||
// Control: no `permissions` writes no key, so the default applies.
|
||||
let _ = create("damocles", None).await.expect("created");
|
||||
assert_eq!(published(&client, "damocles").await, None);
|
||||
// No `permissions` still writes the record: the explicit `false`.
|
||||
let _ = create("damocles", None, None).await.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "damocles").await.as_deref(),
|
||||
Some(r#"{"unrestricted_internet":false}"#)
|
||||
);
|
||||
|
||||
// The request can name the flag.
|
||||
let _ = create("hermes", None, Some(true)).await.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "hermes").await.as_deref(),
|
||||
Some(r#"{"unrestricted_internet":true}"#)
|
||||
);
|
||||
}
|
||||
|
||||
/// Destroying an agent deletes its key, so a new agent of the same name
|
||||
/// created on the default gets the default, not the old grant.
|
||||
/// created on the default gets only its own record, not the old grant.
|
||||
#[tokio::test]
|
||||
async fn a_recreated_agent_does_not_inherit_the_destroyed_ones_grant() {
|
||||
let nats = TestNats::start();
|
||||
|
|
@ -974,6 +1086,7 @@ mod tests {
|
|||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
unrestricted_internet: None,
|
||||
archetype: None,
|
||||
agents_md: None,
|
||||
}),
|
||||
|
|
@ -981,8 +1094,10 @@ mod tests {
|
|||
.await
|
||||
.expect("recreated");
|
||||
|
||||
assert_eq!(published(&client, "atlas").await, None);
|
||||
assert_eq!(get(&state, "atlas").await, Permissions::default());
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"unrestricted_internet":false}"#)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue