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
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue