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
|
|
@ -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
|
agent's at its own start, so a change to it reaches existing agents with the
|
||||||
next controller restart.
|
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 `<agent>` with the alias `hive-agent-<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
|
### 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
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,16 @@ let
|
||||||
# The agent PKI grant is its only path on that mount: it can ask the one role
|
# 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.
|
# 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
|
# 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
|
# with a trailing slash, where `+` matches the empty last segment, so a
|
||||||
# slashless list path loses that request to the `+/matrix/+` and `+/forge/+`
|
# slashless list path loses that request to the `+/matrix/+` and `+/forge/+`
|
||||||
|
|
@ -401,6 +411,41 @@ let
|
||||||
capabilities = ["list"]
|
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/*" {
|
path "${credentialMountPath}/data/swarm/agents/*" {
|
||||||
capabilities = ["create", "read", "update"]
|
capabilities = ["create", "read", "update"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1558,6 +1558,39 @@ let
|
||||||
in
|
in
|
||||||
lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"read\", \"update\"]" s;
|
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
|
# `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
|
||||||
|
|
|
||||||
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
|
//! and its grant covers exactly the calls made here (`swarm-bao.nix`'s
|
||||||
//! `controllerPolicyText`: `update` on the agent PKI role's `issue` path, and
|
//! `controllerPolicyText`: `update` on the agent PKI role's `issue` path, and
|
||||||
//! `create/update` on `secret/data/swarm/agents/*`, on
|
//! `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
|
//! **Two credentials, deliberately unrelated.** The certificate reaches the
|
||||||
//! store; the queue secret identifies the agent to the swarm queue. Both sit
|
//! store; the queue secret identifies the agent to the swarm queue. Both sit
|
||||||
|
|
@ -67,13 +68,18 @@ struct RoleInputs<'a> {
|
||||||
ca: &'a str,
|
ca: &'a str,
|
||||||
/// The policy document the role attaches.
|
/// The policy document the role attaches.
|
||||||
policy: String,
|
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>> {
|
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
|
||||||
|
let name = policy::agent_object_name(agent)?;
|
||||||
Ok(RoleInputs {
|
Ok(RoleInputs {
|
||||||
name: policy::agent_object_name(agent)?,
|
name: name.clone(),
|
||||||
ca: &credential.ca,
|
ca: &credential.ca,
|
||||||
policy: policy::render_agent(agent)?,
|
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
|
/// identity at the swarm queue, minted here so that the credential an agent
|
||||||
/// presents names *it* rather than its hive;
|
/// presents names *it* rather than its hive;
|
||||||
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on
|
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on
|
||||||
/// this one agent's paths and nothing else;
|
/// this one agent's paths — and the shared [`policy::render_shared_agent`];
|
||||||
/// 5. write the cert-auth role that ties the three together, pinning the CA
|
/// 5. write the cert-auth role that ties the three together, attaching both
|
||||||
/// the store named as this leaf's issuer.
|
/// 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
|
/// 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
|
/// 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)
|
.write_policy(&inputs.name, &inputs.policy)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing the read policy {}", inputs.name))?;
|
.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
|
store
|
||||||
.write_cert_role(
|
.write_cert_role(
|
||||||
DEFAULT_CERT_MOUNT,
|
DEFAULT_CERT_MOUNT,
|
||||||
&inputs.name,
|
&inputs.name,
|
||||||
inputs.ca,
|
inputs.ca,
|
||||||
&inputs.name,
|
&inputs.name,
|
||||||
&inputs.name,
|
&inputs.attached.each_ref().map(String::as_str),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
||||||
tracing::info!(agent, %path, role = %name, "agent store identity published");
|
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?;
|
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -450,6 +468,11 @@ mod tests {
|
||||||
"the CN the leaf is issued for"
|
"the CN the leaf is issued for"
|
||||||
);
|
);
|
||||||
assert_eq!(inputs.ca, "AGENT-CA");
|
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_eq!(inputs.policy, policy::render_agent("atlas").expect("legal"));
|
||||||
assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy);
|
assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ use anyhow::{Context, Result};
|
||||||
|
|
||||||
mod agent_archetype;
|
mod agent_archetype;
|
||||||
mod agent_control;
|
mod agent_control;
|
||||||
|
mod agent_entity;
|
||||||
mod agent_icon;
|
mod agent_icon;
|
||||||
mod agent_identity;
|
mod agent_identity;
|
||||||
mod agent_input;
|
mod agent_input;
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
|
||||||
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, &[&name])
|
||||||
.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");
|
||||||
|
|
@ -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
|
/// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so
|
||||||
/// it is not what a pass on every start may call.
|
/// 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)
|
.list_cert_roles(DEFAULT_CERT_MOUNT)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Unreachable(format!("listing the store's cert-auth roles: {e}")))?;
|
.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 policies = agent_policies(&roles);
|
||||||
let mut written = 0_usize;
|
let mut written = 0_usize;
|
||||||
for (name, document) in &policies {
|
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");
|
tracing::info!(written, agents = policies.len(), "agent policies rewritten");
|
||||||
|
crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rustify_derive::Endpoint;
|
use rustify_derive::Endpoint;
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||||
use vaultrs::{
|
use vaultrs::{
|
||||||
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
|
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
|
||||||
client::{Client, VaultClient, VaultClientSettings, VaultClientSettingsBuilder},
|
client::{Client, VaultClient, VaultClientSettings, VaultClientSettingsBuilder},
|
||||||
|
|
@ -355,7 +355,7 @@ impl SecretStore {
|
||||||
|
|
||||||
/// Create or replace the cert-auth role `name`, so that a certificate
|
/// Create or replace the cert-auth role `name`, so that a certificate
|
||||||
/// issued by `certificate` and carrying `common_name` logs in with
|
/// issued by `certificate` and carrying `common_name` logs in with
|
||||||
/// `policy`.
|
/// `policies`.
|
||||||
///
|
///
|
||||||
/// `certificate` is the authority by value, not a path: the store keeps
|
/// `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.
|
/// its own copy inside the role and never reads a file of ours.
|
||||||
|
|
@ -369,12 +369,12 @@ impl SecretStore {
|
||||||
name: &str,
|
name: &str,
|
||||||
certificate: &str,
|
certificate: &str,
|
||||||
common_name: &str,
|
common_name: &str,
|
||||||
policy: &str,
|
policies: &[&str],
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut opts =
|
let mut opts =
|
||||||
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
|
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
|
||||||
opts.allowed_common_names(vec![common_name.to_owned()])
|
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::<Vec<_>>());
|
||||||
vaultrs::auth::cert::ca_cert_role::set(
|
vaultrs::auth::cert::ca_cert_role::set(
|
||||||
&self.inner,
|
&self.inner,
|
||||||
mount,
|
mount,
|
||||||
|
|
@ -428,6 +428,195 @@ impl SecretStore {
|
||||||
Err(e) => Err(e.into()),
|
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/<mount>` (a plain `read` is refused), or no such
|
||||||
|
/// mount exists.
|
||||||
|
pub async fn auth_mount_accessor(&self, mount: &str) -> Result<String, Error> {
|
||||||
|
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<Option<EntityRef>, 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<Option<String>, 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/<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/<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/<mount>`.
|
||||||
|
#[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
|
/// 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_eq!(soft.path(), "secret/data/swarm/agents/atlas/queue");
|
||||||
assert_ne!(request.path(), soft.path());
|
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<u8>>| -> Vec<String> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_slice(&body.unwrap_or_else(|| b"{}".to_vec())).expect("json");
|
||||||
|
let mut keys: Vec<String> = 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,10 +200,83 @@ pub fn render_agent(agent: &str) -> Result<String, Error> {
|
||||||
Ok(format!("{read}{list}"))
|
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/<generated name>/`, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn the_document_grants_every_agent_and_this_hive_alone() {
|
fn the_document_grants_every_agent_and_this_hive_alone() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue