Watch
0
0
Fork
You've already forked hyperhive
0

add one shared templated hive policy keyed on the cert alias name

This commit is contained in:
atlas 2026-10-26 08:57:24 +01:00 • committed by mara
commit 03796f1964
4 changed files with 232 additions and 12 deletions

View file

@ -398,6 +398,18 @@ keeps working through its own policy, which stays attached until every agent
has its entity. An agent's role gains `hive-agent` at its next certificate has its entity. An agent's role gains `hive-agent` at its next certificate
mint. mint.
A hive's cert-auth role `hive-<hive>` likewise attaches a second policy,
`hive-hives`, shared by all hives. A hive's certificate carries its bare name as
the common name, which is the login's alias name, so hives need no entity:
the policy states the hive's own path as
`swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>`
is the cert mount's accessor. The controller reads the accessor at its own start,
writes the policy, and attaches it when it rewrites the roles. No hive name can
spell `hive-hives`: it would be a hive called `hives`, and
`reserved-hive-fragments.nix` refuses the substring `hive`. When the controller
can't read the accessor or write the policy it logs a warning and the role
attaches the hive's own policy alone, which stays attached.
### Per-principal identities ### Per-principal identities
Bao matches a cert-auth role on the certificate's subject, so a certificate is an Bao matches a cert-auth role on the certificate's subject, so a certificate is an

View file

@ -1591,6 +1591,22 @@ let
# that the walk reads nothing. # that the walk reads nothing.
&& pathsUnder "sys/policies/acl/" == [ "path \"sys/policies/acl/hive-*\" {" ]; && pathsUnder "sys/policies/acl/" == [ "path \"sys/policies/acl/hive-*\" {" ];
} }
{
# `read_policy.rs` writes the policy every hive's role attaches as
# `hive-hives` and the one every agent's role attaches as `hive-agent`. Both
# sit under the policy glob the controller already holds, so neither
# adds a grant.
name = "the controller's policy-write glob covers the shared hive and agent policies and not a name outside hive-*";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
globs = map (l: lib.removeSuffix "\" {" (lib.removePrefix "path \"" l)) (
lib.filter (l: lib.hasPrefix "path \"sys/policies/acl/" l) (map lib.trim (lib.splitString "\n" s))
);
covers = name: lib.any (g: lib.hasPrefix (lib.removeSuffix "*" g) "sys/policies/acl/${name}") globs;
in
covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives");
}
{ {
# `matrix_account::hive_sender` writes every hive's sender token and # `matrix_account::hive_sender` writes every hive's sender token and
# `forge::hive_token` every hive's forge token, and nothing else under # `forge::hive_token` every hive's forge token, and nothing else under

View file

@ -16,10 +16,11 @@
//! //!
//! The document names the hive it is written for: one stanza is the same //! The document names the hive it is written for: one stanza is the same
//! everywhere, the other is scoped to the reader's own name //! everywhere, the other is scoped to the reader's own name
//! ([`swarm_secret_client::policy`] explains the asymmetry). There is no shared //! ([`swarm_secret_client::policy`] explains the asymmetry). The same rule is
//! document to hoist this render up to — doing that hands every hive the stanza //! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the
//! naming one of them. The hive list is loaded once because a config change //! reader's own name taken from its login's alias on the cert mount instead of
//! means a redeploy. //! written into the text; a hive's role attaches both. The hive list is loaded
//! once because a config change means a redeploy.
//! //!
//! Every agent's policy is rewritten by the same kind of pass //! Every agent's policy is rewritten by the same kind of pass
//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches //! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches
@ -158,6 +159,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed"); tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed");
} }
} }
let shared = write_shared_policy(&store).await;
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else { let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
tracing::info!("no hive certificate authority configured; hive roles are not managed here"); tracing::info!("no hive certificate authority configured; hive roles are not managed here");
@ -169,7 +171,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
let ca = std::fs::read_to_string(&path) let ca = std::fs::read_to_string(&path)
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?; .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
for hive in hives { for hive in hives {
if let Err(e) = write_role(&store, hive, &ca).await { if let Err(e) = write_role(&store, hive, &ca, shared).await {
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed"); tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
} }
} }
@ -187,13 +189,78 @@ async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// The role for one hive: its own certificate's subject, its own policy. /// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's
async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { /// accessor. Answers whether it is in place: a hive's role attaches it only
/// then.
///
/// Best effort. A hive's own policy grants it the same paths, so a store that
/// refuses the accessor read (the controller's policy may not carry it yet) or
/// the write costs the shared policy and nothing else.
async fn write_shared_policy(store: &SecretStore) -> bool {
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
Ok(accessor) => accessor,
Err(e) => {
tracing::warn!(
error = %e,
"the shared hive policy is not managed: the cert auth mount's accessor could not be read; hives keep their own policy"
);
return false;
}
};
let document = match policy::render_shared_hives(&accessor) {
Ok(document) => document,
Err(e) => {
tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hives keep their own policy");
return false;
}
};
match store
.write_policy(policy::SHARED_HIVE_POLICY, &document)
.await
{
Ok(()) => {
tracing::info!(
policy = policy::SHARED_HIVE_POLICY,
"shared hive policy in place"
);
true
}
Err(e) => {
tracing::warn!(
policy = policy::SHARED_HIVE_POLICY,
error = %e,
"writing the shared hive policy failed; hives keep their own policy"
);
false
}
}
}
/// The policies `hive`'s role attaches: its own, and the shared one when that
/// is in place.
fn attached_policies(hive: &str, shared: bool) -> Result<Vec<String>, Error> {
let mut attached = vec![policy::hive_object_name(hive)?];
if shared {
attached.push(policy::SHARED_HIVE_POLICY.to_owned());
}
Ok(attached)
}
/// The role for one hive: its own certificate's subject, its own policy, and
/// the shared one when `shared`.
async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Result<()> {
let name = policy::hive_object_name(hive)?; let name = policy::hive_object_name(hive)?;
let attached = attached_policies(hive, shared)?;
store store
// The subject is the hive's name: `glue-bao-tls.nix` mints a hive's // The subject is the hive's name: `glue-bao-tls.nix` mints a hive's
// client leaf with its name as the CN, and cert auth matches on that. // client leaf with its name as the CN, and cert auth matches on that.
.write_cert_role(DEFAULT_CERT_MOUNT, &name, ca, hive, &[&name]) .write_cert_role(
DEFAULT_CERT_MOUNT,
&name,
ca,
hive,
&attached.iter().map(String::as_str).collect::<Vec<_>>(),
)
.await .await
.with_context(|| format!("writing the cert-auth role {name}"))?; .with_context(|| format!("writing the cert-auth role {name}"))?;
tracing::info!(hive, role = %name, "hive store role in place"); tracing::info!(hive, role = %name, "hive store role in place");
@ -314,8 +381,8 @@ mod tests {
use std::{cell::Cell, time::Duration}; use std::{cell::Cell, time::Duration};
use super::{ use super::{
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, policy, RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies,
retry_until_ok, attached_policies, policy, retry_until_ok,
}; };
#[test] #[test]
@ -378,4 +445,33 @@ mod tests {
); );
assert_eq!(written[1].1, policy::render_agent("argus").expect("legal")); assert_eq!(written[1].1, policy::render_agent("argus").expect("legal"));
} }
#[test]
fn a_hives_role_attaches_its_own_policy_and_the_shared_one() {
assert_eq!(
attached_policies("pr1ma", true).expect("legal"),
["hive-pr1ma", policy::SHARED_HIVE_POLICY]
);
}
#[test]
fn without_the_shared_policy_the_role_keeps_the_hives_own() {
assert_eq!(
attached_policies("pr1ma", false).expect("legal"),
["hive-pr1ma"]
);
}
#[test]
fn the_shared_text_serves_every_hive_what_its_own_policy_does() {
let shared = policy::render_shared_hives("auth_cert_0a1b2c3d").expect("legal accessor");
let template = "{{identity.entity.aliases.auth_cert_0a1b2c3d.name}}";
for hive in ["pr1ma", "beta", "gamma-2", "delta_3"] {
assert_eq!(
shared.replace(template, hive),
policy::render(hive).expect("legal"),
"{hive}"
);
}
}
} }

View file

@ -151,15 +151,49 @@ fn list_stanza(path: &str) -> String {
/// stanza could grant itself anything. /// stanza could grant itself anything.
pub fn render(hive: &str) -> Result<String, Error> { pub fn render(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?; checked_segment("hive", hive)?;
Ok(hive_document(hive))
}
/// The text of [`render`] with `own` as the hive segment: a checked hive name,
/// or the template [`render_shared_hives`] puts there.
fn hive_document(own: &str) -> String {
let agents = read_stanza(&format!( let agents = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/*", "{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Agent) <&str>::from(Kind::Agent)
)); ));
let own = read_stanza(&format!( let own = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{hive}/*", "{MOUNT}/data/{ROOT}/{}/{own}/*",
<&str>::from(Kind::Hive) <&str>::from(Kind::Hive)
)); ));
Ok(format!("{agents}{own}")) format!("{agents}{own}")
}
/// The one policy every hive's cert-auth role attaches next to its own.
///
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids
/// the substring `hive` in every hive name, so no hive spells it.
pub const SHARED_HIVE_POLICY: &str = "hive-hives";
/// Render the policy [`SHARED_HIVE_POLICY`] holds: the text of [`render`], with
/// the hive's name taken from the alias of its certificate login on the cert
/// auth mount `mount_accessor`.
///
/// A hive's certificate carries its bare name as the common name, and that is
/// the alias name, so the own-path stanza reads `swarm/hives/<hive>/` for
/// whichever hive presents the token. A login whose alias is not a hive's name
/// reads a path where nothing is stored. The `agents/` and `hives/` segments
/// are fixed text, never taken from the identity.
///
/// # Errors
/// [`Error::PathSegment`] when `mount_accessor` holds anything but
/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could
/// close the stanza.
pub fn render_shared_hives(mount_accessor: &str) -> Result<String, Error> {
checked_segment("mount accessor", mount_accessor)?;
Ok(hive_document(&format!(
"{{{{identity.entity.aliases.{mount_accessor}.name}}}}"
)))
} }
/// Render `agent`'s policy document: read on that one agent's credentials, list /// Render `agent`'s policy document: read on that one agent's credentials, list
@ -286,6 +320,68 @@ mod tests {
); );
} }
#[test]
fn the_shared_hive_document_is_each_hives_own_with_the_alias_name_for_the_hive() {
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
assert_eq!(
shared,
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n"
);
// Same text as every hive's own document once the template is filled
// in, for hives that differ in length, case and punctuation.
for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] {
assert_eq!(
shared.replace("{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}", hive),
render(hive).expect("legal"),
"{hive}"
);
}
}
#[test]
fn the_shared_hive_document_names_no_hive_and_grants_no_wider_than_one() {
let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
assert!(!shared.contains("pr1ma"));
assert!(!shared.contains("hives/*"), "{shared}");
assert!(!shared.contains("create"));
assert!(!shared.contains("update"));
assert!(!shared.contains("delete"));
assert!(!shared.contains("list"));
// The control: the walk finds the stanzas that are there.
assert_eq!(stanza_paths(&shared).len(), 2, "{shared}");
assert!(
stanza_paths(&shared)
.iter()
.any(|g| g.starts_with("swarm/hives/{{"))
);
}
#[test]
fn an_accessor_that_could_close_the_stanza_is_refused() {
for bad in ["", "a.b", "a}}b", "a\"b", "a/b", "a b"] {
assert!(
matches!(render_shared_hives(bad), Err(Error::PathSegment { .. })),
"{bad:?}"
);
}
}
#[test]
fn the_shared_hive_policy_name_is_writable_and_no_agents() {
assert!(SHARED_HIVE_POLICY.starts_with(HIVE_PREFIX));
assert!(
agents_from_role_names(&[SHARED_HIVE_POLICY.to_owned()]).is_empty(),
"it must not be read back as an agent"
);
assert_ne!(SHARED_HIVE_POLICY, SHARED_AGENT_POLICY);
// The only hive name that would render it carries `hive`.
assert_eq!(
hive_object_name("hives").expect("legal as a segment"),
SHARED_HIVE_POLICY
);
}
#[test] #[test]
fn a_hive_reaches_no_swarm_services_secret() { fn a_hive_reaches_no_swarm_services_secret() {
// A swarm service's OIDC client secret is read under a certificate that // A swarm service's OIDC client secret is read under a certificate that