use core::fmt;
use std::collections::BTreeMap;
use secrecy::{ExposeSecret, SecretString};
use subtle::ConstantTimeEq;
#[cfg(feature = "approle")]
use crate::auth::approle::{AppRoleRoleRequest, AppRoleSecretId, AppRoleSecretIdRequest};
#[cfg(feature = "identity")]
use crate::secrets::identity::{
IdentityEntityInfo, IdentityEntityRequest, IdentityGroupInfo, IdentityGroupRequest,
};
#[cfg(feature = "pki")]
use crate::secrets::pki::PkiRole;
use crate::{
AclPolicyBuilder, Authenticated, Client, Error, Result,
auth::token::{TokenAuth, TokenCreateRequest},
path::{validate_endpoint_path, validate_mount_path},
secrets::{
kv2::{Kv2ServiceConfig, Kv2WriteOptions},
transit::TransitCreateKeyRequest,
},
sys::{AuthEnableRequest, MountEnableRequest, PolicyWriteRequest},
};
const MAX_BOOTSTRAP_OPERATIONS: usize = 512;
#[derive(Clone, Debug, Default)]
pub struct AdminBootstrap {
operations: Vec<BootstrapOperation>,
}
#[derive(Clone)]
enum BootstrapOperation {
AuthMethod {
path: String,
backend_type: String,
description: Option<String>,
},
Kv2Mount {
path: String,
description: Option<String>,
},
TransitMount {
path: String,
description: Option<String>,
},
TransitKey {
mount: String,
name: String,
request: TransitCreateKeyRequest,
},
Policy {
name: String,
policy: String,
},
Kv2SecretValues {
mount: String,
path: String,
values: BTreeMap<String, SecretString>,
},
#[cfg(feature = "pki")]
PkiRole {
mount: String,
name: String,
role: Box<PkiRole>,
},
#[cfg(feature = "identity")]
IdentityEntity {
mount: String,
name: String,
request: IdentityEntityRequest,
},
#[cfg(feature = "identity")]
IdentityGroup {
mount: String,
name: String,
request: IdentityGroupRequest,
},
ServiceToken {
name: String,
request: TokenCreateRequest,
},
#[cfg(feature = "approle")]
AppRoleRole {
mount: String,
name: String,
request: AppRoleRoleRequest,
},
#[cfg(feature = "approle")]
AppRoleSecretId {
name: String,
mount: String,
role_name: String,
request: AppRoleSecretIdRequest,
},
}
#[derive(Debug, Default)]
pub struct BootstrapReport {
pub steps: Vec<BootstrapStepReport>,
pub issued_tokens: Vec<BootstrapIssuedToken>,
#[cfg(feature = "approle")]
pub issued_approle_secret_ids: Vec<BootstrapIssuedAppRoleSecretId>,
}
impl BootstrapReport {
#[must_use]
pub fn issued_token(&self, name: &str) -> Option<&BootstrapIssuedToken> {
self.issued_tokens.iter().find(|token| token.name == name)
}
#[cfg(feature = "approle")]
#[must_use]
pub fn issued_approle_secret_id(&self, name: &str) -> Option<&BootstrapIssuedAppRoleSecretId> {
self.issued_approle_secret_ids
.iter()
.find(|secret_id| secret_id.name == name)
}
#[must_use]
pub fn is_converged(&self) -> bool {
self.steps
.iter()
.all(|step| step.status == BootstrapStepStatus::Unchanged)
}
pub fn changed_steps(&self) -> impl Iterator<Item = &BootstrapStepReport> {
self.steps
.iter()
.filter(|step| step.status != BootstrapStepStatus::Unchanged)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BootstrapPreviewReport {
pub steps: Vec<BootstrapPreviewStep>,
}
impl BootstrapPreviewReport {
#[must_use]
pub fn is_converged(&self) -> bool {
self.steps
.iter()
.all(|step| step.status == BootstrapPreviewStatus::Unchanged)
}
pub fn changed_steps(&self) -> impl Iterator<Item = &BootstrapPreviewStep> {
self.steps
.iter()
.filter(|step| step.status != BootstrapPreviewStatus::Unchanged)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BootstrapStepStatus {
Unchanged,
Created,
Updated,
Issued,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BootstrapPreviewStatus {
Unchanged,
WouldCreate,
WouldUpdate,
WouldIssue,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BootstrapStepReport {
pub target_type: &'static str,
pub target: String,
pub status: BootstrapStepStatus,
}
impl BootstrapStepReport {
fn new(
target_type: &'static str,
target: impl Into<String>,
status: BootstrapStepStatus,
) -> Self {
Self {
target_type,
target: target.into(),
status,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BootstrapPreviewStep {
pub target_type: &'static str,
pub target: String,
pub status: BootstrapPreviewStatus,
}
impl BootstrapPreviewStep {
fn new(
target_type: &'static str,
target: impl Into<String>,
status: BootstrapPreviewStatus,
) -> Self {
Self {
target_type,
target: target.into(),
status,
}
}
}
pub struct BootstrapIssuedToken {
pub name: String,
pub auth: TokenAuth,
}
#[cfg(feature = "approle")]
pub struct BootstrapIssuedAppRoleSecretId {
pub name: String,
pub secret_id: AppRoleSecretId,
}
#[cfg(feature = "approle")]
impl fmt::Debug for BootstrapIssuedAppRoleSecretId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BootstrapIssuedAppRoleSecretId")
.field("name", &self.name)
.field("secret_id", &"<redacted>")
.finish()
}
}
impl fmt::Debug for BootstrapIssuedToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BootstrapIssuedToken")
.field("name", &self.name)
.field("auth", &"<redacted>")
.finish()
}
}
impl fmt::Debug for BootstrapOperation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AuthMethod {
path,
backend_type,
description,
} => formatter
.debug_struct("AuthMethod")
.field("path", path)
.field("backend_type", backend_type)
.field("description", description)
.finish(),
Self::Kv2Mount { path, description } => formatter
.debug_struct("Kv2Mount")
.field("path", path)
.field("description", description)
.finish(),
Self::TransitMount { path, description } => formatter
.debug_struct("TransitMount")
.field("path", path)
.field("description", description)
.finish(),
Self::TransitKey { mount, name, .. } => formatter
.debug_struct("TransitKey")
.field("mount", mount)
.field("name", name)
.field("request", &"<redacted>")
.finish(),
Self::Policy { name, .. } => formatter
.debug_struct("Policy")
.field("name", name)
.field("policy", &"<redacted>")
.finish(),
Self::Kv2SecretValues { mount, path, .. } => formatter
.debug_struct("Kv2SecretValues")
.field("mount", mount)
.field("path", path)
.field("values", &"<redacted>")
.finish(),
#[cfg(feature = "pki")]
Self::PkiRole { mount, name, role } => formatter
.debug_struct("PkiRole")
.field("mount", mount)
.field("name", name)
.field("role", role)
.finish(),
#[cfg(feature = "identity")]
Self::IdentityEntity { mount, name, .. } => formatter
.debug_struct("IdentityEntity")
.field("mount", mount)
.field("name", name)
.field("request", &"<redacted>")
.finish(),
#[cfg(feature = "identity")]
Self::IdentityGroup { mount, name, .. } => formatter
.debug_struct("IdentityGroup")
.field("mount", mount)
.field("name", name)
.field("request", &"<redacted>")
.finish(),
Self::ServiceToken { name, .. } => formatter
.debug_struct("ServiceToken")
.field("name", name)
.field("request", &"<redacted>")
.finish(),
#[cfg(feature = "approle")]
Self::AppRoleRole { mount, name, .. } => formatter
.debug_struct("AppRoleRole")
.field("mount", mount)
.field("name", name)
.field("request", &"<redacted>")
.finish(),
#[cfg(feature = "approle")]
Self::AppRoleSecretId {
name,
mount,
role_name,
..
} => formatter
.debug_struct("AppRoleSecretId")
.field("name", name)
.field("mount", mount)
.field("role_name", role_name)
.field("request", &"<redacted>")
.finish(),
}
}
}
impl AdminBootstrap {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn ensure_auth_method(
&mut self,
path: impl AsRef<str>,
backend_type: impl AsRef<str>,
description: Option<&str>,
) -> Result<&mut Self> {
let path = validate_mount_path(path.as_ref())?.join("/");
let backend_type = validate_mount_path(backend_type.as_ref())?.join("/");
self.push_operation(BootstrapOperation::AuthMethod {
path,
backend_type,
description: description.map(str::to_owned),
})
}
pub fn ensure_approle_auth_method(
&mut self,
path: impl AsRef<str>,
description: Option<&str>,
) -> Result<&mut Self> {
self.ensure_auth_method(path, "approle", description)
}
pub fn ensure_kv2_mount(
&mut self,
path: impl AsRef<str>,
description: Option<&str>,
) -> Result<&mut Self> {
let path = validate_mount_path(path.as_ref())?.join("/");
self.push_operation(BootstrapOperation::Kv2Mount {
path,
description: description.map(str::to_owned),
})
}
pub fn ensure_transit_mount(
&mut self,
path: impl AsRef<str>,
description: Option<&str>,
) -> Result<&mut Self> {
let path = validate_mount_path(path.as_ref())?.join("/");
self.push_operation(BootstrapOperation::TransitMount {
path,
description: description.map(str::to_owned),
})
}
pub fn ensure_transit_key(
&mut self,
mount: impl AsRef<str>,
name: impl AsRef<str>,
request: TransitCreateKeyRequest,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let name = validate_mount_path(name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::TransitKey {
mount,
name,
request,
})
}
pub fn ensure_policy(
&mut self,
name: impl AsRef<str>,
policy: &AclPolicyBuilder,
) -> Result<&mut Self> {
self.ensure_policy_document(name, policy.build()?)
}
pub fn ensure_policy_document(
&mut self,
name: impl AsRef<str>,
policy: impl Into<String>,
) -> Result<&mut Self> {
let name = validate_mount_path(name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::Policy {
name,
policy: policy.into(),
})
}
pub fn ensure_kv2_secret_values(
&mut self,
mount: impl AsRef<str>,
path: impl AsRef<str>,
values: BTreeMap<String, SecretString>,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let path = validate_endpoint_path(path.as_ref())?.join("/");
self.push_operation(BootstrapOperation::Kv2SecretValues {
mount,
path,
values,
})
}
#[cfg(feature = "pki")]
pub fn ensure_pki_role(
&mut self,
mount: impl AsRef<str>,
name: impl AsRef<str>,
role: PkiRole,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let name = validate_mount_path(name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::PkiRole {
mount,
name,
role: Box::new(role),
})
}
#[cfg(feature = "identity")]
pub fn ensure_identity_entity(
&mut self,
name: impl AsRef<str>,
request: IdentityEntityRequest,
) -> Result<&mut Self> {
self.ensure_identity_entity_at("identity", name, request)
}
#[cfg(feature = "identity")]
pub fn ensure_identity_entity_at(
&mut self,
mount: impl AsRef<str>,
name: impl AsRef<str>,
request: IdentityEntityRequest,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let name = validate_mount_path(name.as_ref())?.join("/");
if request.name.as_deref() != Some(name.as_str()) && request.name.is_some() {
return Err(Error::InvalidParameter(
"identity entity request name must match bootstrap target name".into(),
));
}
self.push_operation(BootstrapOperation::IdentityEntity {
mount,
name,
request,
})
}
#[cfg(feature = "identity")]
pub fn ensure_identity_group(
&mut self,
name: impl AsRef<str>,
request: IdentityGroupRequest,
) -> Result<&mut Self> {
self.ensure_identity_group_at("identity", name, request)
}
#[cfg(feature = "identity")]
pub fn ensure_identity_group_at(
&mut self,
mount: impl AsRef<str>,
name: impl AsRef<str>,
request: IdentityGroupRequest,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let name = validate_mount_path(name.as_ref())?.join("/");
if request.name.as_deref() != Some(name.as_str()) && request.name.is_some() {
return Err(Error::InvalidParameter(
"identity group request name must match bootstrap target name".into(),
));
}
self.push_operation(BootstrapOperation::IdentityGroup {
mount,
name,
request,
})
}
pub fn issue_service_token(
&mut self,
name: impl AsRef<str>,
request: TokenCreateRequest,
) -> Result<&mut Self> {
let name = validate_mount_path(name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::ServiceToken { name, request })
}
#[cfg(feature = "approle")]
pub fn ensure_approle_role(
&mut self,
mount: impl AsRef<str>,
name: impl AsRef<str>,
request: AppRoleRoleRequest,
) -> Result<&mut Self> {
let mount = validate_mount_path(mount.as_ref())?.join("/");
let name = validate_mount_path(name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::AppRoleRole {
mount,
name,
request,
})
}
#[cfg(feature = "approle")]
pub fn issue_approle_secret_id(
&mut self,
name: impl AsRef<str>,
mount: impl AsRef<str>,
role_name: impl AsRef<str>,
request: AppRoleSecretIdRequest,
) -> Result<&mut Self> {
let name = validate_mount_path(name.as_ref())?.join("/");
let mount = validate_mount_path(mount.as_ref())?.join("/");
let role_name = validate_mount_path(role_name.as_ref())?.join("/");
self.push_operation(BootstrapOperation::AppRoleSecretId {
name,
mount,
role_name,
request,
})
}
fn push_operation(&mut self, operation: BootstrapOperation) -> Result<&mut Self> {
if self.operations.len() >= MAX_BOOTSTRAP_OPERATIONS {
return Err(Error::InvalidParameter(
"bootstrap plan exceeds maximum allowed operation count".into(),
));
}
self.operations.push(operation);
Ok(self)
}
pub async fn preview(&self, client: &Client<Authenticated>) -> Result<BootstrapPreviewReport> {
let mut report = BootstrapPreviewReport::default();
for operation in &self.operations {
match operation {
BootstrapOperation::AuthMethod {
path, backend_type, ..
} => {
let status = preview_auth_method(client, path, backend_type).await?;
report
.steps
.push(BootstrapPreviewStep::new("auth_method", path, status));
}
BootstrapOperation::Kv2Mount { path, .. } => {
let status = preview_mount(client, path, "kv", Some(("version", "2"))).await?;
report
.steps
.push(BootstrapPreviewStep::new("kv2_mount", path, status));
}
BootstrapOperation::TransitMount { path, .. } => {
let status = preview_mount(client, path, "transit", None).await?;
report
.steps
.push(BootstrapPreviewStep::new("transit_mount", path, status));
}
BootstrapOperation::TransitKey { mount, name, .. } => {
let status = match client.transit(mount)?.read_key(name).await {
Ok(_) => BootstrapPreviewStatus::Unchanged,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report.steps.push(BootstrapPreviewStep::new(
"transit_key",
format!("{mount}/{name}"),
status,
));
}
BootstrapOperation::Policy { name, policy } => {
let status = match client.sys().read_policy(name).await {
Ok(existing) if existing.rules == *policy => {
BootstrapPreviewStatus::Unchanged
}
Ok(_) => BootstrapPreviewStatus::WouldUpdate,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report
.steps
.push(BootstrapPreviewStep::new("policy", name, status));
}
BootstrapOperation::Kv2SecretValues {
mount,
path,
values,
} => {
let kv = client.kv2(mount)?;
let current = match kv.read_service_config(path).await {
Ok(config) => Some(config),
Err(error) if error.is_not_found() => None,
Err(error) => return Err(error),
};
let needs_patch = current.as_ref().is_none_or(|config| {
values.iter().any(|(key, value)| {
config.get(key).is_none_or(|current| {
!secret_values_equal(current.expose_secret(), value.expose_secret())
})
})
});
let status = if needs_patch {
if current.is_some() {
BootstrapPreviewStatus::WouldUpdate
} else {
BootstrapPreviewStatus::WouldCreate
}
} else {
BootstrapPreviewStatus::Unchanged
};
report.steps.push(BootstrapPreviewStep::new(
"kv2_secret",
format!("{mount}/{path}"),
status,
));
}
#[cfg(feature = "pki")]
BootstrapOperation::PkiRole { mount, name, role } => {
let pki = client.pki(mount)?;
let status = match pki.read_role(name).await {
Ok(existing) if pki_role_matches_desired(&existing, role) => {
BootstrapPreviewStatus::Unchanged
}
Ok(_) => BootstrapPreviewStatus::WouldUpdate,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report.steps.push(BootstrapPreviewStep::new(
"pki_role",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "identity")]
BootstrapOperation::IdentityEntity {
mount,
name,
request,
} => {
let identity = client.identity_at(mount)?;
let status = match identity.read_entity_by_name(name).await {
Ok(existing) if identity_entity_matches_desired(&existing, request) => {
BootstrapPreviewStatus::Unchanged
}
Ok(_) => BootstrapPreviewStatus::WouldUpdate,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report.steps.push(BootstrapPreviewStep::new(
"identity_entity",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "identity")]
BootstrapOperation::IdentityGroup {
mount,
name,
request,
} => {
let identity = client.identity_at(mount)?;
let status = match identity.read_group_by_name(name).await {
Ok(existing) if identity_group_matches_desired(&existing, request) => {
BootstrapPreviewStatus::Unchanged
}
Ok(_) => BootstrapPreviewStatus::WouldUpdate,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report.steps.push(BootstrapPreviewStep::new(
"identity_group",
format!("{mount}/{name}"),
status,
));
}
BootstrapOperation::ServiceToken { name, .. } => {
report.steps.push(BootstrapPreviewStep::new(
"service_token",
name,
BootstrapPreviewStatus::WouldIssue,
));
}
#[cfg(feature = "approle")]
BootstrapOperation::AppRoleRole {
mount,
name,
request,
} => {
let admin = client.approle_admin_at(mount)?;
let status = match admin.read_role(name).await {
Ok(existing) if approle_role_matches_desired(&existing, request) => {
BootstrapPreviewStatus::Unchanged
}
Ok(_) => BootstrapPreviewStatus::WouldUpdate,
Err(error) if error.is_not_found() => BootstrapPreviewStatus::WouldCreate,
Err(error) => return Err(error),
};
report.steps.push(BootstrapPreviewStep::new(
"approle_role",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "approle")]
BootstrapOperation::AppRoleSecretId {
name,
mount,
role_name,
..
} => {
report.steps.push(BootstrapPreviewStep::new(
"approle_secret_id",
format!("{mount}/{role_name}/{name}"),
BootstrapPreviewStatus::WouldIssue,
));
}
}
}
Ok(report)
}
pub async fn run(&self, client: &Client<Authenticated>) -> Result<BootstrapReport> {
let mut report = BootstrapReport::default();
for operation in &self.operations {
match operation {
BootstrapOperation::AuthMethod {
path,
backend_type,
description,
} => {
let status = ensure_auth_method(client, path, backend_type, || {
AuthEnableRequest::new(backend_type.clone())
.with_optional_description(description)
})
.await?;
report
.steps
.push(BootstrapStepReport::new("auth_method", path, status));
}
BootstrapOperation::Kv2Mount { path, description } => {
let status = ensure_mount(client, path, "kv", Some(("version", "2")), || {
MountEnableRequest::kv2().with_optional_description(description)
})
.await?;
report
.steps
.push(BootstrapStepReport::new("kv2_mount", path, status));
}
BootstrapOperation::TransitMount { path, description } => {
let status = ensure_mount(client, path, "transit", None, || {
MountEnableRequest::new("transit").with_optional_description(description)
})
.await?;
report
.steps
.push(BootstrapStepReport::new("transit_mount", path, status));
}
BootstrapOperation::TransitKey {
mount,
name,
request,
} => {
let status = match client.transit(mount)?.read_key(name).await {
Ok(_) => BootstrapStepStatus::Unchanged,
Err(error) if error.is_not_found() => {
match client.transit(mount)?.create_key(name, request).await {
Ok(_) => BootstrapStepStatus::Created,
Err(error) if is_already_exists_error(&error) => {
BootstrapStepStatus::Unchanged
}
Err(error) => return Err(error),
}
}
Err(error) => return Err(error),
};
report.steps.push(BootstrapStepReport::new(
"transit_key",
format!("{mount}/{name}"),
status,
));
}
BootstrapOperation::Policy { name, policy } => {
let status = match client.sys().read_policy(name).await {
Ok(existing) if existing.rules == *policy => BootstrapStepStatus::Unchanged,
Ok(_) => {
client
.sys()
.write_policy(name, &PolicyWriteRequest::new(policy.clone()))
.await?;
BootstrapStepStatus::Updated
}
Err(error) if error.is_not_found() => {
client
.sys()
.write_policy(name, &PolicyWriteRequest::new(policy.clone()))
.await?;
BootstrapStepStatus::Created
}
Err(error) => return Err(error),
};
report
.steps
.push(BootstrapStepReport::new("policy", name, status));
}
BootstrapOperation::Kv2SecretValues {
mount,
path,
values,
} => {
let kv = client.kv2(mount)?;
let current = match kv.read::<Kv2ServiceConfig>(path).await {
Ok(secret) => Some(secret),
Err(error) if error.is_not_found() => None,
Err(error) => return Err(error),
};
let needs_patch = current.as_ref().is_none_or(|secret| {
values.iter().any(|(key, value)| {
secret.data.get(key).is_none_or(|current| {
!secret_values_equal(current.expose_secret(), value.expose_secret())
})
})
});
let status = if needs_patch {
if let Some(current) = current {
kv.patch_with_options(
path,
secret_patch_payload(values),
Some(Kv2WriteOptions {
cas: Some(current.metadata.version),
}),
)
.await?;
BootstrapStepStatus::Updated
} else {
kv.write_with_options(
path,
secret_patch_payload(values),
Some(Kv2WriteOptions { cas: Some(0) }),
)
.await?;
BootstrapStepStatus::Created
}
} else {
BootstrapStepStatus::Unchanged
};
report.steps.push(BootstrapStepReport::new(
"kv2_secret",
format!("{mount}/{path}"),
status,
));
}
#[cfg(feature = "pki")]
BootstrapOperation::PkiRole { mount, name, role } => {
let pki = client.pki(mount)?;
let status = match pki.read_role(name).await {
Ok(existing) if pki_role_matches_desired(&existing, role) => {
BootstrapStepStatus::Unchanged
}
Ok(_) => {
pki.write_role(name, role).await?;
BootstrapStepStatus::Updated
}
Err(error) if error.is_not_found() => {
pki.write_role(name, role).await?;
BootstrapStepStatus::Created
}
Err(error) => return Err(error),
};
report.steps.push(BootstrapStepReport::new(
"pki_role",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "identity")]
BootstrapOperation::IdentityEntity {
mount,
name,
request,
} => {
let identity = client.identity_at(mount)?;
let status = match identity.read_entity_by_name(name).await {
Ok(existing) if identity_entity_matches_desired(&existing, request) => {
BootstrapStepStatus::Unchanged
}
Ok(_) => {
identity.write_entity_by_name(name, request).await?;
BootstrapStepStatus::Updated
}
Err(error) if error.is_not_found() => {
identity.write_entity_by_name(name, request).await?;
BootstrapStepStatus::Created
}
Err(error) => return Err(error),
};
report.steps.push(BootstrapStepReport::new(
"identity_entity",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "identity")]
BootstrapOperation::IdentityGroup {
mount,
name,
request,
} => {
let identity = client.identity_at(mount)?;
let status = match identity.read_group_by_name(name).await {
Ok(existing) if identity_group_matches_desired(&existing, request) => {
BootstrapStepStatus::Unchanged
}
Ok(_) => {
identity.write_group_by_name(name, request).await?;
BootstrapStepStatus::Updated
}
Err(error) if error.is_not_found() => {
identity.write_group_by_name(name, request).await?;
BootstrapStepStatus::Created
}
Err(error) => return Err(error),
};
report.steps.push(BootstrapStepReport::new(
"identity_group",
format!("{mount}/{name}"),
status,
));
}
BootstrapOperation::ServiceToken { name, request } => {
let auth = client.token().create(request).await?;
report.steps.push(BootstrapStepReport::new(
"service_token",
name,
BootstrapStepStatus::Issued,
));
report.issued_tokens.push(BootstrapIssuedToken {
name: name.clone(),
auth,
});
}
#[cfg(feature = "approle")]
BootstrapOperation::AppRoleRole {
mount,
name,
request,
} => {
let admin = client.approle_admin_at(mount)?;
let status = match admin.read_role(name).await {
Ok(existing) if approle_role_matches_desired(&existing, request) => {
BootstrapStepStatus::Unchanged
}
Ok(_) => {
admin.write_role(name, request).await?;
BootstrapStepStatus::Updated
}
Err(error) if error.is_not_found() => {
admin.write_role(name, request).await?;
BootstrapStepStatus::Created
}
Err(error) => return Err(error),
};
report.steps.push(BootstrapStepReport::new(
"approle_role",
format!("{mount}/{name}"),
status,
));
}
#[cfg(feature = "approle")]
BootstrapOperation::AppRoleSecretId {
name,
mount,
role_name,
request,
} => {
let secret_id = client
.approle_admin_at(mount)?
.generate_secret_id(role_name, request)
.await?;
report.steps.push(BootstrapStepReport::new(
"approle_secret_id",
format!("{mount}/{role_name}/{name}"),
BootstrapStepStatus::Issued,
));
report
.issued_approle_secret_ids
.push(BootstrapIssuedAppRoleSecretId {
name: name.clone(),
secret_id,
});
}
}
}
Ok(report)
}
}
trait MountDescriptionExt {
fn with_optional_description(self, description: &Option<String>) -> Self;
}
impl MountDescriptionExt for AuthEnableRequest {
fn with_optional_description(mut self, description: &Option<String>) -> Self {
self.description.clone_from(description);
self
}
}
impl MountDescriptionExt for MountEnableRequest {
fn with_optional_description(mut self, description: &Option<String>) -> Self {
self.description.clone_from(description);
self
}
}
async fn ensure_auth_method<F>(
client: &Client<Authenticated>,
path: &str,
expected_type: &str,
request: F,
) -> Result<BootstrapStepStatus>
where
F: FnOnce() -> AuthEnableRequest,
{
let key = format!("{path}/");
let auth_methods = client.sys().list_auth_methods().await?;
match auth_methods.get(&key).or_else(|| auth_methods.get(path)) {
Some(auth) => {
if auth.backend_type != expected_type {
return Err(Error::InvalidParameter(format!(
"auth method `{path}` exists with type `{}` instead of `{expected_type}`",
auth.backend_type
)));
}
Ok(BootstrapStepStatus::Unchanged)
}
None => match client.sys().enable_auth_method(path, &request()).await {
Ok(_) => Ok(BootstrapStepStatus::Created),
Err(error) if is_already_exists_error(&error) => Ok(BootstrapStepStatus::Unchanged),
Err(error) => Err(error),
},
}
}
async fn preview_auth_method(
client: &Client<Authenticated>,
path: &str,
expected_type: &str,
) -> Result<BootstrapPreviewStatus> {
let key = format!("{path}/");
let auth_methods = client.sys().list_auth_methods().await?;
match auth_methods.get(&key).or_else(|| auth_methods.get(path)) {
Some(auth) => {
if auth.backend_type != expected_type {
return Err(Error::InvalidParameter(format!(
"auth method `{path}` exists with type `{}` instead of `{expected_type}`",
auth.backend_type
)));
}
Ok(BootstrapPreviewStatus::Unchanged)
}
None => Ok(BootstrapPreviewStatus::WouldCreate),
}
}
async fn ensure_mount<F>(
client: &Client<Authenticated>,
path: &str,
expected_type: &str,
expected_option: Option<(&str, &str)>,
request: F,
) -> Result<BootstrapStepStatus>
where
F: FnOnce() -> MountEnableRequest,
{
match client.sys().read_mount(path).await {
Ok(mount) => {
if mount.backend_type != expected_type {
return Err(Error::InvalidParameter(format!(
"mount `{path}` exists with type `{}` instead of `{expected_type}`",
mount.backend_type
)));
}
if let Some((key, value)) = expected_option
&& mount
.options
.as_ref()
.and_then(|options| options.get(key))
.map(String::as_str)
!= Some(value)
{
return Err(Error::InvalidParameter(format!(
"mount `{path}` exists without required option `{key}={value}`"
)));
}
Ok(BootstrapStepStatus::Unchanged)
}
Err(error) if error.is_not_found() => {
match client.sys().enable_mount(path, &request()).await {
Ok(_) => Ok(BootstrapStepStatus::Created),
Err(error) if is_already_exists_error(&error) => Ok(BootstrapStepStatus::Unchanged),
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
async fn preview_mount(
client: &Client<Authenticated>,
path: &str,
expected_type: &str,
expected_option: Option<(&str, &str)>,
) -> Result<BootstrapPreviewStatus> {
match client.sys().read_mount(path).await {
Ok(mount) => {
if mount.backend_type != expected_type {
return Err(Error::InvalidParameter(format!(
"mount `{path}` exists with type `{}` instead of `{expected_type}`",
mount.backend_type
)));
}
if let Some((key, value)) = expected_option
&& mount
.options
.as_ref()
.and_then(|options| options.get(key))
.map(String::as_str)
!= Some(value)
{
return Err(Error::InvalidParameter(format!(
"mount `{path}` exists without required option `{key}={value}`"
)));
}
Ok(BootstrapPreviewStatus::Unchanged)
}
Err(error) if error.is_not_found() => Ok(BootstrapPreviewStatus::WouldCreate),
Err(error) => Err(error),
}
}
fn is_already_exists_error(error: &Error) -> bool {
error.is_conflict()
}
fn secret_values_equal(current: &str, desired: &str) -> bool {
current.as_bytes().ct_eq(desired.as_bytes()).into()
}
fn secret_patch_payload(values: &BTreeMap<String, SecretString>) -> BTreeMap<String, &str> {
values
.iter()
.map(|(key, value)| (key.clone(), value.expose_secret()))
.collect()
}
#[cfg(feature = "pki")]
fn pki_role_matches_desired(existing: &PkiRole, desired: &PkiRole) -> bool {
desired
.issuer_ref
.as_ref()
.is_none_or(|value| existing.issuer_ref.as_ref() == Some(value))
&& vec_empty_or_equal(&existing.allowed_domains, &desired.allowed_domains)
&& desired
.allow_subdomains
.is_none_or(|value| existing.allow_subdomains == Some(value))
&& desired
.ttl
.as_ref()
.is_none_or(|value| existing.ttl.as_ref() == Some(value))
&& desired
.max_ttl
.as_ref()
.is_none_or(|value| existing.max_ttl.as_ref() == Some(value))
&& desired
.key_type
.as_ref()
.is_none_or(|value| existing.key_type.as_ref() == Some(value))
&& desired
.key_bits
.is_none_or(|value| existing.key_bits == Some(value))
}
#[cfg(feature = "identity")]
fn identity_entity_matches_desired(
existing: &IdentityEntityInfo,
desired: &IdentityEntityRequest,
) -> bool {
desired
.name
.as_ref()
.is_none_or(|value| existing.name.as_ref() == Some(value))
&& vec_empty_or_equal(&existing.policies, &desired.policies)
&& map_empty_or_contains(&existing.metadata, &desired.metadata)
&& desired
.disabled
.is_none_or(|value| existing.disabled == value)
}
#[cfg(feature = "identity")]
fn identity_group_matches_desired(
existing: &IdentityGroupInfo,
desired: &IdentityGroupRequest,
) -> bool {
desired
.name
.as_ref()
.is_none_or(|value| existing.name.as_ref() == Some(value))
&& desired
.group_type
.is_none_or(|value| existing.group_type == Some(value))
&& vec_empty_or_equal(&existing.policies, &desired.policies)
&& vec_empty_or_equal(&existing.member_entity_ids, &desired.member_entity_ids)
&& vec_empty_or_equal(&existing.member_group_ids, &desired.member_group_ids)
&& map_empty_or_contains(&existing.metadata, &desired.metadata)
}
#[cfg(feature = "approle")]
fn approle_role_matches_desired(
existing: &AppRoleRoleRequest,
desired: &AppRoleRoleRequest,
) -> bool {
desired
.bind_secret_id
.is_none_or(|value| existing.bind_secret_id == Some(value))
&& vec_empty_or_equal(
&existing.secret_id_bound_cidrs,
&desired.secret_id_bound_cidrs,
)
&& desired
.secret_id_num_uses
.is_none_or(|value| existing.secret_id_num_uses == Some(value))
&& desired
.secret_id_ttl
.as_ref()
.is_none_or(|value| existing.secret_id_ttl.as_ref() == Some(value))
&& desired
.local_secret_ids
.is_none_or(|value| existing.local_secret_ids == Some(value))
&& desired
.token_ttl
.as_ref()
.is_none_or(|value| existing.token_ttl.as_ref() == Some(value))
&& desired
.token_max_ttl
.as_ref()
.is_none_or(|value| existing.token_max_ttl.as_ref() == Some(value))
&& vec_empty_or_equal(&existing.token_policies, &desired.token_policies)
&& vec_empty_or_equal(&existing.token_bound_cidrs, &desired.token_bound_cidrs)
&& desired
.token_strictly_bind_ip
.is_none_or(|value| existing.token_strictly_bind_ip == Some(value))
&& desired
.token_explicit_max_ttl
.as_ref()
.is_none_or(|value| existing.token_explicit_max_ttl.as_ref() == Some(value))
&& desired
.token_no_default_policy
.is_none_or(|value| existing.token_no_default_policy == Some(value))
&& desired
.token_num_uses
.is_none_or(|value| existing.token_num_uses == Some(value))
&& desired
.token_period
.as_ref()
.is_none_or(|value| existing.token_period.as_ref() == Some(value))
&& desired
.token_type
.as_ref()
.is_none_or(|value| existing.token_type.as_ref() == Some(value))
}
fn vec_empty_or_equal(existing: &[String], desired: &[String]) -> bool {
desired.is_empty() || existing == desired
}
#[cfg(feature = "identity")]
fn map_empty_or_contains(
existing: &BTreeMap<String, String>,
desired: &BTreeMap<String, String>,
) -> bool {
desired
.iter()
.all(|(key, value)| existing.get(key) == Some(value))
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
#![allow(deprecated)]
use std::collections::BTreeMap;
use secrecy::SecretString;
#[cfg(feature = "identity")]
use crate::secrets::identity::{
IdentityEntityInfo, IdentityEntityRequest, IdentityGroupInfo, IdentityGroupRequest,
IdentityGroupType,
};
#[cfg(feature = "pki")]
use crate::secrets::pki::PkiRole;
use crate::{
AclCapability, AclPolicyBuilder, Authenticated, Client, Error, OpenBaoConfig,
auth::token::TokenCreateRequest,
bootstrap::{
AdminBootstrap, BootstrapPreviewReport, BootstrapPreviewStatus, BootstrapPreviewStep,
BootstrapStepStatus, MAX_BOOTSTRAP_OPERATIONS, is_already_exists_error,
secret_values_equal,
},
secrets::transit::TransitCreateKeyRequest,
};
use reqwest::StatusCode;
#[test]
fn bootstrap_validates_paths_when_building_plan() {
let mut bootstrap = AdminBootstrap::new();
assert!(bootstrap.ensure_kv2_mount("../secret", None).is_err());
assert!(
bootstrap
.ensure_transit_key("transit", "../key", TransitCreateKeyRequest::default())
.is_err()
);
}
#[test]
fn issued_token_debug_redacts_auth() {
let config = OpenBaoConfig::new("http://127.0.0.1:8200")
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client: Client<Authenticated> = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("token"));
let mut policy = AclPolicyBuilder::new();
policy
.allow_path("secret/data/app", [AclCapability::Read])
.unwrap_or_else(|error| panic!("{error}"));
let mut values = BTreeMap::new();
let sensitive_value = ["sensitive-", "value"].concat();
values.insert(
"API_KEY".to_owned(),
SecretString::from(sensitive_value.clone()),
);
let mut bootstrap = AdminBootstrap::new();
bootstrap
.ensure_policy("app-read", &policy)
.and_then(|builder| builder.ensure_kv2_secret_values("secret", "app", values))
.and_then(|builder| {
builder.issue_service_token(
"app",
TokenCreateRequest {
policies: vec!["app-read".to_owned()],
no_default_policy: Some(true),
..TokenCreateRequest::default()
},
)
})
.unwrap_or_else(|error| panic!("{error}"));
let report = format!("{:?}", bootstrap.operations);
assert!(!report.contains(&sensitive_value));
let _client = client;
}
#[test]
fn bootstrap_statuses_are_stable_values() {
assert_eq!(BootstrapStepStatus::Created, BootstrapStepStatus::Created);
assert_ne!(BootstrapStepStatus::Created, BootstrapStepStatus::Unchanged);
assert_eq!(
BootstrapPreviewStatus::WouldCreate,
BootstrapPreviewStatus::WouldCreate
);
assert_ne!(
BootstrapPreviewStatus::WouldCreate,
BootstrapPreviewStatus::Unchanged
);
}
#[test]
fn bootstrap_plan_operation_count_is_bounded() {
let mut bootstrap = AdminBootstrap::new();
for index in 0..MAX_BOOTSTRAP_OPERATIONS {
bootstrap
.ensure_policy_document(
format!("policy-{index}"),
"path \"secret/data/app\" { capabilities = [\"read\"] }",
)
.unwrap_or_else(|error| panic!("{error}"));
}
assert!(
bootstrap
.ensure_policy_document(
"one-too-many",
"path \"secret/data/app\" { capabilities = [\"read\"] }",
)
.is_err()
);
}
#[test]
fn bootstrap_secret_comparison_and_race_errors_are_handled() {
assert!(secret_values_equal("same-secret", "same-secret"));
assert!(!secret_values_equal("same-secret", "other-secret"));
let duplicate = Error::Api {
status: StatusCode::BAD_REQUEST,
errors: vec!["path is already in use".to_owned()],
};
assert!(is_already_exists_error(&duplicate));
let unrelated = Error::Api {
status: StatusCode::BAD_REQUEST,
errors: vec!["permission denied".to_owned()],
};
assert!(!is_already_exists_error(&unrelated));
}
#[test]
#[cfg(feature = "pki")]
fn pki_role_convergence_compares_desired_fields_only() {
let existing = PkiRole {
issuer_ref: Some("issuer-a".to_owned()),
allowed_domains: vec!["example.com".to_owned()],
allow_subdomains: Some(true),
ttl: Some("1h".to_owned()),
max_ttl: Some("24h".to_owned()),
key_type: Some("rsa".to_owned()),
key_bits: Some(3072),
..PkiRole::default()
};
let desired = PkiRole {
allowed_domains: vec!["example.com".to_owned()],
ttl: Some("1h".to_owned()),
..PkiRole::default()
};
assert!(super::pki_role_matches_desired(&existing, &desired));
let different = PkiRole {
allowed_domains: vec!["internal.example.com".to_owned()],
..PkiRole::default()
};
assert!(!super::pki_role_matches_desired(&existing, &different));
}
#[test]
#[cfg(feature = "identity")]
fn identity_convergence_compares_desired_fields_only() {
let mut entity_metadata = BTreeMap::new();
entity_metadata.insert("owner".to_owned(), "platform".to_owned());
entity_metadata.insert("ignored".to_owned(), "extra".to_owned());
let existing_entity = IdentityEntityInfo {
name: Some("app".to_owned()),
policies: vec!["app-read".to_owned()],
metadata: entity_metadata,
disabled: false,
..IdentityEntityInfo::default()
};
let desired_entity = IdentityEntityRequest::named("app")
.with_policy("app-read")
.with_metadata("owner", "platform");
assert!(super::identity_entity_matches_desired(
&existing_entity,
&desired_entity
));
let existing_group = IdentityGroupInfo {
name: Some("apps".to_owned()),
group_type: Some(IdentityGroupType::Internal),
policies: vec!["app-read".to_owned()],
member_entity_ids: vec!["entity-1".to_owned()],
..IdentityGroupInfo::default()
};
let desired_group = IdentityGroupRequest::internal("apps")
.with_policy("app-read")
.with_member_entity_id("entity-1");
assert!(super::identity_group_matches_desired(
&existing_group,
&desired_group
));
let different_group = IdentityGroupRequest::internal("apps").with_policy("admin");
assert!(!super::identity_group_matches_desired(
&existing_group,
&different_group
));
}
#[test]
#[cfg(feature = "identity")]
fn identity_bootstrap_rejects_mismatched_request_names() {
let mut bootstrap = AdminBootstrap::new();
assert!(
bootstrap
.ensure_identity_entity("app", IdentityEntityRequest::named("other"))
.is_err()
);
assert!(
bootstrap
.ensure_identity_group("apps", IdentityGroupRequest::internal("other"))
.is_err()
);
}
#[test]
fn bootstrap_report_helpers_find_issued_and_changed_steps() {
let report = crate::bootstrap::BootstrapReport {
steps: vec![
crate::bootstrap::BootstrapStepReport {
target_type: "policy",
target: "app".to_owned(),
status: BootstrapStepStatus::Unchanged,
},
crate::bootstrap::BootstrapStepReport {
target_type: "token",
target: "app".to_owned(),
status: BootstrapStepStatus::Issued,
},
],
issued_tokens: vec![crate::bootstrap::BootstrapIssuedToken {
name: "app".to_owned(),
auth: crate::auth::token::TokenAuth {
client_token: SecretString::from("token"),
accessor: SecretString::from("accessor"),
policies: Vec::new(),
token_policies: Vec::new(),
metadata: BTreeMap::new(),
lease_duration: 3600,
renewable: true,
entity_id: None,
token_type: None,
orphan: false,
},
}],
#[cfg(feature = "approle")]
issued_approle_secret_ids: Vec::new(),
};
assert!(report.issued_token("app").is_some());
assert!(report.issued_token("missing").is_none());
assert!(!report.is_converged());
assert_eq!(report.changed_steps().count(), 1);
}
#[test]
fn bootstrap_preview_report_helpers_find_changed_steps() {
let report = BootstrapPreviewReport {
steps: vec![
BootstrapPreviewStep {
target_type: "policy",
target: "app".to_owned(),
status: BootstrapPreviewStatus::Unchanged,
},
BootstrapPreviewStep {
target_type: "service_token",
target: "app".to_owned(),
status: BootstrapPreviewStatus::WouldIssue,
},
],
};
assert!(!report.is_converged());
assert_eq!(report.changed_steps().count(), 1);
}
}