166 lines
5.9 KiB
Rust
166 lines
5.9 KiB
Rust
//! The egress-proxy agreement: the credentials squid authenticates its clients
|
|
//! with, where they live in the store, and what the objects at those paths hold.
|
|
//!
|
|
//! Each agent has one ([`agent_credential_path`]), minted by `swarm-controller`
|
|
//! and read by the agent's hive, which turns it into the hash squid's users file
|
|
//! holds. Each hive has one more ([`selfcheck_path`]) for its own squid
|
|
//! self-check, so the self-check can prove an authenticated request is let
|
|
//! through. 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 name of an agent's leaf.
|
|
pub const AGENT_LEAF: &str = "proxy-credential";
|
|
|
|
/// The name of a hive's self-check leaf.
|
|
pub const SELFCHECK_LEAF: &str = "squid-selfcheck";
|
|
|
|
/// The squid user the self-check logs in as.
|
|
///
|
|
/// An agent's squid user is its name, and agent names cannot hold a `.`
|
|
/// ([`crate::path::checked_segment`]), so this one cannot be an agent's.
|
|
/// `nix/host-modules/hive-squid.nix` spells the same string.
|
|
pub const SELFCHECK_USER: &str = "selfcheck.squid";
|
|
|
|
/// The squid user the self-check logs in as to prove an unrestricted agent
|
|
/// reaches an unlisted name. It authenticates with the same secret as
|
|
/// [`SELFCHECK_USER`] ([`selfcheck_path`]), and is always on squid's
|
|
/// unrestricted list. It cannot be an agent's name for the same reason as
|
|
/// [`SELFCHECK_USER`]. `nix/host-modules/hive-squid.nix` spells the same
|
|
/// string.
|
|
pub const SELFCHECK_UNRESTRICTED_USER: &str = "selfcheck-unrestricted.squid";
|
|
|
|
/// The path holding the secret `agent` authenticates to the egress proxy with.
|
|
///
|
|
/// A flat leaf under the agent's prefix, like
|
|
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
|
/// ([`crate::policy::render_shared_agent`]) already covers it, and so does the
|
|
/// hive's.
|
|
///
|
|
/// # 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 agent_credential_path(agent: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Agent, agent)?;
|
|
Ok(format!("{prefix}/{AGENT_LEAF}"))
|
|
}
|
|
|
|
/// The path holding the secret `hive`'s own squid self-check authenticates with.
|
|
///
|
|
/// Under [`Kind::Hive`], so the grant that reaches it is the hive's own
|
|
/// `swarm/hives/<this hive>/*` stanza: hive `alpha` reads its own and gets a 403
|
|
/// on `beta`'s.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`.
|
|
pub fn selfcheck_path(hive: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Hive, hive)?;
|
|
Ok(format!("{prefix}/{SELFCHECK_LEAF}"))
|
|
}
|
|
|
|
/// What [`agent_credential_path`] and [`selfcheck_path`] hold.
|
|
///
|
|
/// No `Debug` derive: `value` is a live credential, and a derived `Debug` is
|
|
/// one `{:?}` away from a log line.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Credential {
|
|
/// The secret: 32 random bytes, base64url without padding, so it can sit in
|
|
/// a proxy URL's userinfo unescaped.
|
|
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_paths_are_the_leaves_the_nix_side_spells() {
|
|
assert_eq!(
|
|
agent_credential_path("atlas").expect("legal"),
|
|
"swarm/agents/atlas/proxy-credential"
|
|
);
|
|
assert_eq!(
|
|
selfcheck_path("h1").expect("legal"),
|
|
"swarm/hives/h1/squid-selfcheck"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_traversal_in_either_name_is_refused() {
|
|
for bad in ["../argus", "a/b", "a.b", ""] {
|
|
assert!(agent_credential_path(bad).is_err(), "{bad:?}");
|
|
assert!(selfcheck_path(bad).is_err(), "{bad:?}");
|
|
}
|
|
assert!(agent_credential_path("a-b_C9").is_ok());
|
|
assert!(selfcheck_path("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_agent_reads_its_own_credential_and_a_hive_reads_every_one() {
|
|
let path = agent_credential_path("atlas").expect("legal");
|
|
assert!(covered_by(&crate::policy::effective_agent("atlas"), &path));
|
|
assert!(!covered_by(&crate::policy::effective_agent("argus"), &path));
|
|
assert!(covered_by(&crate::policy::effective_hive("h1"), &path));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_reads_its_own_selfcheck_credential_and_no_other_hives() {
|
|
let path = selfcheck_path("h1").expect("legal");
|
|
assert!(covered_by(&crate::policy::effective_hive("h1"), &path));
|
|
assert!(!covered_by(&crate::policy::effective_hive("h2"), &path));
|
|
assert!(!covered_by(&crate::policy::effective_agent("atlas"), &path));
|
|
}
|
|
|
|
#[test]
|
|
fn the_selfcheck_users_are_not_legal_agent_names() {
|
|
for user in [SELFCHECK_USER, SELFCHECK_UNRESTRICTED_USER] {
|
|
assert!(
|
|
crate::path::checked_segment("agent", user).is_err(),
|
|
"{user}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn debug_hides_the_value() {
|
|
let shown = format!(
|
|
"{:?}",
|
|
Credential {
|
|
value: "s3cret".to_owned()
|
|
}
|
|
);
|
|
assert!(!shown.contains("s3cret"), "{shown}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_value_field_is_named_value() {
|
|
let json = serde_json::to_string(&Credential {
|
|
value: "t".to_owned(),
|
|
})
|
|
.expect("a String serialises");
|
|
assert_eq!(json, r#"{"value":"t"}"#);
|
|
}
|
|
}
|