Watch
0
0
Fork
You've already forked hyperhive
0

add a per-agent egress log mode

This commit is contained in:
atlas 2026-10-30 02:18:39 +01:00 • committed by mara
commit e46ad07226

View file

@ -37,14 +37,31 @@ use crate::wanted::WantedWriter;
const MAX_ATTEMPTS: usize = 5;
/// 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`.
/// replaces the stored one; `unrestricted_internet` left out keeps the one in
/// `current`. `internet_log` is stored only as `true`, and a request that sets
/// `unrestricted_internet` without it turns it off: an agent that moves to
/// unrestricted and back to restricted does not come back logging.
fn into_stored(req: Permissions, current: &AgentPermissions) -> AgentPermissions {
let internet_log = match req.internet_log {
Some(on) => on.then_some(true),
None if req.unrestricted_internet.is_some() => None,
None => current.internet_log,
};
AgentPermissions {
permissions: req.permissions,
unrestricted_internet: req.unrestricted_internet.or(current.unrestricted_internet),
internet_log,
}
}
/// Why `value` is refused: `internet_log` is only meaningful beside an explicit
/// `unrestricted_internet: false`, the value an older hive reads.
fn log_without_restriction(value: &AgentPermissions) -> Option<&'static str> {
(value.internet_log == Some(true) && value.unrestricted_internet != Some(false)).then_some(
"internet_log needs unrestricted_internet to be false, in this request or stored",
)
}
/// Refuse a list that names a permission twice.
pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
@ -62,6 +79,7 @@ fn view(v: AgentPermissions) -> Permissions {
Permissions {
permissions: v.permissions,
unrestricted_internet: v.unrestricted_internet,
internet_log: v.internet_log,
}
}
@ -173,6 +191,9 @@ pub(crate) async fn put_agent_permissions(
.await
.map_err(|e| queue_problem(&e))?;
let value = into_stored(req.clone(), &current);
if let Some(why) = log_without_restriction(&value) {
return Err(refuse(error_problem(StatusCode::BAD_REQUEST, why)));
}
if (current == value && !undecodable)
|| write(&store, &agent, &value, revision)
.await
@ -214,10 +235,12 @@ pub(crate) async fn delete_agent_permissions(
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.
// Only the flags outlive a clear: dropping `unrestricted_internet`
// would turn an agent created with `false` on, and dropping
// `internet_log` would turn a logging agent into an enforced one.
let kept = AgentPermissions {
unrestricted_internet: current.unrestricted_internet,
internet_log: current.internet_log,
..AgentPermissions::default()
};
if kept == AgentPermissions::default() {
@ -307,6 +330,7 @@ pub(crate) fn at_create(
Ok(AgentPermissions {
permissions: perms,
unrestricted_internet,
internet_log: None,
})
}
@ -329,6 +353,13 @@ fn merged_at_create(
.unrestricted_internet
.or(current.unrestricted_internet)
.or_else(|| (!held).then_some(false)),
// A create is not a decision about logging: a request that sets
// `unrestricted_internet` ends it, as a `PUT` does.
internet_log: if value.unrestricted_internet.is_some() {
None
} else {
current.internet_log
},
}
}
@ -596,6 +627,80 @@ mod tests {
use hive_types::Permission;
use swarm_wire::agent_permissions::{Import, ImportReply};
fn stored(json: &str) -> swarm_wire::agent_permissions::AgentPermissions {
serde_json::from_str(json).expect("decodes")
}
fn request(unrestricted_internet: Option<bool>, internet_log: Option<bool>) -> Permissions {
Permissions {
permissions: None,
unrestricted_internet,
internet_log,
}
}
/// Logging is never switched by a request that does not name it, except
/// that setting the main flag ends it, so no agent comes back logging.
#[test]
fn a_request_changes_logging_only_by_naming_it_or_the_main_flag() {
let logging = stored(r#"{"unrestricted_internet":false,"internet_log":true}"#);
let after = |req| super::into_stored(req, &logging).internet_log;
assert_eq!(after(request(None, None)), Some(true), "left out: kept");
assert_eq!(after(request(Some(false), Some(true))), Some(true));
assert_eq!(
after(request(Some(false), None)),
None,
"main flag set: ended"
);
assert_eq!(
after(request(Some(true), None)),
None,
"unrestricted: ended"
);
assert_eq!(after(request(Some(false), Some(false))), None);
let plain = stored(r#"{"unrestricted_internet":false}"#);
assert_eq!(
super::into_stored(request(None, Some(true)), &plain).internet_log,
Some(true),
"control: the field turns it on beside a stored false"
);
}
/// `true` without a stored or requested `false` is refused; the pair and
/// the plain restricted record are not.
#[test]
fn logging_is_refused_unless_the_main_flag_is_false() {
let refused = |current: &str, req| {
super::log_without_restriction(&super::into_stored(req, &stored(current))).is_some()
};
assert!(refused("{}", request(None, Some(true))), "flag absent");
assert!(
refused("{}", request(Some(true), Some(true))),
"unrestricted"
);
assert!(!refused("{}", request(Some(false), Some(true))), "the pair");
assert!(
!refused(
r#"{"unrestricted_internet":false}"#,
request(None, Some(true))
),
"stored false"
);
assert!(!refused("{}", request(Some(false), None)), "restricted");
}
/// A create keeps a stored logging flag unless it sets the main flag.
#[test]
fn a_create_keeps_logging_unless_it_sets_the_main_flag() {
let current = stored(r#"{"unrestricted_internet":false,"internet_log":true}"#);
let merged = |value: &str| super::merged_at_create(&stored(value), &current, true);
assert_eq!(merged("{}").internet_log, Some(true));
assert_eq!(
merged(r#"{"unrestricted_internet":false}"#).internet_log,
None
);
}
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
let store = swarm_queue_client::agent_permissions::open_or_create(client)
.await
@ -611,6 +716,7 @@ mod tests {
Permissions {
permissions: Some(vec![Permission::ManageRootAgent]),
unrestricted_internet: None,
internet_log: None,
}
}
@ -655,6 +761,7 @@ mod tests {
let none = Permissions {
permissions: Some(Vec::new()),
unrestricted_internet: None,
internet_log: None,
};
let _ = put(&state, "atlas", none.clone()).await.expect("set");
assert_eq!(
@ -670,6 +777,53 @@ mod tests {
assert_eq!(get(&state, "atlas").await, Permissions::default());
}
/// Through the handlers: `log` is stored beside the old flag at `false`, a
/// `DELETE` keeps both, and a `PUT` of the main flag alone ends it.
#[tokio::test]
async fn log_mode_is_stored_beside_the_old_flag_and_ends_with_the_main_flag() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let logging = r#"{"unrestricted_internet":false,"internet_log":true}"#;
let log = Permissions {
permissions: None,
unrestricted_internet: Some(false),
internet_log: Some(true),
};
let stored = put(&state, "atlas", log.clone()).await.expect("set").0;
assert_eq!(stored, log);
assert_eq!(published(&client, "atlas").await.as_deref(), Some(logging));
let _ = delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await;
assert_eq!(published(&client, "atlas").await.as_deref(), Some(logging));
// Refused: `log` beside `true`, and nothing is written.
let both = Permissions {
unrestricted_internet: Some(true),
..log.clone()
};
let refused = put(&state, "atlas", both).await.expect_err("refused");
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST));
assert_eq!(published(&client, "atlas").await.as_deref(), Some(logging));
// The main flag alone ends it, and `false` again does not bring it back.
let on = Permissions {
permissions: None,
unrestricted_internet: Some(true),
internet_log: None,
};
let _ = put(&state, "atlas", on.clone()).await.expect("set");
let off = Permissions {
unrestricted_internet: Some(false),
..on.clone()
};
let _ = put(&state, "atlas", off).await.expect("set");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"unrestricted_internet":false}"#)
);
}
/// 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.