//! 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 { 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 { 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", &"") .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}"); } }