Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/gitlab.rs

260 lines
9.6 KiB
Rust

//! The GitLab agreement: where an agent's accounts on GitLab instances live in
//! the store, and what the objects at those paths hold.
//!
//! An operator hands `swarm-controller` a base URL and a token, the controller
//! stores them at [`account_path`], and `nix/agent-modules/gitlab-accounts.nix`
//! lists [`accounts_dir`] and reads each account under the agent's own
//! certificate. The directory is named apart from [`crate::forge`]'s `forge/`,
//! so a label of one kind is never read as the other.
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, checked_segment, principal_prefix},
};
/// The path holding `agent`'s account on the GitLab instance it calls `label`.
///
/// # Errors
/// [`Error::PathSegment`] when either name 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, label: &str) -> Result<String, Error> {
checked_segment("label", label)?;
Ok(format!("{}/{label}", accounts_dir(agent)?))
}
/// The directory every one of `agent`'s [`account_path`]s is under, in the
/// form [`crate::SecretStore::list`] takes.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
Ok(format!("{prefix}/gitlab"))
}
/// The shape of an instance URL the agent end accepts, as a regular
/// expression: `gitlab-accounts.nix` filters every stored account through
/// exactly this pattern (a `jq` `test`) and leaves out one that doesn't match.
/// [`is_instance_url`] is the same rule in Rust, which `swarm-controller`
/// applies before it stores an account, so a stored URL is always one the
/// agent uses. A module-eval case pins the nix side to this literal.
pub const URL_PATTERN: &str = "^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$";
/// Whether `url` matches [`URL_PATTERN`]: `http` or `https`, a host of letters,
/// digits, dots and hyphens, an optional numeric port, and an optional path of
/// letters, digits and `. _ ~ / -`. No user information, query or fragment.
#[must_use]
pub fn is_instance_url(url: &str) -> bool {
let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
else {
return false;
};
let (authority, path) = rest.find('/').map_or((rest, ""), |i| rest.split_at(i));
let (host, port) = match authority.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
};
!host.is_empty()
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
&& port.is_none_or(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
&& path
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._~/-".contains(&b))
}
/// What an [`account_path`] holds.
///
/// No `Debug` derive: `value` is a live GitLab credential, and a derived
/// `Debug` is one `{:?}` away from a log line.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// The access token. `gitlab-accounts.nix` reads this field by name.
pub value: String,
/// The instance's base URL, without a trailing slash. Not secret.
/// `gitlab-accounts.nix` reads this field by name too.
pub url: String,
}
impl std::fmt::Debug for Account {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Account")
.field("value", &"<redacted>")
.field("url", &self.url)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whether `policy` has a stanza whose prefix `path` starts with.
fn covers(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| path.starts_with(prefix))
})
}
#[test]
fn an_account_and_its_directory_land_under_the_agent_prefix() {
// Spelled out: `gitlab-accounts.nix` and `swarm-bao.nix` spell the
// same strings.
assert_eq!(
account_path("atlas", "work").expect("both segments are legal"),
"swarm/agents/atlas/gitlab/work"
);
assert_eq!(
accounts_dir("atlas").expect("legal"),
"swarm/agents/atlas/gitlab"
);
}
#[test]
fn the_gitlab_directory_is_not_the_forgejo_one() {
let gitlab = accounts_dir("atlas").expect("legal");
let forge = crate::forge::accounts_dir("atlas").expect("legal");
assert_ne!(gitlab, forge);
assert!(!gitlab.starts_with(&format!("{forge}/")), "{gitlab}");
assert!(!forge.starts_with(&format!("{gitlab}/")), "{forge}");
}
#[test]
fn a_name_that_could_escape_the_directory_is_refused() {
for bad in ["../argus", "a/b", "a.b", ""] {
assert!(account_path("atlas", bad).is_err(), "label {bad:?}");
assert!(account_path(bad, "work").is_err(), "agent {bad:?}");
assert!(accounts_dir(bad).is_err(), "agent {bad:?}");
}
// The control: the legal charset stays reachable.
assert!(account_path("a-b_C9", "d-e0").is_ok());
}
#[test]
fn the_agents_own_grants_list_its_accounts_and_read_each() {
let policy = crate::policy::effective_agent("atlas");
let dir = accounts_dir("atlas").expect("legal");
assert!(covers(&policy, &format!("secret/metadata/{dir}/")));
assert!(covers(
&policy,
&format!(
"secret/data/{}",
account_path("atlas", "work").expect("legal")
)
));
// The control: another agent's accounts are outside both grants.
let other = accounts_dir("argus").expect("legal");
assert!(!covers(&policy, &format!("secret/metadata/{other}/")));
assert!(!covers(
&policy,
&format!(
"secret/data/{}",
account_path("argus", "work").expect("legal")
)
));
}
#[test]
fn the_agent_can_write_nothing_under_its_accounts() {
// Egress is derived from these leaves, so an agent that could write
// one would choose its own allowed hosts.
let policy = crate::policy::effective_agent("atlas");
let writes = |path: &str| {
policy
.split("path \"")
.skip(1)
.filter(|stanza| {
stanza.split_once("\" {").is_some_and(|(p, _)| {
p.strip_suffix('*')
.map_or(p == path, |prefix| path.starts_with(prefix))
})
})
.any(|stanza| {
["create", "update", "delete"]
.iter()
.any(|cap| stanza.contains(&format!("\"{cap}\"")))
})
};
let leaf = format!(
"secret/data/{}",
account_path("atlas", "work").expect("legal")
);
assert!(!writes(&leaf), "{policy}");
// The control: the same scan finds the one write the agent has.
let seed = format!(
"secret/data/{}/{}",
principal_prefix(Kind::Agent, "atlas").expect("legal"),
crate::backend_env::LEAF
);
assert!(writes(&seed), "{policy}");
}
/// One table for the rule: the same rows were fed to `jq`'s `test` with
/// [`URL_PATTERN`] when this was written, and the module-eval case pins
/// `gitlab-accounts.nix` to the same pattern.
#[test]
fn the_instance_url_rule_takes_exactly_the_documented_shapes() {
for ok in [
"https://gitlab.example.org",
"http://gitlab.lan",
"https://GitLab.Example.org:8443",
"https://gitlab.example.org/gl",
"https://gitlab.example.org/apps/gitlab_2/x~y.z-w",
"https://10.0.0.5",
] {
assert!(is_instance_url(ok), "{ok}");
}
for bad in [
"",
"ftp://gitlab.example.org",
"gitlab.example.org",
"https://",
"https://:8443",
"https://gitlab.example.org:",
"https://gitlab.example.org:80a",
"https://user@gitlab.example.org",
"https://gitlab.example.org/@x",
"https://gitlab.example.org/?x=1",
"https://gitlab.example.org?x=1",
"https://gitlab.example.org/#frag",
"https://gitlab.example.org/a b",
"https://gitlab.example.org/a%20b",
"https://gitlab.example.org/a:b",
"https://gitlab_x.example.org",
"https://[::1]",
] {
assert!(!is_instance_url(bad), "{bad:?}");
}
}
#[test]
fn the_account_fields_match_what_the_nix_reader_asks_for() {
let json = serde_json::to_string(&Account {
value: "t".to_owned(),
url: "https://gitlab.example.org".to_owned(),
})
.expect("two Strings serialise");
assert_eq!(json, r#"{"value":"t","url":"https://gitlab.example.org"}"#);
}
#[test]
fn debug_never_prints_an_accounts_value() {
let a = Account {
value: "0123456789abcdef".to_owned(),
url: "https://gitlab.example.org".to_owned(),
};
let shown = format!("{a:?}");
assert!(!shown.contains("0123456789abcdef"), "{shown}");
assert!(shown.contains("gitlab.example.org"), "{shown}");
}
}