add one shared templated agent policy and name each agent's bao entity after it
This commit is contained in:
parent
d893097ffe
commit
4cdf807afc
9 changed files with 777 additions and 13 deletions
299
swarm-controller/src/agent_entity.rs
Normal file
299
swarm-controller/src/agent_entity.rs
Normal file
|
|
@ -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-<agent>`. The shared policy
|
||||
//! ([`policy::render_shared_agent`]) reads `{{identity.entity.name}}`, so the
|
||||
//! entity has to be called `<agent>`. 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<EntityRef>,
|
||||
/// The id of the entity named after the agent.
|
||||
pub by_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Plan> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RoleInputs<'a>> {
|
||||
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<String> {
|
|||
/// 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue