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
1 changed files with 535 additions and 67 deletions
|
|
@ -29,6 +29,7 @@
|
|||
//! [`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.
|
||||
|
||||
use crate::store::AccessStore;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use swarm_secret_client::{
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// presents names *it* rather than its hive;
|
||||
/// 4. write the shared ACL document [`policy::render_shared_agent`] renders;
|
||||
/// 5. write the cert-auth role that ties the three together, attaching that
|
||||
/// policy and pinning the CA the store named as this leaf's issuer;
|
||||
/// 6. name the agent's identity entity after the agent ([`crate::agent_entity`]),
|
||||
/// which the policy reads.
|
||||
/// 5. name the agent's identity entity after the agent ([`crate::agent_entity`]),
|
||||
/// which the policy reads;
|
||||
/// 6. write the cert-auth role that ties the three together, attaching that
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// # Errors
|
||||
/// Anything that stops steps 1–5, with the step named, or the read-back login,
|
||||
/// which is where a step 6 that did not take shows up. A failure here fails the
|
||||
/// job node and nothing else — the agent is still created, without a store
|
||||
/// identity.
|
||||
/// Anything that stops one of those six steps, with the step named, or the
|
||||
/// read-back login. A failure here fails the job node and nothing else — a new
|
||||
/// agent is still created, without a store identity, and a live agent keeps the
|
||||
/// role it had.
|
||||
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||||
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 inputs = role_inputs(agent, &credential)?;
|
||||
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
|
||||
.attached
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
||||
publish_access(&store, agent, &inputs).await?;
|
||||
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!(
|
||||
|
|
@ -352,8 +370,9 @@ async fn read_back_as_agent(
|
|||
mod tests {
|
||||
use super::{
|
||||
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};
|
||||
|
||||
/// 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_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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue