name the agent's entity before narrowing its role, and test hive provisioning against a fake store
This commit is contained in:
parent
139007e656
commit
09d89507a9
5 changed files with 535 additions and 67 deletions
|
|
@ -377,12 +377,15 @@ is one write. `swarm-controller` writes it at agent creation and at its own
|
||||||
start.
|
start.
|
||||||
|
|
||||||
For that the controller names each agent's identity entity after the agent: at
|
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
|
each certificate mint or renewal, before it writes the agent's cert-auth role,
|
||||||
accessor and creates the entity `<agent>` with the alias `hive-agent-<agent>`,
|
and for every agent at its own start. It reads the cert mount's accessor and
|
||||||
or renames the entity a first login created under a generated name. When the alias
|
creates the entity `<agent>` with the alias `hive-agent-<agent>`, or renames the
|
||||||
sits on any other entity, the controller logs an error and changes nothing, and
|
entity a first login created under a generated name. At a mint or renewal this is
|
||||||
that agent reads nothing until an operator resolves it. It needs the `identity/`
|
a precondition: when the store refuses a call, or the alias sits on any other
|
||||||
and `sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that.
|
entity, the mint fails and the role stays as it was, so a live agent keeps
|
||||||
|
working until an operator resolves it. The start pass only logs the same
|
||||||
|
failures, because it writes no role. The controller needs the `identity/` and
|
||||||
|
`sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that.
|
||||||
|
|
||||||
A cert-auth role that predates the shared policy may still name a policy
|
A cert-auth role that predates the shared policy may still name a policy
|
||||||
`hive-agent-<agent>`. The controller no longer writes or attaches these. The
|
`hive-agent-<agent>`. The controller no longer writes or attaches these. The
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,22 @@
|
||||||
//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is
|
//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is
|
||||||
//! written and an operator decides.
|
//! written and an operator decides.
|
||||||
//!
|
//!
|
||||||
//! Everything else here is best effort: a failure is logged at `warn` and the
|
//! An agent whose entity is not named after it reads nothing under the shared
|
||||||
//! caller goes on. An agent whose entity is not named after it reads nothing,
|
//! policy, so what a failure costs depends on the caller. [`ensure_agent`],
|
||||||
//! because the shared policy is the only one its role attaches; at mint the
|
//! which runs before a cert-auth role is narrowed to that policy, returns the
|
||||||
//! read-back fails, and on a start pass its next reads are refused.
|
//! error and leaves the role as it was. [`ensure_agents`], the pass at every
|
||||||
|
//! start, writes no role and so narrows nothing: it logs at `warn` and goes on.
|
||||||
//! The grants are the `identity/` stanzas of `swarm-bao.nix`'s
|
//! The grants are the `identity/` stanzas of `swarm-bao.nix`'s
|
||||||
//! `controllerPolicyText`.
|
//! `controllerPolicyText`.
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
SecretStore,
|
|
||||||
client::{DEFAULT_CERT_MOUNT, EntityRef},
|
client::{DEFAULT_CERT_MOUNT, EntityRef},
|
||||||
policy,
|
policy,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::store::AccessStore;
|
||||||
|
|
||||||
/// What the store holds for one agent's entity.
|
/// What the store holds for one agent's entity.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Seen {
|
pub struct Seen {
|
||||||
|
|
@ -92,13 +94,13 @@ pub fn plan(agent: &str, seen: &Seen) -> Plan {
|
||||||
|
|
||||||
/// Make sure every agent in `agents` has its entity, logging what could not be
|
/// Make sure every agent in `agents` has its entity, logging what could not be
|
||||||
/// done. Never fails: see the module header.
|
/// done. Never fails: see the module header.
|
||||||
pub async fn ensure_agents(store: &SecretStore, agents: &[String]) {
|
pub async fn ensure_agents(store: &impl AccessStore, agents: &[String]) {
|
||||||
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
||||||
Ok(accessor) => accessor,
|
Ok(accessor) => accessor,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose entity is not named after it cannot read its secrets"
|
"agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose role attaches the shared policy alone reads nothing until its entity is named after it"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +122,30 @@ pub async fn ensure_agents(store: &SecretStore, agents: &[String]) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`ensure_agents`] for one agent.
|
/// Make sure `agent` has its entity, or say why not.
|
||||||
pub async fn ensure_agent(store: &SecretStore, agent: &str) {
|
///
|
||||||
ensure_agents(store, &[agent.to_owned()]).await;
|
/// For a caller that must not go on without it, such as the one that narrows a
|
||||||
|
/// cert-auth role to the shared policy: an agent whose entity is not named after
|
||||||
|
/// it reads nothing under that policy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// When the cert mount's accessor cannot be read, when a store call fails, and
|
||||||
|
/// on a [`Plan::Conflict`], which [`ensure_agents`] only logs.
|
||||||
|
pub async fn ensure_agent(store: &impl AccessStore, agent: &str) -> Result<()> {
|
||||||
|
let accessor = store
|
||||||
|
.auth_mount_accessor(DEFAULT_CERT_MOUNT)
|
||||||
|
.await
|
||||||
|
.context("reading the cert auth mount's accessor")?;
|
||||||
|
match ensure_one(store, &accessor, agent).await? {
|
||||||
|
Plan::Conflict { alias, entity_name } => bail!(
|
||||||
|
"the alias {alias} is on the entity {entity_name}, which is not {agent}; nothing was changed"
|
||||||
|
),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observe, plan and apply for one agent.
|
/// Observe, plan and apply for one agent.
|
||||||
async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result<Plan> {
|
async fn ensure_one(store: &impl AccessStore, accessor: &str, agent: &str) -> Result<Plan> {
|
||||||
let alias = policy::agent_object_name(agent)?;
|
let alias = policy::agent_object_name(agent)?;
|
||||||
let seen = Seen {
|
let seen = Seen {
|
||||||
by_alias: store
|
by_alias: store
|
||||||
|
|
@ -174,7 +193,7 @@ async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result<
|
||||||
agent,
|
agent,
|
||||||
alias,
|
alias,
|
||||||
entity_name,
|
entity_name,
|
||||||
"the agent's alias is on an entity that is not the agent's; nothing was changed and the agent cannot read its secrets until an operator resolves it"
|
"the agent's alias is on an entity that is not the agent's; nothing was changed; under the shared policy alone the agent reads nothing until an operator resolves it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
//! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half
|
//! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half
|
||||||
//! its validity (the role's `ttl`), by queueing the same node as creation.
|
//! its validity (the role's `ttl`), by queueing the same node as creation.
|
||||||
|
|
||||||
|
use crate::store::AccessStore;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
SecretStore,
|
SecretStore,
|
||||||
|
|
@ -79,6 +80,48 @@ fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<Role
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the shared policy, name the agent's entity, then write the cert-auth
|
||||||
|
/// role that attaches the policy.
|
||||||
|
///
|
||||||
|
/// The entity comes before the role and is a precondition for it. The role
|
||||||
|
/// attaches the shared policy alone, so an agent whose entity is not named after
|
||||||
|
/// it reads nothing under it. This also runs for a live agent's renewal, which
|
||||||
|
/// rewrites the role its current leaf logs in under: when the entity step
|
||||||
|
/// fails, or finds the alias on another entity, the role is left as it was and
|
||||||
|
/// the renewal fails while the agent keeps working.
|
||||||
|
async fn publish_access(
|
||||||
|
store: &impl AccessStore,
|
||||||
|
agent: &str,
|
||||||
|
inputs: &RoleInputs<'_>,
|
||||||
|
) -> Result<()> {
|
||||||
|
store
|
||||||
|
.write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent())
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"writing the shared agent policy {}",
|
||||||
|
policy::SHARED_AGENT_POLICY
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
crate::agent_entity::ensure_agent(store, agent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("naming {agent}'s identity entity; the role is not changed"))?;
|
||||||
|
store
|
||||||
|
.write_cert_role(
|
||||||
|
DEFAULT_CERT_MOUNT,
|
||||||
|
&inputs.name,
|
||||||
|
inputs.ca,
|
||||||
|
&inputs.name,
|
||||||
|
&inputs
|
||||||
|
.attached
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing the cert-auth role {}", inputs.name))
|
||||||
|
}
|
||||||
|
|
||||||
/// How many bytes of kernel randomness a queue secret is before encoding.
|
/// How many bytes of kernel randomness a queue secret is before encoding.
|
||||||
///
|
///
|
||||||
/// Thirty-two because the secret is a bearer token compared for equality and
|
/// Thirty-two because the secret is a bearer token compared for equality and
|
||||||
|
|
@ -121,12 +164,12 @@ 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 shared ACL document [`policy::render_shared_agent`] renders;
|
/// 4. write the shared ACL document [`policy::render_shared_agent`] renders;
|
||||||
/// 5. write the cert-auth role that ties the three together, attaching that
|
/// 5. name the agent's identity entity after the agent ([`crate::agent_entity`]),
|
||||||
/// policy and pinning the CA the store named as this leaf's issuer;
|
/// which the policy reads;
|
||||||
/// 6. name the agent's identity entity after the agent ([`crate::agent_entity`]),
|
/// 6. write the cert-auth role that ties the three together, attaching that
|
||||||
/// which the policy reads.
|
/// policy and pinning the CA the store named as this leaf's issuer.
|
||||||
///
|
///
|
||||||
/// Policy before role, for the reason `read_policy::provision` gives.
|
/// Policy and entity before role: see [`publish_access`].
|
||||||
///
|
///
|
||||||
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
||||||
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
||||||
|
|
@ -135,10 +178,10 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
|
||||||
/// replacing one by age is [`crate::agent_renewal`]'s.
|
/// replacing one by age is [`crate::agent_renewal`]'s.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Anything that stops steps 1–5, with the step named, or the read-back login,
|
/// Anything that stops one of those six steps, with the step named, or the
|
||||||
/// which is where a step 6 that did not take shows up. A failure here fails the
|
/// read-back login. A failure here fails the job node and nothing else — a new
|
||||||
/// job node and nothing else — the agent is still created, without a store
|
/// agent is still created, without a store identity, and a live agent keeps the
|
||||||
/// identity.
|
/// role it had.
|
||||||
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||||||
let name = policy::agent_object_name(agent)?;
|
let name = policy::agent_object_name(agent)?;
|
||||||
|
|
@ -197,33 +240,8 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
let queue_credential = wanted;
|
let queue_credential = wanted;
|
||||||
|
|
||||||
let inputs = role_inputs(agent, &credential)?;
|
let inputs = role_inputs(agent, &credential)?;
|
||||||
store
|
publish_access(&store, agent, &inputs).await?;
|
||||||
.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
|
|
||||||
.attached
|
|
||||||
.iter()
|
|
||||||
.map(String::as_str)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.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!(
|
||||||
|
|
@ -352,8 +370,9 @@ async fn read_back_as_agent(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki,
|
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki,
|
||||||
generate_queue_secret, role_inputs,
|
generate_queue_secret, publish_access, role_inputs,
|
||||||
};
|
};
|
||||||
|
use crate::store::fake::FakeAccess;
|
||||||
use swarm_secret_client::{mtls, policy};
|
use swarm_secret_client::{mtls, policy};
|
||||||
|
|
||||||
/// The alphabet claim the token format rests on: the secret is carried in
|
/// The alphabet claim the token format rests on: the secret is carried in
|
||||||
|
|
@ -481,4 +500,73 @@ mod tests {
|
||||||
assert_ne!(other.name, inputs.name);
|
assert_ne!(other.name, inputs.name);
|
||||||
assert_eq!(other.attached, inputs.attached);
|
assert_eq!(other.attached, inputs.attached);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn credential() -> mtls::Credential {
|
||||||
|
mtls::Credential {
|
||||||
|
cert: "LEAF".to_owned(),
|
||||||
|
key: "KEY".to_owned(),
|
||||||
|
ca: "AGENT-CA".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control: with a working entity step the role is narrowed, after the
|
||||||
|
/// entity is named, so the absence checks below can see a role write.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_role_is_narrowed_after_the_entity_is_named() {
|
||||||
|
let store = FakeAccess::default();
|
||||||
|
let credential = credential();
|
||||||
|
let inputs = role_inputs("atlas", &credential).expect("legal");
|
||||||
|
publish_access(&store, "atlas", &inputs)
|
||||||
|
.await
|
||||||
|
.expect("a store that accepts everything");
|
||||||
|
assert_eq!(
|
||||||
|
store.roles_written(),
|
||||||
|
["write_cert_role hive-agent-atlas hive-agent"]
|
||||||
|
);
|
||||||
|
let calls = store.calls();
|
||||||
|
let at = |call: &str| calls.iter().position(|c| c == call).expect(call);
|
||||||
|
assert!(
|
||||||
|
at("create_entity_alias hive-agent-atlas")
|
||||||
|
< at("write_cert_role hive-agent-atlas hive-agent"),
|
||||||
|
"the entity is named before the role is written: {calls:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_renewal_whose_entity_step_fails_leaves_the_role_alone() {
|
||||||
|
let store = FakeAccess::without_accessor();
|
||||||
|
let credential = credential();
|
||||||
|
let inputs = role_inputs("atlas", &credential).expect("legal");
|
||||||
|
let err = publish_access(&store, "atlas", &inputs)
|
||||||
|
.await
|
||||||
|
.expect_err("the entity step failed");
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("the role is not changed"),
|
||||||
|
"{err:#}"
|
||||||
|
);
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
// The control: the call log sees writes, and the shared policy was one.
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.calls()
|
||||||
|
.contains(&"write_policy hive-agent".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_renewal_whose_alias_is_on_another_entity_leaves_the_role_alone() {
|
||||||
|
let store = FakeAccess::with_alias_on("id-1", "somebody");
|
||||||
|
let credential = credential();
|
||||||
|
let inputs = role_inputs("atlas", &credential).expect("legal");
|
||||||
|
let err = publish_access(&store, "atlas", &inputs)
|
||||||
|
.await
|
||||||
|
.expect_err("a conflict is an error");
|
||||||
|
assert!(format!("{err:#}").contains("somebody"), "{err:#}");
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.calls()
|
||||||
|
.contains(&"entity_by_alias hive-agent-atlas".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ use std::{future::Future, time::Duration};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use backon::{BackoffBuilder as _, ConstantBuilder};
|
use backon::{BackoffBuilder as _, ConstantBuilder};
|
||||||
use swarm_secret_client::{Error, SecretStore, client::DEFAULT_CERT_MOUNT, policy};
|
use swarm_secret_client::{Error, client::DEFAULT_CERT_MOUNT, policy};
|
||||||
|
|
||||||
|
use crate::store::AccessStore;
|
||||||
|
|
||||||
/// File holding the authority hives are issued from, as
|
/// File holding the authority hives are issued from, as
|
||||||
/// `swarm-controller.nix` names it.
|
/// `swarm-controller.nix` names it.
|
||||||
|
|
@ -154,21 +156,38 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
|
||||||
}
|
}
|
||||||
Err(e) => return Err(Unreachable(e.to_string())),
|
Err(e) => return Err(Unreachable(e.to_string())),
|
||||||
};
|
};
|
||||||
if !write_shared_policy(&store).await {
|
provision_with(&store, hives, read_hive_authority).await
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hive authority from the file `ENV_HIVE_CLIENT_CA` names, or `None` when
|
||||||
|
/// none is configured.
|
||||||
|
fn read_hive_authority() -> Result<Option<String>, Unreachable> {
|
||||||
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
|
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
|
||||||
tracing::info!("no hive certificate authority configured; hive roles are not managed here");
|
return Ok(None);
|
||||||
return Ok(());
|
|
||||||
};
|
};
|
||||||
let path = path.to_string_lossy().into_owned();
|
let path = path.to_string_lossy().into_owned();
|
||||||
// Retryable rather than fatal: the unit that places this file and this
|
// Retryable rather than fatal: the unit that places this file and this
|
||||||
// daemon start together, so "not there yet" is a moment, not a verdict.
|
// daemon start together, so "not there yet" is a moment, not a verdict.
|
||||||
let ca = std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
|
.map(Some)
|
||||||
|
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`provision`] against `store`, with the authority read by `read_authority`.
|
||||||
|
async fn provision_with(
|
||||||
|
store: &impl AccessStore,
|
||||||
|
hives: &[String],
|
||||||
|
read_authority: impl FnOnce() -> Result<Option<String>, Unreachable>,
|
||||||
|
) -> Result<(), Unreachable> {
|
||||||
|
if !write_shared_policy(store).await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(ca) = read_authority()? else {
|
||||||
|
tracing::info!("no hive certificate authority configured; hive roles are not managed here");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
for hive in hives {
|
for hive in hives {
|
||||||
if let Err(e) = write_role(&store, hive, &ca).await {
|
if let Err(e) = write_role(store, hive, &ca).await {
|
||||||
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
|
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +199,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> {
|
||||||
///
|
///
|
||||||
/// A store that refuses the accessor read (the controller's policy may not
|
/// A store that refuses the accessor read (the controller's policy may not
|
||||||
/// carry it yet) or the write leaves the roles already in the store as they are.
|
/// carry it yet) or the write leaves the roles already in the store as they are.
|
||||||
async fn write_shared_policy(store: &SecretStore) -> bool {
|
async fn write_shared_policy(store: &impl AccessStore) -> bool {
|
||||||
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
|
||||||
Ok(accessor) => accessor,
|
Ok(accessor) => accessor,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -227,7 +246,7 @@ fn attached_policies() -> Vec<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The role for one hive: its own certificate's subject and the shared policy.
|
/// The role for one hive: its own certificate's subject and the shared policy.
|
||||||
async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
|
async fn write_role(store: &impl AccessStore, hive: &str, ca: &str) -> Result<()> {
|
||||||
let name = policy::hive_object_name(hive)?;
|
let name = policy::hive_object_name(hive)?;
|
||||||
let attached = attached_policies();
|
let attached = attached_policies();
|
||||||
store
|
store
|
||||||
|
|
@ -330,9 +349,11 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{cell::Cell, time::Duration};
|
use std::{cell::Cell, time::Duration};
|
||||||
|
|
||||||
|
use crate::store::fake::FakeAccess;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy,
|
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy,
|
||||||
retry_until_ok,
|
provision_with, retry_until_ok,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -385,4 +406,84 @@ mod tests {
|
||||||
// The control: the same lookup finds a policy that is attached.
|
// The control: the same lookup finds a policy that is attached.
|
||||||
assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned()));
|
assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hives() -> Vec<String> {
|
||||||
|
["pr1ma", "beta"].map(str::to_owned).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_hives_role_is_written_after_the_shared_policy_and_attaches_it_alone() {
|
||||||
|
let store = FakeAccess::default();
|
||||||
|
provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned())))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("a store that accepts everything"));
|
||||||
|
assert_eq!(
|
||||||
|
store.roles_written(),
|
||||||
|
[
|
||||||
|
"write_cert_role hive-pr1ma hive-hives",
|
||||||
|
"write_cert_role hive-beta hive-hives"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let calls = store.calls();
|
||||||
|
let policy_at = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "write_policy hive-hives")
|
||||||
|
.expect("the shared policy was written");
|
||||||
|
let first_role_at = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.starts_with("write_cert_role "))
|
||||||
|
.expect("a role was written");
|
||||||
|
assert!(policy_at < first_role_at, "{calls:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unreadable_accessor_leaves_every_hive_role_as_it_was() {
|
||||||
|
let store = FakeAccess::without_accessor();
|
||||||
|
provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned())))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("a policy that cannot be written is not a retry"));
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
// The control: the accessor read was attempted, and the same fake
|
||||||
|
// writes roles when it works (the test above).
|
||||||
|
assert_eq!(store.calls(), ["auth_mount_accessor"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_shared_policy_leaves_every_hive_role_as_it_was() {
|
||||||
|
let store = FakeAccess::refusing_policies();
|
||||||
|
provision_with(&store, &hives(), || Ok(Some("HIVE-CA".to_owned())))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("a refused write is not a retry"));
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.calls()
|
||||||
|
.contains(&"write_policy hive-hives".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn without_an_authority_the_policy_is_written_and_no_role_is() {
|
||||||
|
let store = FakeAccess::default();
|
||||||
|
provision_with(&store, &hives(), || Ok(None))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("no authority configured is not a retry"));
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.calls()
|
||||||
|
.contains(&"write_policy hive-hives".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_authority_that_cannot_be_read_is_retried_and_writes_no_role() {
|
||||||
|
let store = FakeAccess::default();
|
||||||
|
let outcome = provision_with(&store, &hives(), || {
|
||||||
|
Err(Unreachable("not placed yet".to_owned()))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(outcome.is_err());
|
||||||
|
assert!(store.roles_written().is_empty(), "{:?}", store.calls());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
//! Two paths log in: writing an agent's credential, and writing the read
|
//! Two paths log in: writing an agent's credential, and writing the read
|
||||||
//! grant that lets a hive fetch one back. Both present the same certificate
|
//! grant that lets a hive fetch one back. Both present the same certificate
|
||||||
//! under the same role, so the role is named here rather than at each caller.
|
//! under the same role, so the role is named here rather than at each caller.
|
||||||
|
//! [`AccessStore`] is the subset of calls that give a principal its access, so
|
||||||
|
//! a test can stand in for the store.
|
||||||
|
|
||||||
use swarm_secret_client::{Error, SecretStore};
|
use std::future::Future;
|
||||||
|
|
||||||
|
use swarm_secret_client::{Error, SecretStore, client::EntityRef};
|
||||||
|
|
||||||
/// The cert-auth role the controller logs in under.
|
/// The cert-auth role the controller logs in under.
|
||||||
///
|
///
|
||||||
|
|
@ -23,3 +27,256 @@ pub const CERT_ROLE: &str = "swarm-controller";
|
||||||
pub async fn connect() -> Result<SecretStore, Error> {
|
pub async fn connect() -> Result<SecretStore, Error> {
|
||||||
SecretStore::from_env(CERT_ROLE).await
|
SecretStore::from_env(CERT_ROLE).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The store calls that give a principal its access: policies, cert-auth roles
|
||||||
|
/// and identity entities. A test stands in for the store through this trait.
|
||||||
|
pub(crate) trait AccessStore {
|
||||||
|
/// As [`SecretStore::write_policy`].
|
||||||
|
fn write_policy(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
policy: &str,
|
||||||
|
) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::write_cert_role`].
|
||||||
|
fn write_cert_role(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
name: &str,
|
||||||
|
certificate: &str,
|
||||||
|
common_name: &str,
|
||||||
|
policies: &[&str],
|
||||||
|
) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::auth_mount_accessor`].
|
||||||
|
fn auth_mount_accessor(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
) -> impl Future<Output = Result<String, Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::entity_by_alias`].
|
||||||
|
fn entity_by_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
mount_accessor: &str,
|
||||||
|
) -> impl Future<Output = Result<Option<EntityRef>, Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::entity_id_by_name`].
|
||||||
|
fn entity_id_by_name(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> impl Future<Output = Result<Option<String>, Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::create_entity`].
|
||||||
|
fn create_entity(&self, name: &str) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::rename_entity`].
|
||||||
|
fn rename_entity(&self, id: &str, name: &str)
|
||||||
|
-> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::create_entity_alias`].
|
||||||
|
fn create_entity_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
mount_accessor: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessStore for SecretStore {
|
||||||
|
async fn write_policy(&self, name: &str, policy: &str) -> Result<(), Error> {
|
||||||
|
SecretStore::write_policy(self, name, policy).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_cert_role(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
name: &str,
|
||||||
|
certificate: &str,
|
||||||
|
common_name: &str,
|
||||||
|
policies: &[&str],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
SecretStore::write_cert_role(self, mount, name, certificate, common_name, policies).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_mount_accessor(&self, mount: &str) -> Result<String, Error> {
|
||||||
|
SecretStore::auth_mount_accessor(self, mount).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entity_by_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
mount_accessor: &str,
|
||||||
|
) -> Result<Option<EntityRef>, Error> {
|
||||||
|
SecretStore::entity_by_alias(self, alias_name, mount_accessor).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entity_id_by_name(&self, name: &str) -> Result<Option<String>, Error> {
|
||||||
|
SecretStore::entity_id_by_name(self, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_entity(&self, name: &str) -> Result<(), Error> {
|
||||||
|
SecretStore::create_entity(self, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_entity(&self, id: &str, name: &str) -> Result<(), Error> {
|
||||||
|
SecretStore::rename_entity(self, id, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_entity_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
mount_accessor: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
SecretStore::create_entity_alias(self, alias_name, mount_accessor, entity_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store that records what it is asked to do, for tests of the order and the
|
||||||
|
/// outcome of the access calls.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod fake {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use swarm_secret_client::{Error, client::EntityRef};
|
||||||
|
|
||||||
|
use super::AccessStore;
|
||||||
|
|
||||||
|
/// What the fake answers; the default is a store with a readable cert mount
|
||||||
|
/// and no entities, which accepts every write.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct FakeAccess {
|
||||||
|
/// The accessor read fails.
|
||||||
|
no_accessor: bool,
|
||||||
|
/// Every policy write is refused.
|
||||||
|
refuse_policy: bool,
|
||||||
|
/// The entity that owns each alias, as `(id, name)`.
|
||||||
|
alias_owner: Option<(String, String)>,
|
||||||
|
entities: Mutex<Vec<String>>,
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeAccess {
|
||||||
|
/// A store whose cert mount accessor cannot be read.
|
||||||
|
pub(crate) fn without_accessor() -> Self {
|
||||||
|
Self {
|
||||||
|
no_accessor: true,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store that refuses every policy write.
|
||||||
|
pub(crate) fn refusing_policies() -> Self {
|
||||||
|
Self {
|
||||||
|
refuse_policy: true,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store where the agent alias is already on the entity `name`.
|
||||||
|
pub(crate) fn with_alias_on(id: &str, name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
alias_owner: Some((id.to_owned(), name.to_owned())),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calls so far, as `verb name` strings, in order.
|
||||||
|
pub(crate) fn calls(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().expect("not poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `write_cert_role` calls so far, as `write_cert_role <role> <policies>`.
|
||||||
|
pub(crate) fn roles_written(&self) -> Vec<String> {
|
||||||
|
self.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.starts_with("write_cert_role "))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, call: String) {
|
||||||
|
self.calls.lock().expect("not poisoned").push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn denied() -> Error {
|
||||||
|
Error::Settings("fake store: refused".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessStore for FakeAccess {
|
||||||
|
async fn write_policy(&self, name: &str, _policy: &str) -> Result<(), Error> {
|
||||||
|
self.log(format!("write_policy {name}"));
|
||||||
|
if self.refuse_policy {
|
||||||
|
return Err(Self::denied());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_cert_role(
|
||||||
|
&self,
|
||||||
|
_mount: &str,
|
||||||
|
name: &str,
|
||||||
|
_certificate: &str,
|
||||||
|
_common_name: &str,
|
||||||
|
policies: &[&str],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.log(format!("write_cert_role {name} {}", policies.join(",")));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_mount_accessor(&self, _mount: &str) -> Result<String, Error> {
|
||||||
|
self.log("auth_mount_accessor".to_owned());
|
||||||
|
if self.no_accessor {
|
||||||
|
return Err(Self::denied());
|
||||||
|
}
|
||||||
|
Ok("auth_cert_test".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entity_by_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
_mount_accessor: &str,
|
||||||
|
) -> Result<Option<EntityRef>, Error> {
|
||||||
|
self.log(format!("entity_by_alias {alias_name}"));
|
||||||
|
Ok(self.alias_owner.as_ref().map(|(id, name)| EntityRef {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entity_id_by_name(&self, name: &str) -> Result<Option<String>, Error> {
|
||||||
|
self.log(format!("entity_id_by_name {name}"));
|
||||||
|
let named = self
|
||||||
|
.entities
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.contains(&name.to_owned());
|
||||||
|
Ok(named.then(|| format!("id-{name}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_entity(&self, name: &str) -> Result<(), Error> {
|
||||||
|
self.log(format!("create_entity {name}"));
|
||||||
|
self.entities
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.push(name.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_entity(&self, id: &str, name: &str) -> Result<(), Error> {
|
||||||
|
self.log(format!("rename_entity {id} {name}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_entity_alias(
|
||||||
|
&self,
|
||||||
|
alias_name: &str,
|
||||||
|
_mount_accessor: &str,
|
||||||
|
_entity_id: &str,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.log(format!("create_entity_alias {alias_name}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue