- docs/web-ui/README.md: drop the removed Credentials tile from the
H0M3 hub list.
- api-error.ts, hive-warn.js: rewrite comments pointing at
dashboard/src/credentials.js and credentials.html, now deleted, to
state what the code does instead.
- swarm-secret-client/src/github.rs: correct the Credential.value doc
to the actual read command (bao kv get -format=json | jq
.data.data.value), keeping the load-bearing-field-name point.
- github-token.nix, agent-github-bao.nix, LinkGithubAccountForm.tsx:
restate added comments as current behaviour instead of changelog
wording ("has always had", "holds the token now").
Refs #4347
129 lines
4.5 KiB
Rust
129 lines
4.5 KiB
Rust
//! 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 -format=json` and pulls `.data.data.value` out with
|
|
/// `jq`, 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}");
|
|
}
|
|
}
|