add one shared templated hive policy keyed on the cert alias name
This commit is contained in:
parent
e437f817dd
commit
03796f1964
4 changed files with 232 additions and 12 deletions
|
|
@ -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
|
||||
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
|
||||
|
||||
Bao matches a cert-auth role on the certificate's subject, so a certificate is an
|
||||
|
|
|
|||
|
|
@ -1591,6 +1591,22 @@ let
|
|||
# that the walk reads nothing.
|
||||
&& 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
|
||||
# `forge::hive_token` every hive's forge token, and nothing else under
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@
|
|||
//!
|
||||
//! 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
|
||||
//! ([`swarm_secret_client::policy`] explains the asymmetry). There is no shared
|
||||
//! document to hoist this render up to — doing that hands every hive the stanza
|
||||
//! naming one of them. The hive list is loaded once because a config change
|
||||
//! means a redeploy.
|
||||
//! ([`swarm_secret_client::policy`] explains the asymmetry). The same rule is
|
||||
//! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the
|
||||
//! reader's own name taken from its login's alias on the cert mount instead of
|
||||
//! 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
|
||||
//! ([`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");
|
||||
}
|
||||
}
|
||||
let shared = write_shared_policy(&store).await;
|
||||
|
||||
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");
|
||||
|
|
@ -169,7 +171,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
|
|||
let ca = std::fs::read_to_string(&path)
|
||||
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -187,13 +189,78 @@ async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The role for one hive: its own certificate's subject, its own policy.
|
||||
async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
|
||||
/// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's
|
||||
/// 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 attached = attached_policies(hive, shared)?;
|
||||
store
|
||||
// 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.
|
||||
.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
|
||||
.with_context(|| format!("writing the cert-auth role {name}"))?;
|
||||
tracing::info!(hive, role = %name, "hive store role in place");
|
||||
|
|
@ -314,8 +381,8 @@ mod tests {
|
|||
use std::{cell::Cell, time::Duration};
|
||||
|
||||
use super::{
|
||||
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, policy,
|
||||
retry_until_ok,
|
||||
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies,
|
||||
attached_policies, policy, retry_until_ok,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -378,4 +445,33 @@ mod tests {
|
|||
);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,15 +151,49 @@ fn list_stanza(path: &str) -> String {
|
|||
/// stanza could grant itself anything.
|
||||
pub fn render(hive: &str) -> Result<String, Error> {
|
||||
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!(
|
||||
"{MOUNT}/data/{ROOT}/{}/*",
|
||||
<&str>::from(Kind::Agent)
|
||||
));
|
||||
let own = read_stanza(&format!(
|
||||
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
||||
"{MOUNT}/data/{ROOT}/{}/{own}/*",
|
||||
<&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
|
||||
|
|
@ -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]
|
||||
fn a_hive_reaches_no_swarm_services_secret() {
|
||||
// A swarm service's OIDC client secret is read under a certificate that
|
||||
|
|
|
|||
Loading…
Reference in a new issue