github: PATs live in swarm bao; the agent fetches them itself
An operator links an agent's GitHub personal access token in the swarm UI
(LinkGithubAccountForm, "link github account" on /agents). swarm-controller's
PUT /api/hives/{hive}/agents/{agent}/github-account stores it at
swarm/agents/<agent>/github-token (swarm_secret_client::github), a flat leaf
under the agent's prefix that the agent's existing read grant already covers:
no policy change, and no list grant, since there is one token per agent.
In the agent, hive-agent-github-token (oneshot + 2-minute timer, as the agent
user, under its own store certificate, ordered before hive-github-notify)
reads that path and writes <state>/github-token, 0600 and agent-owned, the
file the gh wrapper, git credential helper and hive-github-notify already
read. It replaces the file by rename only when the bytes changed and never
deletes it: a hive-written github-token stays until a token is linked in the
swarm UI. It is installed only with a store address and
services.hyperhive.agent.github.enable.
Removed: the dashboard's CR3D3NTIALS page (credentials.html/js/css, its
build entries and H0M3 tile; GITHUB was its only tab), hive-c0re's
dashboard/matrix_accounts.rs with GET/POST /api/github-account,
priv_client::write_agent_github_token, the host socket's
SetAgentGithubToken and `hivectl github set-token`, and hive-priv's
WriteAgentGithubToken with write_agent_state_file, its only caller gone.
Docs: integrations/github.md and swarm/ui.md describe the swarm path,
swarm/credentials.md gains the store-path row, and the hive UI docs,
hivectl docs and security.md's hive-priv table drop the removed pieces.
Closes #4347
This commit is contained in:
parent
2b2608a491
commit
8e23feb01b
41 changed files with 804 additions and 846 deletions
128
swarm-secret-client/src/github.rs
Normal file
128
swarm-secret-client/src/github.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! The GitHub agreement: where an agent's GitHub personal access token lives in
|
||||
//! the store, and what the object at that path holds.
|
||||
//!
|
||||
//! An operator hands `swarm-controller` the token, the controller stores it at
|
||||
//! [`account_path`], and `nix/agent-modules/github-token.nix` reads it back
|
||||
//! under the agent's own certificate. One token per agent, so one path and no
|
||||
//! directory to list. Neither end is senior, so both halves are stated once,
|
||||
//! here, beside [`crate::forge`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s GitHub token.
|
||||
///
|
||||
/// A flat leaf under the agent's prefix, like
|
||||
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
||||
/// ([`crate::policy::render_agent`]) already covers it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn account_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/github-token"))
|
||||
}
|
||||
|
||||
/// What an [`account_path`] holds.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live GitHub credential, and a derived
|
||||
/// `Debug` is one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
/// The token. `github-token.nix` reads the store with
|
||||
/// `bao kv get -field=value`, so this name is load-bearing for a reader
|
||||
/// this crate does not control.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("value", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn covered_by(policy: &str, path: &str) -> bool {
|
||||
policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_lands_under_the_agent_prefix() {
|
||||
// Spelled out: `github-token.nix` spells the same string.
|
||||
assert_eq!(
|
||||
account_path("atlas").expect("a plain name is legal"),
|
||||
"swarm/agents/atlas/github-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_agent_name_is_refused() {
|
||||
for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] {
|
||||
let e = account_path(bad).expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(account_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_own_read_grant_covers_the_path() {
|
||||
let path = account_path("atlas").expect("legal");
|
||||
let own = crate::policy::render_agent("atlas").expect("legal");
|
||||
assert!(
|
||||
covered_by(&own, &path),
|
||||
"no stanza in\n{own}\ncovers {path}"
|
||||
);
|
||||
// The control: another agent's grant does not reach it.
|
||||
let other = crate::policy::render_agent("argus").expect("legal");
|
||||
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_github_key_is_also_a_directory() {
|
||||
// KV-v2 cannot hold a key whose name is also a prefix of another key.
|
||||
let key = account_path("atlas").expect("legal");
|
||||
for other in [
|
||||
crate::forge::agent_token_path("atlas").expect("legal"),
|
||||
crate::forge::accounts_dir("atlas").expect("legal"),
|
||||
] {
|
||||
assert_ne!(key, other);
|
||||
assert!(!other.starts_with(&format!("{key}/")), "{other}");
|
||||
assert!(!key.starts_with(&format!("{other}/")), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Credential {
|
||||
value: "t".to_owned(),
|
||||
})
|
||||
.expect("a String serialises");
|
||||
assert_eq!(json, r#"{"value":"t"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_value() {
|
||||
let c = Credential {
|
||||
value: "0123456789abcdef".to_owned(),
|
||||
};
|
||||
let shown = format!("{c:?}");
|
||||
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||
assert!(shown.contains("redacted"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
//! the rules every path obeys ([`path`]), the translation from this
|
||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||
//! of secret, the path it lives at together with the fields it holds
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`acp`]). Each of those is a thing the controller
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`]). Each of those is a thing the controller
|
||||
//! and a hive must say identically, so it is said once here.
|
||||
//!
|
||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
pub mod acp;
|
||||
pub mod client;
|
||||
pub mod forge;
|
||||
pub mod github;
|
||||
pub mod matrix;
|
||||
pub mod mtls;
|
||||
pub mod path;
|
||||
|
|
|
|||
Loading…
Reference in a new issue