Watch
0
0
Fork
You've already forked hyperhive
0

add one shared templated agent policy and name each agent's bao entity after it

This commit is contained in:
atlas 2026-10-25 20:39:59 +01:00 • committed by mara
commit 4cdf807afc
9 changed files with 777 additions and 13 deletions

View file

@ -3,7 +3,7 @@
use std::time::Duration;
use rustify_derive::Endpoint;
use serde::{Serialize, de::DeserializeOwned};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use vaultrs::{
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
client::{Client, VaultClient, VaultClientSettings, VaultClientSettingsBuilder},
@ -355,7 +355,7 @@ impl SecretStore {
/// Create or replace the cert-auth role `name`, so that a certificate
/// issued by `certificate` and carrying `common_name` logs in with
/// `policy`.
/// `policies`.
///
/// `certificate` is the authority by value, not a path: the store keeps
/// its own copy inside the role and never reads a file of ours.
@ -369,12 +369,12 @@ impl SecretStore {
name: &str,
certificate: &str,
common_name: &str,
policy: &str,
policies: &[&str],
) -> Result<(), Error> {
let mut opts =
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
opts.allowed_common_names(vec![common_name.to_owned()])
.token_policies(vec![policy.to_owned()]);
.token_policies(policies.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>());
vaultrs::auth::cert::ca_cert_role::set(
&self.inner,
mount,
@ -428,6 +428,195 @@ impl SecretStore {
Err(e) => Err(e.into()),
}
}
/// The accessor of the auth method mounted at `mount`: the id an entity
/// alias names that mount by.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not grant `read` and
/// `sudo` on `sys/auth/<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
@ -786,4 +975,74 @@ mod tests {
assert_eq!(soft.path(), "secret/data/swarm/agents/atlas/queue");
assert_ne!(request.path(), soft.path());
}
/// The bodies the controller's parameter-restricted grant
/// (`swarm-bao.nix`'s `controllerPolicyText`) allows: any other key in a
/// request is refused by the store, so a field added to one of these
/// structs fails here before it fails there.
#[test]
fn the_identity_requests_send_only_the_keys_the_grant_allows() {
use rustify::endpoint::Endpoint as _;
let keys = |body: Option<Vec<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"
);
}
}

View file

@ -200,10 +200,83 @@ pub fn render_agent(agent: &str) -> Result<String, Error> {
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)]
mod tests {
use super::*;
#[test]
fn the_shared_document_is_the_per_agent_one_with_the_entity_name_for_the_agent() {
let shared = render_shared_agent();
assert_eq!(
shared,
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n"
);
// Same shape as the per-agent document, so the rule is one rule.
assert_eq!(
shared.replace("{{identity.entity.name}}", "atlas"),
render_agent("atlas").expect("legal")
);
}
#[test]
fn the_shared_document_names_no_agent_and_grants_no_wider_than_one() {
let shared = render_shared_agent();
assert!(!shared.contains("atlas"));
assert!(!shared.contains("agents/*"), "{shared}");
assert!(!shared.contains("create"));
assert!(!shared.contains("update"));
assert!(!shared.contains("delete"));
}
#[test]
fn the_shared_policy_name_is_writable_and_no_agents_or_hives() {
assert!(SHARED_AGENT_POLICY.starts_with(HIVE_PREFIX));
assert!(
agents_from_role_names(&[SHARED_AGENT_POLICY.to_owned()]).is_empty(),
"it must not be read back as an agent"
);
// The only hive name that would render it carries `agent`.
assert_eq!(
hive_object_name("agent").expect("legal as a segment"),
SHARED_AGENT_POLICY
);
}
#[test]
fn the_document_grants_every_agent_and_this_hive_alone() {
assert_eq!(