260 lines
9.6 KiB
Rust
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}");
|
|
}
|
|
}
|