diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index 699823ca..efa984b7 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -384,6 +384,20 @@ nothing else: `read` on the values there and `list` on their names. agent's at its own start, so a change to it reaches existing agents with the next controller restart. +The agent's cert-auth role attaches a second policy, `hive-agent`, shared by +all agents. It states the same rule with `{{identity.entity.name}}` where the +per-agent policy has the agent's name, so the rule is one document. For that +the controller names each agent's identity entity after the agent: at agent +creation, and for every agent at its own start, it reads the cert mount's +accessor and creates the entity `` with the alias `hive-agent-`, +or renames the entity a first login created under a generated name. When the alias +sits on any other entity, the controller logs an error and changes nothing. It needs +the `identity/` and `sys/auth/cert` grants in `swarm-bao.nix`'s +`controllerPolicyText` for that. Without them it logs a warning and the agent +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. + ### Per-principal identities Bao matches a cert-auth role on the certificate's subject, so a certificate is an diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 60280ee7..da640965 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -380,6 +380,16 @@ let # The agent PKI grant is its only path on that mount: it can ask the one role # for a certificate, not write that role or reach the issuer. + # The identity grants name each agent's login entity after the agent + # (`agent_entity.rs`). `sys/auth/cert` carries `sudo` because a plain `read` + # of an auth mount is refused, and it is that one mount: it yields the + # accessor an entity alias names the mount by. Every identity stanza lists the + # request keys it takes, so the grants cannot set an entity's `policies`, which + # would attach any policy to a login. The entity and alias names are not + # scoped further, by accessor or by entity: an agent's name is not known at + # deploy time, so the guard in `agent_entity.rs` (rename only a name the store + # generated, never move an alias) is what prevents an identity swap. + # The two `list` paths end in `/`: bao checks a LIST against the directory # with a trailing slash, where `+` matches the empty last segment, so a # slashless list path loses that request to the `+/matrix/+` and `+/forge/+` @@ -401,6 +411,41 @@ let capabilities = ["list"] } + path "sys/auth/cert" { + capabilities = ["read", "sudo"] + } + + path "identity/lookup/entity" { + capabilities = ["update"] + allowed_parameters = { + "alias_name" = [] + "alias_mount_accessor" = [] + } + } + + path "identity/entity/name/+" { + capabilities = ["create", "read", "update"] + allowed_parameters = { + "name" = [] + } + } + + path "identity/entity/id/+" { + capabilities = ["update"] + allowed_parameters = { + "name" = [] + } + } + + path "identity/entity-alias" { + capabilities = ["create", "update"] + allowed_parameters = { + "name" = [] + "mount_accessor" = [] + "canonical_id" = [] + } + } + path "${credentialMountPath}/data/swarm/agents/*" { capabilities = ["create", "read", "update"] } diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index a6e7b314..6ea52166 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1558,6 +1558,39 @@ let in lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"read\", \"update\"]" s; } + { + # `agent_entity.rs` names each agent's login entity after the agent. The + # accessor read is `read` + `sudo` on the one mount, the lookup and both + # writes list the request keys they take (so `policies`, which would + # attach any policy to a login, is refused), and nothing else under + # `identity/` or `sys/auth` is reachable. Pinned as whole stanzas and as + # the exact list of identity and sys/auth paths, so a wider glob or an + # added path fails. + name = "the controller's identity grants are the five stanzas agent_entity.rs needs and nothing else"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; + pathsUnder = + prefix: lib.filter (l: lib.hasPrefix "path \"${prefix}" l) (map lib.trim (lib.splitString "\n" s)); + in + lib.hasInfix "path \"sys/auth/cert\" {\n capabilities = [\"read\", \"sudo\"]\n}" s + && lib.hasInfix "path \"identity/lookup/entity\" {\n capabilities = [\"update\"]\n allowed_parameters = {\n \"alias_name\" = []\n \"alias_mount_accessor\" = []\n }\n}" s + && lib.hasInfix "path \"identity/entity/name/+\" {\n capabilities = [\"create\", \"read\", \"update\"]\n allowed_parameters = {\n \"name\" = []\n }\n}" s + && lib.hasInfix "path \"identity/entity/id/+\" {\n capabilities = [\"update\"]\n allowed_parameters = {\n \"name\" = []\n }\n}" s + && lib.hasInfix "path \"identity/entity-alias\" {\n capabilities = [\"create\", \"update\"]\n allowed_parameters = {\n \"name\" = []\n \"mount_accessor\" = []\n \"canonical_id\" = []\n }\n}" s + && + pathsUnder "identity/" == [ + "path \"identity/lookup/entity\" {" + "path \"identity/entity/name/+\" {" + "path \"identity/entity/id/+\" {" + "path \"identity/entity-alias\" {" + ] + && pathsUnder "sys/auth" == [ "path \"sys/auth/cert\" {" ] + # The control: the same walk finds the policy-namespace stanza that is + # there, so an empty result above means nothing else exists rather than + # that the walk reads nothing. + && pathsUnder "sys/policies/acl/" == [ "path \"sys/policies/acl/hive-*\" {" ]; + } { # `matrix_account::hive_sender` writes every hive's sender token and # `forge::hive_token` every hive's forge token, and nothing else under diff --git a/swarm-controller/src/agent_entity.rs b/swarm-controller/src/agent_entity.rs new file mode 100644 index 00000000..0bbc0d2d --- /dev/null +++ b/swarm-controller/src/agent_entity.rs @@ -0,0 +1,299 @@ +//! The identity entity an agent's login lands on, named after the agent. +//! +//! A cert login creates an entity with a generated name and an alias named for +//! the certificate's common name, `hive-agent-`. The shared policy +//! ([`policy::render_shared_agent`]) reads `{{identity.entity.name}}`, so the +//! entity has to be called ``. This module makes it so, either by +//! creating the entity and its alias before the first login or by renaming the +//! entity a login already created. The alias is unique per auth mount and +//! name, so there is never a second entity to pick between. +//! +//! An entity's name is its access under the shared policy, so only an entity +//! the store named itself is ever renamed ([`is_generated_name`]) and no alias +//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is +//! written and an operator decides. +//! +//! Everything else here is best effort. An agent's own policy still grants it +//! its paths, so a store that refuses these calls costs the shared policy and +//! nothing else: the failure is logged at `warn` and the caller goes on. The +//! grants are the `identity/` stanzas of `swarm-bao.nix`'s +//! `controllerPolicyText`, and a controller can start before they deploy. + +use anyhow::{Context, Result, bail}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, EntityRef}, + policy, +}; + +/// What the store holds for one agent's entity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seen { + /// The entity that owns the agent's alias on the cert mount. + pub by_alias: Option, + /// The id of the entity named after the agent. + pub by_name: Option, +} + +/// What to do about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Plan { + /// The alias's entity is already named after the agent. + Keep, + /// A login created the entity under a generated name. + Rename { id: String }, + /// An entity named after the agent exists, and the alias is on no entity. + AddAlias { entity_id: String }, + /// Neither the entity nor the alias exists. + CreateAndAlias, + /// The alias sits on an entity that is not the agent's: one with a name of + /// someone's choosing, or one while another entity already has the + /// agent's name. Nothing is written. + Conflict { alias: String, entity_name: String }, +} + +/// Whether `name` is one the store gave an entity at login: `entity_`, eight +/// lowercase hex digits, and the root namespace's `.root` suffix, which stores +/// that predate namespaces leave off. +/// +/// Exact on purpose. A name that is merely close, such as `entity_` followed +/// by a word, is somebody's choice of name. +#[must_use] +pub fn is_generated_name(name: &str) -> bool { + let Some(rest) = name.strip_prefix("entity_") else { + return false; + }; + let digits = rest.strip_suffix(".root").unwrap_or(rest); + digits.len() == 8 + && digits + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// The plan that leaves the alias on an entity named `agent`. +#[must_use] +pub fn plan(agent: &str, seen: &Seen) -> Plan { + match (&seen.by_alias, &seen.by_name) { + (Some(entity), _) if entity.name == agent => Plan::Keep, + (Some(entity), None) if is_generated_name(&entity.name) => Plan::Rename { + id: entity.id.clone(), + }, + (Some(entity), _) => Plan::Conflict { + alias: format!("{}{agent}", policy::AGENT_PREFIX), + entity_name: entity.name.clone(), + }, + (None, Some(named)) => Plan::AddAlias { + entity_id: named.clone(), + }, + (None, None) => Plan::CreateAndAlias, + } +} + +/// Make sure every agent in `agents` has its entity, logging what could not be +/// done. Never fails: see the module header. +pub async fn ensure_agents(store: &SecretStore, agents: &[String]) { + let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { + Ok(accessor) => accessor, + Err(e) => { + tracing::warn!( + error = %e, + "agent entities are not managed: the cert auth mount's accessor could not be read; agents keep their own policy" + ); + return; + } + }; + let mut changed = 0_usize; + for agent in agents { + match ensure_one(store, &accessor, agent).await { + Ok(Plan::Keep | Plan::Conflict { .. }) => {} + Ok(_) => changed += 1, + Err(e) => { + tracing::warn!(agent, error = %format!("{e:#}"), "this agent's identity entity could not be set up"); + } + } + } + tracing::info!( + changed, + agents = agents.len(), + "agent identity entities checked" + ); +} + +/// [`ensure_agents`] for one agent. +pub async fn ensure_agent(store: &SecretStore, agent: &str) { + ensure_agents(store, &[agent.to_owned()]).await; +} + +/// Observe, plan and apply for one agent. +async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result { + let alias = policy::agent_object_name(agent)?; + let seen = Seen { + by_alias: store + .entity_by_alias(&alias, accessor) + .await + .with_context(|| format!("looking up the entity owning alias {alias}"))?, + by_name: store + .entity_id_by_name(agent) + .await + .with_context(|| format!("looking up the entity named {agent}"))?, + }; + let plan = plan(agent, &seen); + match &plan { + Plan::Keep => {} + Plan::Rename { id } => { + store + .rename_entity(id, agent) + .await + .with_context(|| format!("renaming entity {id} to {agent}"))?; + tracing::info!(agent, entity = %id, "agent identity entity renamed"); + } + Plan::AddAlias { entity_id } => { + store + .create_entity_alias(&alias, accessor, entity_id) + .await + .with_context(|| format!("adding alias {alias} to entity {entity_id}"))?; + tracing::info!(agent, entity = %entity_id, "agent identity alias added"); + } + Plan::CreateAndAlias => { + store + .create_entity(agent) + .await + .with_context(|| format!("creating the entity {agent}"))?; + let Some(entity_id) = store.entity_id_by_name(agent).await? else { + bail!("the entity {agent} was created and is not there to read back"); + }; + store + .create_entity_alias(&alias, accessor, &entity_id) + .await + .with_context(|| format!("adding alias {alias} to entity {entity_id}"))?; + tracing::info!(agent, entity = %entity_id, "agent identity entity created"); + } + Plan::Conflict { alias, entity_name } => { + tracing::error!( + agent, + alias, + entity_name, + "the agent's alias is on an entity that is not the agent's; nothing was changed and the agent keeps its own policy" + ); + } + } + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entity(id: &str, name: &str) -> EntityRef { + EntityRef { + id: id.to_owned(), + name: name.to_owned(), + } + } + + fn conflict(entity_name: &str) -> Plan { + Plan::Conflict { + alias: "hive-agent-atlas".to_owned(), + entity_name: entity_name.to_owned(), + } + } + + #[test] + fn an_entity_already_named_after_the_agent_is_kept() { + let seen = Seen { + by_alias: Some(entity("e1", "atlas")), + by_name: Some("e1".to_owned()), + }; + assert_eq!(plan("atlas", &seen), Plan::Keep); + } + + #[test] + fn nothing_at_all_is_created_with_its_alias() { + let seen = Seen { + by_alias: None, + by_name: None, + }; + assert_eq!(plan("atlas", &seen), Plan::CreateAndAlias); + } + + #[test] + fn an_entity_a_login_named_is_renamed() { + for generated in ["entity_1a2b3c4d.root", "entity_1a2b3c4d"] { + let seen = Seen { + by_alias: Some(entity("e1", generated)), + by_name: None, + }; + assert_eq!( + plan("atlas", &seen), + Plan::Rename { + id: "e1".to_owned() + }, + "{generated}" + ); + } + } + + #[test] + fn an_entity_somebody_named_is_never_renamed() { + let seen = Seen { + by_alias: Some(entity("e1", "bob")), + by_name: None, + }; + assert_eq!(plan("atlas", &seen), conflict("bob")); + } + + #[test] + fn a_name_only_close_to_the_generated_shape_is_somebodys_choice() { + for name in [ + "entity_", + "entity_backup", + "entity_1a2b3c4", + "entity_1a2b3c4d5", + "entity_1A2B3C4D", + "entity_1a2b3c4g", + "entity_1a2b3c4d.root2", + "entity_1a2b3c4d.other", + "entity_1a2b3c4d.root.root", + "xentity_1a2b3c4d", + "entity-1a2b3c4d", + ] { + let seen = Seen { + by_alias: Some(entity("e1", name)), + by_name: None, + }; + assert_eq!(plan("atlas", &seen), conflict(name), "{name}"); + } + } + + #[test] + fn an_entity_without_the_alias_gets_it() { + let seen = Seen { + by_alias: None, + by_name: Some("e1".to_owned()), + }; + assert_eq!( + plan("atlas", &seen), + Plan::AddAlias { + entity_id: "e1".to_owned() + } + ); + } + + #[test] + fn an_alias_on_another_entity_while_the_agents_name_is_taken_is_a_conflict() { + let seen = Seen { + by_alias: Some(entity("e1", "entity_1a2b3c4d.root")), + by_name: Some("e2".to_owned()), + }; + assert_eq!(plan("atlas", &seen), conflict("entity_1a2b3c4d.root")); + } + + #[test] + fn an_alias_on_a_foreign_entity_is_not_moved_to_the_agents() { + let seen = Seen { + by_alias: Some(entity("e1", "bob")), + by_name: Some("e2".to_owned()), + }; + assert_eq!(plan("atlas", &seen), conflict("bob")); + } +} diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index c1396a02..aead0378 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -8,7 +8,8 @@ //! and its grant covers exactly the calls made here (`swarm-bao.nix`'s //! `controllerPolicyText`: `update` on the agent PKI role's `issue` path, and //! `create/update` on `secret/data/swarm/agents/*`, on -//! `sys/policies/acl/hive-*`, and on `auth/cert/certs/hive-*`). +//! `sys/policies/acl/hive-*`, and on `auth/cert/certs/hive-*`, plus the +//! `identity/` and `sys/auth/cert` stanzas [`crate::agent_entity`] uses). //! //! **Two credentials, deliberately unrelated.** The certificate reaches the //! store; the queue secret identifies the agent to the swarm queue. Both sit @@ -67,13 +68,18 @@ struct RoleInputs<'a> { ca: &'a str, /// The policy document the role attaches. policy: String, + /// The policies the role attaches by name: the agent's own, and the one every + /// agent shares. + attached: [String; 2], } fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result> { + let name = policy::agent_object_name(agent)?; Ok(RoleInputs { - name: policy::agent_object_name(agent)?, + name: name.clone(), ca: &credential.ca, policy: policy::render_agent(agent)?, + attached: [name, policy::SHARED_AGENT_POLICY.to_owned()], }) } @@ -119,9 +125,9 @@ pub(crate) fn generate_queue_secret() -> Result { /// identity at the swarm queue, minted here so that the credential an agent /// presents names *it* rather than its hive; /// 4. write the ACL document [`policy::render_agent`] renders — read and list on -/// this one agent's paths and nothing else; -/// 5. write the cert-auth role that ties the three together, pinning the CA -/// the store named as this leaf's issuer. +/// this one agent's paths — and the shared [`policy::render_shared_agent`]; +/// 5. write the cert-auth role that ties the three together, attaching both +/// policies and pinning the CA the store named as this leaf's issuer. /// /// Policy before role, for the reason `read_policy::provision` gives: the role /// names the policy, so the other order leaves a window in which it points at @@ -199,17 +205,29 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> { .write_policy(&inputs.name, &inputs.policy) .await .with_context(|| format!("writing the read policy {}", inputs.name))?; + store + .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) + .await + .with_context(|| { + format!( + "writing the shared agent policy {}", + policy::SHARED_AGENT_POLICY + ) + })?; store .write_cert_role( DEFAULT_CERT_MOUNT, &inputs.name, inputs.ca, &inputs.name, - &inputs.name, + &inputs.attached.each_ref().map(String::as_str), ) .await .with_context(|| format!("writing the cert-auth role {}", inputs.name))?; tracing::info!(agent, %path, role = %name, "agent store identity published"); + // Before the read-back below, whose login would otherwise create the + // entity under a generated name. + crate::agent_entity::ensure_agent(&store, agent).await; read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?; tracing::info!( @@ -450,6 +468,11 @@ mod tests { "the CN the leaf is issued for" ); assert_eq!(inputs.ca, "AGENT-CA"); + assert_eq!( + inputs.attached, + ["hive-agent-atlas", policy::SHARED_AGENT_POLICY], + "the role attaches the agent's own policy and the shared one" + ); assert_eq!(inputs.policy, policy::render_agent("atlas").expect("legal")); assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy); diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 1f8c9a00..f3990f92 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -23,6 +23,7 @@ use anyhow::{Context, Result}; mod agent_archetype; mod agent_control; +mod agent_entity; mod agent_icon; mod agent_identity; mod agent_input; diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index 61777762..0dbaf69e 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -193,7 +193,7 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { 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, &[&name]) .await .with_context(|| format!("writing the cert-auth role {name}"))?; tracing::info!(hive, role = %name, "hive store role in place"); @@ -234,9 +234,10 @@ pub async fn ensure_agent_policies() { } } -/// One pass: log in once, list the agents, write each one's policy. +/// One pass: log in once, list the agents, write the shared agent policy and +/// each one's own, and give each its identity entity. /// -/// The policy only. An agent's cert-auth role and certificate are +/// Policies and entities only. An agent's cert-auth role and certificate are /// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so /// it is not what a pass on every start may call. /// @@ -260,6 +261,21 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> { .list_cert_roles(DEFAULT_CERT_MOUNT) .await .map_err(|e| Unreachable(format!("listing the store's cert-auth roles: {e}")))?; + // First, so the cert-auth role that names it never points at nothing. + match store + .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) + .await + { + Ok(()) => tracing::info!( + policy = policy::SHARED_AGENT_POLICY, + "shared agent policy in place" + ), + Err(e) => tracing::warn!( + policy = policy::SHARED_AGENT_POLICY, + error = %e, + "writing the shared agent policy failed" + ), + } let policies = agent_policies(&roles); let mut written = 0_usize; for (name, document) in &policies { @@ -271,6 +287,7 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> { } } tracing::info!(written, agents = policies.len(), "agent policies rewritten"); + crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await; Ok(()) } diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index ef4125f3..829b4b56 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -3,7 +3,7 @@ use std::time::Duration; use rustify_derive::Endpoint; -use serde::{Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use vaultrs::{ api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse}, client::{Client, VaultClient, VaultClientSettings, VaultClientSettingsBuilder}, @@ -355,7 +355,7 @@ impl SecretStore { /// Create or replace the cert-auth role `name`, so that a certificate /// issued by `certificate` and carrying `common_name` logs in with - /// `policy`. + /// `policies`. /// /// `certificate` is the authority by value, not a path: the store keeps /// its own copy inside the role and never reads a file of ours. @@ -369,12 +369,12 @@ impl SecretStore { name: &str, certificate: &str, common_name: &str, - policy: &str, + policies: &[&str], ) -> Result<(), Error> { let mut opts = vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder(); opts.allowed_common_names(vec![common_name.to_owned()]) - .token_policies(vec![policy.to_owned()]); + .token_policies(policies.iter().map(|p| (*p).to_owned()).collect::>()); vaultrs::auth::cert::ca_cert_role::set( &self.inner, mount, @@ -428,6 +428,195 @@ impl SecretStore { Err(e) => Err(e.into()), } } + + /// The accessor of the auth method mounted at `mount`: the id an entity + /// alias names that mount by. + /// + /// # Errors + /// [`Error::Vault`] when the token's policy does not grant `read` and + /// `sudo` on `sys/auth/` (a plain `read` is refused), or no such + /// mount exists. + pub async fn auth_mount_accessor(&self, mount: &str) -> Result { + let info = vaultrs::api::exec_with_result( + &self.inner, + ReadAuthMount { + mount: mount.to_owned(), + }, + ) + .await?; + Ok(info.accessor) + } + + /// The identity entity that the alias `alias_name` on the auth mount + /// `mount_accessor` belongs to, or `None` when neither a login nor a + /// caller has created that alias. + /// + /// # Errors + /// [`Error::Vault`] for anything but an empty answer: a denial, or an + /// unreachable store. + pub async fn entity_by_alias( + &self, + alias_name: &str, + mount_accessor: &str, + ) -> Result, Error> { + let request = LookupEntity { + alias_name: alias_name.to_owned(), + alias_mount_accessor: mount_accessor.to_owned(), + }; + match vaultrs::api::exec_with_result(&self.inner, request).await { + Ok(entity) => Ok(Some(entity)), + // A lookup that finds nothing is answered with an empty 204, which + // does not parse as the envelope. Any other parse failure stays one. + Err(vaultrs::error::ClientError::RestClientError { + source: rustify::errors::ClientError::ResponseParseError { content, .. }, + }) if content.as_deref().is_none_or(str::is_empty) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// The id of the identity entity named `name`, or `None` when there is + /// none. Only a 404 is absence, for the reason + /// [`read_optional`][Self::read_optional] gives. + /// + /// # Errors + /// [`Error::Vault`] for anything that is not a 404. + pub async fn entity_id_by_name(&self, name: &str) -> Result, Error> { + let request = ReadEntityByName { + name: name.to_owned(), + }; + match vaultrs::api::exec_with_result(&self.inner, request).await { + Ok(entity) => Ok(Some(entity.id)), + Err(e) if is_absent(&e) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Create the identity entity `name` with no policies, metadata or + /// aliases; one that exists is left as it is. + /// + /// # Errors + /// [`Error::Vault`] when the token's policy does not cover + /// `identity/entity/name/`. + pub async fn create_entity(&self, name: &str) -> Result<(), Error> { + let request = CreateEntityByName { + name: name.to_owned(), + }; + vaultrs::api::exec_with_empty(&self.inner, request).await?; + Ok(()) + } + + /// Give the identity entity `id` the name `name`. + /// + /// # Errors + /// [`Error::Vault`] when another entity already has that name, or the + /// token's policy does not cover `identity/entity/id/`. + pub async fn rename_entity(&self, id: &str, name: &str) -> Result<(), Error> { + let request = RenameEntity { + id: id.to_owned(), + name: name.to_owned(), + }; + vaultrs::api::exec_with_empty(&self.inner, request).await?; + Ok(()) + } + + /// Attach the alias `alias_name` on the auth mount `mount_accessor` to + /// the entity `entity_id`, so that a login under that name lands on it. + /// + /// # Errors + /// [`Error::Vault`] when the store refuses, or the token's policy does + /// not cover `identity/entity-alias`. + pub async fn create_entity_alias( + &self, + alias_name: &str, + mount_accessor: &str, + entity_id: &str, + ) -> Result<(), Error> { + let request = CreateEntityAlias { + name: alias_name.to_owned(), + mount_accessor: mount_accessor.to_owned(), + canonical_id: entity_id.to_owned(), + }; + vaultrs::api::exec_with_empty(&self.inner, request).await?; + Ok(()) + } +} + +/// An identity entity as a lookup names it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct EntityRef { + /// The store's id for the entity. + pub id: String, + /// The entity's name: generated by the store when a login created the + /// entity, the caller's when the caller did. + pub name: String, +} + +/// What this crate reads of `sys/auth/`. +#[derive(Deserialize)] +struct AuthMountInfo { + accessor: String, +} + +/// Spelled out here because [`vaultrs`] can list the auth mounts but not read +/// one, and the list needs the wider `sys/auth` grant. +#[derive(Endpoint)] +#[endpoint( + path = "sys/auth/{self.mount}", + method = "GET", + response = "AuthMountInfo" +)] +struct ReadAuthMount { + #[endpoint(skip)] + mount: String, +} + +#[derive(Endpoint)] +#[endpoint( + path = "identity/lookup/entity", + method = "POST", + response = "EntityRef" +)] +struct LookupEntity { + alias_name: String, + alias_mount_accessor: String, +} + +/// Spelled out here because [`vaultrs`]'s response for this read requires +/// fields this crate has no use for. +#[derive(Endpoint)] +#[endpoint( + path = "identity/entity/name/{self.name}", + method = "GET", + response = "EntityRef" +)] +struct ReadEntityByName { + #[endpoint(skip)] + name: String, +} + +/// The name in the path is all that is sent, which is what the controller's +/// parameter-restricted grant allows. +#[derive(Endpoint)] +#[endpoint(path = "identity/entity/name/{self.name}", method = "POST")] +struct CreateEntityByName { + #[endpoint(skip)] + name: String, +} + +#[derive(Endpoint)] +#[endpoint(path = "identity/entity/id/{self.id}", method = "POST")] +struct RenameEntity { + #[endpoint(skip)] + id: String, + name: String, +} + +#[derive(Endpoint)] +#[endpoint(path = "identity/entity-alias", method = "POST")] +struct CreateEntityAlias { + name: String, + mount_accessor: String, + canonical_id: String, } /// Whether a failed store call means the path holds nothing — the one status @@ -786,4 +975,74 @@ mod tests { assert_eq!(soft.path(), "secret/data/swarm/agents/atlas/queue"); assert_ne!(request.path(), soft.path()); } + + /// The bodies the controller's parameter-restricted grant + /// (`swarm-bao.nix`'s `controllerPolicyText`) allows: any other key in a + /// request is refused by the store, so a field added to one of these + /// structs fails here before it fails there. + #[test] + fn the_identity_requests_send_only_the_keys_the_grant_allows() { + use rustify::endpoint::Endpoint as _; + + let keys = |body: Option>| -> Vec { + let value: serde_json::Value = + serde_json::from_slice(&body.unwrap_or_else(|| b"{}".to_vec())).expect("json"); + let mut keys: Vec = value + .as_object() + .expect("an object") + .keys() + .cloned() + .collect(); + keys.sort(); + keys + }; + let create = CreateEntityByName { + name: "atlas".to_owned(), + }; + assert_eq!(create.path(), "identity/entity/name/atlas"); + assert!(keys(create.body().expect("body")).is_empty()); + + let rename = RenameEntity { + id: "e1".to_owned(), + name: "atlas".to_owned(), + }; + assert_eq!(rename.path(), "identity/entity/id/e1"); + assert_eq!(keys(rename.body().expect("body")), ["name"]); + + let alias = CreateEntityAlias { + name: "hive-agent-atlas".to_owned(), + mount_accessor: "auth_cert_x".to_owned(), + canonical_id: "e1".to_owned(), + }; + assert_eq!(alias.path(), "identity/entity-alias"); + assert_eq!( + keys(alias.body().expect("body")), + ["canonical_id", "mount_accessor", "name"] + ); + + let lookup = LookupEntity { + alias_name: "hive-agent-atlas".to_owned(), + alias_mount_accessor: "auth_cert_x".to_owned(), + }; + assert_eq!(lookup.path(), "identity/lookup/entity"); + assert_eq!( + keys(lookup.body().expect("body")), + ["alias_mount_accessor", "alias_name"] + ); + + assert_eq!( + ReadAuthMount { + mount: "cert".to_owned() + } + .path(), + "sys/auth/cert" + ); + assert_eq!( + ReadEntityByName { + name: "atlas".to_owned() + } + .path(), + "identity/entity/name/atlas" + ); + } } diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index 12ebbfb6..3e3e7635 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -200,10 +200,83 @@ pub fn render_agent(agent: &str) -> Result { Ok(format!("{read}{list}")) } +/// The one policy every agent's cert-auth role attaches next to its own. +/// +/// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an +/// agent's suffix so that it is never an agent's object name: +/// [`agents_from_role_names`] needs `hive-agent-` with a segment after it. The +/// hive name that would spell it is `agent`, which `nix/reserved-hive-fragments.nix` +/// forbids. +pub const SHARED_AGENT_POLICY: &str = "hive-agent"; + +/// The template that stands for the agent's name in [`render_shared_agent`]: +/// the name of the login's identity entity, which swarm-controller sets to the +/// agent's name. +const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}"; + +/// Render the policy [`SHARED_AGENT_POLICY`] holds: the stanzas of +/// [`render_agent`], with the agent's name taken from the identity entity of +/// whoever presents the token instead of written into the text. +/// +/// The text is the same for every agent, so a change to the rule is one write. +/// An entity whose name is not an agent's grants nothing: the controller names +/// each agent's entity after the agent, and a login that no entity of that +/// name owns reads `swarm/agents//`, where nothing is stored. +#[must_use] +pub fn render_shared_agent() -> String { + let kind = <&str>::from(Kind::Agent); + let read = read_stanza(&format!( + "{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*" + )); + let list = list_stanza(&format!( + "{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*" + )); + format!("{read}{list}") +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_shared_document_is_the_per_agent_one_with_the_entity_name_for_the_agent() { + let shared = render_shared_agent(); + assert_eq!( + shared, + "path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\ + path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n" + ); + // Same shape as the per-agent document, so the rule is one rule. + assert_eq!( + shared.replace("{{identity.entity.name}}", "atlas"), + render_agent("atlas").expect("legal") + ); + } + + #[test] + fn the_shared_document_names_no_agent_and_grants_no_wider_than_one() { + let shared = render_shared_agent(); + assert!(!shared.contains("atlas")); + assert!(!shared.contains("agents/*"), "{shared}"); + assert!(!shared.contains("create")); + assert!(!shared.contains("update")); + assert!(!shared.contains("delete")); + } + + #[test] + fn the_shared_policy_name_is_writable_and_no_agents_or_hives() { + assert!(SHARED_AGENT_POLICY.starts_with(HIVE_PREFIX)); + assert!( + agents_from_role_names(&[SHARED_AGENT_POLICY.to_owned()]).is_empty(), + "it must not be read back as an agent" + ); + // The only hive name that would render it carries `agent`. + assert_eq!( + hive_object_name("agent").expect("legal as a segment"), + SHARED_AGENT_POLICY + ); + } + #[test] fn the_document_grants_every_agent_and_this_hive_alone() { assert_eq!(