add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab
This commit is contained in:
parent
3b8ab17240
commit
d6e983995d
1 changed files with 1322 additions and 51 deletions
260
swarm-secret-client/src/gitlab.rs
Normal file
260
swarm-secret-client/src/gitlab.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! 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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue