use std::collections::BTreeMap;
use reqwest::{
Method, StatusCode,
header::{HeaderName, HeaderValue},
};
use secrecy::{ExposeSecret, SecretString};
use serde::{
Deserialize, Deserializer, Serialize, Serializer,
de::{Error as DeError, Visitor},
};
use crate::{
Authenticated, Client, Error, Result,
path::{validate_mount_path, validate_secret_path},
response::{
Empty, ResponseEnvelope, WrapInfo, deserialize_bounded_string_vec,
deserialize_optional_bounded_string_vec,
},
};
#[derive(Debug)]
pub struct Sys<'a, State> {
client: &'a Client<State>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Health {
pub initialized: bool,
pub sealed: bool,
#[serde(default)]
pub standby: bool,
pub version: String,
#[serde(default)]
pub cluster_name: Option<String>,
#[serde(default)]
pub cluster_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SealStatus {
#[serde(rename = "type")]
pub seal_type: String,
pub initialized: bool,
pub sealed: bool,
#[serde(default)]
pub n: Option<u64>,
#[serde(default)]
pub t: Option<u64>,
#[serde(default)]
pub progress: Option<u64>,
pub version: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct MountInfo {
#[serde(rename = "type")]
pub backend_type: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub accessor: Option<SecretString>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub config: MountConfig,
#[serde(default)]
pub options: Option<BTreeMap<String, String>>,
#[serde(default)]
pub local: bool,
#[serde(default)]
pub seal_wrap: bool,
#[serde(default)]
pub external_entropy_access: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct MountConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_lease_ttl: Option<LeaseDuration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lease_ttl: Option<LeaseDuration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub force_no_cache: Option<bool>,
#[serde(
default,
deserialize_with = "deserialize_optional_bounded_string_vec",
skip_serializing_if = "Option::is_none"
)]
pub audit_non_hmac_request_keys: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "deserialize_optional_bounded_string_vec",
skip_serializing_if = "Option::is_none"
)]
pub audit_non_hmac_response_keys: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listing_visibility: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_bounded_string_vec",
skip_serializing_if = "Option::is_none"
)]
pub passthrough_request_headers: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "deserialize_optional_bounded_string_vec",
skip_serializing_if = "Option::is_none"
)]
pub allowed_response_headers: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_lockout_config: Option<UserLockoutConfig>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LeaseDuration {
Seconds(u64),
Duration(String),
}
impl Serialize for LeaseDuration {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Seconds(seconds) => serializer.serialize_u64(*seconds),
Self::Duration(duration) => serializer.serialize_str(duration),
}
}
}
impl<'de> Deserialize<'de> for LeaseDuration {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(LeaseDurationVisitor)
}
}
struct LeaseDurationVisitor;
impl Visitor<'_> for LeaseDurationVisitor {
type Value = LeaseDuration;
fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str("a non-negative second count or a duration string")
}
fn visit_u64<E>(self, value: u64) -> core::result::Result<Self::Value, E> {
Ok(LeaseDuration::Seconds(value))
}
fn visit_i64<E>(self, value: i64) -> core::result::Result<Self::Value, E>
where
E: DeError,
{
u64::try_from(value)
.map(LeaseDuration::Seconds)
.map_err(|_| E::custom("duration seconds must not be negative"))
}
fn visit_str<E>(self, value: &str) -> core::result::Result<Self::Value, E>
where
E: DeError,
{
validate_duration_string(value, true)
.then(|| LeaseDuration::Duration(value.to_owned()))
.ok_or_else(|| E::custom("invalid duration string"))
}
fn visit_string<E>(self, value: String) -> core::result::Result<Self::Value, E>
where
E: DeError,
{
validate_duration_string(&value, true)
.then_some(LeaseDuration::Duration(value))
.ok_or_else(|| E::custom("invalid duration string"))
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct UserLockoutConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockout_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockout_duration: Option<LeaseDuration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockout_counter_reset_duration: Option<LeaseDuration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockout_disable: Option<bool>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MountEnableRequest {
#[serde(rename = "type")]
pub backend_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<MountConfig>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub options: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seal_wrap: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_entropy_access: Option<bool>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AuthEnableRequest {
#[serde(rename = "type")]
pub backend_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<MountConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct WrappingLookup {
#[serde(default)]
pub creation_time: Option<String>,
#[serde(default)]
pub creation_path: Option<String>,
#[serde(default)]
pub creation_ttl: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PolicyList {
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
pub policies: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PolicyInfo {
pub name: String,
pub rules: String,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub version: Option<u64>,
#[serde(default)]
pub cas_required: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct PolicyWriteRequest {
pub policy: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cas: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cas_required: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Capabilities {
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
pub capabilities: Vec<String>,
#[serde(flatten)]
pub by_path: BTreeMap<String, Vec<String>>,
}
#[derive(Serialize)]
struct WrappingTokenPayload<'a> {
token: &'a str,
}
#[derive(Serialize)]
struct CapabilitiesPayload<'a> {
paths: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
accessor: Option<&'a str>,
}
impl<State> Client<State> {
pub fn sys(&self) -> Sys<'_, State> {
Sys { client: self }
}
}
impl<State> Sys<'_, State> {
pub async fn health(&self) -> Result<Health> {
self.client
.request_json_accepting(
Method::GET,
"sys/health",
Option::<&Empty>::None,
&[
StatusCode::OK,
StatusCode::NO_CONTENT,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::NOT_IMPLEMENTED,
StatusCode::SERVICE_UNAVAILABLE,
openbao_status(472)?,
openbao_status(473)?,
],
)
.await
}
pub async fn seal_status(&self) -> Result<SealStatus> {
self.client
.request_json(Method::GET, "sys/seal-status", Option::<&Empty>::None)
.await
}
}
impl Sys<'_, Authenticated> {
pub async fn list_mounts(&self) -> Result<BTreeMap<String, MountInfo>> {
let envelope: ResponseEnvelope<BTreeMap<String, MountInfo>> = self
.client
.request_json(Method::GET, "sys/mounts", Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
pub async fn read_mount(&self, mount_path: &str) -> Result<MountInfo> {
let envelope: ResponseEnvelope<MountInfo> = self
.client
.request_json(
Method::GET,
&sys_path("sys/mounts", mount_path, None)?,
Option::<&Empty>::None,
)
.await?;
Ok(envelope.data)
}
pub async fn enable_mount(
&self,
mount_path: &str,
request: &MountEnableRequest,
) -> Result<Empty> {
self.client
.request_json(
Method::POST,
&sys_path("sys/mounts", mount_path, None)?,
Some(request),
)
.await
}
pub async fn disable_mount(&self, mount_path: &str) -> Result<Empty> {
self.client
.request_json_accepting(
Method::DELETE,
&sys_path("sys/mounts", mount_path, None)?,
Option::<&Empty>::None,
&[StatusCode::OK, StatusCode::NO_CONTENT],
)
.await
}
pub async fn read_mount_tune(&self, mount_path: &str) -> Result<MountConfig> {
self.client
.request_json(
Method::GET,
&sys_path("sys/mounts", mount_path, Some("tune"))?,
Option::<&Empty>::None,
)
.await
}
pub async fn tune_mount(&self, mount_path: &str, config: &MountConfig) -> Result<Empty> {
self.client
.request_json(
Method::POST,
&sys_path("sys/mounts", mount_path, Some("tune"))?,
Some(config),
)
.await
}
pub async fn list_auth_methods(&self) -> Result<BTreeMap<String, MountInfo>> {
let envelope: ResponseEnvelope<BTreeMap<String, MountInfo>> = self
.client
.request_json(Method::GET, "sys/auth", Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
pub async fn enable_auth_method(
&self,
mount_path: &str,
request: &AuthEnableRequest,
) -> Result<Empty> {
self.client
.request_json(
Method::POST,
&sys_path("sys/auth", mount_path, None)?,
Some(request),
)
.await
}
pub async fn disable_auth_method(&self, mount_path: &str) -> Result<Empty> {
self.client
.request_json_accepting(
Method::DELETE,
&sys_path("sys/auth", mount_path, None)?,
Option::<&Empty>::None,
&[StatusCode::OK, StatusCode::NO_CONTENT],
)
.await
}
pub async fn read_auth_tune(&self, mount_path: &str) -> Result<MountConfig> {
self.client
.request_json(
Method::GET,
&sys_path("sys/auth", mount_path, Some("tune"))?,
Option::<&Empty>::None,
)
.await
}
pub async fn tune_auth_method(&self, mount_path: &str, config: &MountConfig) -> Result<Empty> {
self.client
.request_json(
Method::POST,
&sys_path("sys/auth", mount_path, Some("tune"))?,
Some(config),
)
.await
}
pub async fn list_policies(&self) -> Result<PolicyList> {
self.client
.request_json(Method::GET, "sys/policy", Option::<&Empty>::None)
.await
}
pub async fn list_policies_with_prefix(&self, prefix: &str) -> Result<PolicyList> {
let method =
Method::from_bytes(b"LIST").map_err(|error| Error::InvalidHeader(error.to_string()))?;
self.client
.request_json(
method,
&sys_path("sys/policy", prefix, None)?,
Option::<&Empty>::None,
)
.await
}
pub async fn read_policy(&self, name: &str) -> Result<PolicyInfo> {
self.client
.request_json(
Method::GET,
&sys_path("sys/policy", name, None)?,
Option::<&Empty>::None,
)
.await
}
pub async fn write_policy(&self, name: &str, request: &PolicyWriteRequest) -> Result<Empty> {
self.client
.request_json(
Method::POST,
&sys_path("sys/policy", name, None)?,
Some(request),
)
.await
}
pub async fn delete_policy(&self, name: &str) -> Result<Empty> {
self.client
.request_json_accepting(
Method::DELETE,
&sys_path("sys/policy", name, None)?,
Option::<&Empty>::None,
&[StatusCode::OK, StatusCode::NO_CONTENT],
)
.await
}
pub async fn capabilities_self<I, P>(&self, paths: I) -> Result<Capabilities>
where
I: IntoIterator<Item = P>,
P: AsRef<str>,
{
let paths = validate_capability_paths(paths)?;
let payload = CapabilitiesPayload {
paths: &paths,
token: None,
accessor: None,
};
let envelope: ResponseEnvelope<Capabilities> = self
.client
.request_json(Method::POST, "sys/capabilities-self", Some(&payload))
.await?;
Ok(envelope.data)
}
pub async fn capabilities<I, P>(&self, token: &SecretString, paths: I) -> Result<Capabilities>
where
I: IntoIterator<Item = P>,
P: AsRef<str>,
{
let paths = validate_capability_paths(paths)?;
let payload = CapabilitiesPayload {
paths: &paths,
token: Some(token.expose_secret()),
accessor: None,
};
let envelope: ResponseEnvelope<Capabilities> = self
.client
.request_json(Method::POST, "sys/capabilities", Some(&payload))
.await?;
Ok(envelope.data)
}
pub async fn capabilities_accessor<I, P>(
&self,
accessor: &SecretString,
paths: I,
) -> Result<Capabilities>
where
I: IntoIterator<Item = P>,
P: AsRef<str>,
{
let paths = validate_capability_paths(paths)?;
let payload = CapabilitiesPayload {
paths: &paths,
token: None,
accessor: Some(accessor.expose_secret()),
};
let envelope: ResponseEnvelope<Capabilities> = self
.client
.request_json(Method::POST, "sys/capabilities-accessor", Some(&payload))
.await?;
Ok(envelope.data)
}
pub async fn wrapping_lookup(&self, token: &SecretString) -> Result<WrappingLookup> {
let payload = WrappingTokenPayload {
token: token.expose_secret(),
};
let envelope: ResponseEnvelope<WrappingLookup> = self
.client
.request_json(Method::POST, "sys/wrapping/lookup", Some(&payload))
.await?;
Ok(envelope.data)
}
pub async fn wrapping_wrap<T>(&self, ttl: &str, data: &T) -> Result<WrapInfo>
where
T: Serialize + ?Sized,
{
validate_wrapping_ttl(ttl)?;
let ttl =
HeaderValue::from_str(ttl).map_err(|error| Error::InvalidHeader(error.to_string()))?;
let envelope: ResponseEnvelope<Option<Empty>> = self
.client
.request_json_headers_accepting(
Method::POST,
"sys/wrapping/wrap",
&[(HeaderName::from_static("x-vault-wrap-ttl"), ttl)],
Some(data),
&[StatusCode::OK],
)
.await?;
envelope.wrap_info.ok_or(Error::MissingField("wrap_info"))
}
pub async fn wrapping_unwrap<T>(&self, token: Option<&SecretString>) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
match token {
Some(token) => {
let payload = WrappingTokenPayload {
token: token.expose_secret(),
};
let envelope: ResponseEnvelope<T> = self
.client
.request_json(Method::POST, "sys/wrapping/unwrap", Some(&payload))
.await?;
Ok(envelope.data)
}
None => {
let envelope: ResponseEnvelope<T> = self
.client
.request_json(Method::POST, "sys/wrapping/unwrap", Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
}
}
pub async fn wrapping_rewrap(&self, token: &SecretString) -> Result<WrapInfo> {
let payload = WrappingTokenPayload {
token: token.expose_secret(),
};
let envelope: ResponseEnvelope<Option<Empty>> = self
.client
.request_json(Method::POST, "sys/wrapping/rewrap", Some(&payload))
.await?;
envelope.wrap_info.ok_or(Error::MissingField("wrap_info"))
}
}
fn openbao_status(code: u16) -> Result<StatusCode> {
StatusCode::from_u16(code)
.map_err(|_| crate::Error::Internal("invalid OpenBao health status code"))
}
fn sys_path(prefix: &str, mount_path: &str, suffix: Option<&str>) -> Result<String> {
let mut segments = vec![prefix.to_owned()];
segments.extend(validate_mount_path(mount_path)?);
if let Some(suffix) = suffix {
segments.push(suffix.to_owned());
}
Ok(segments.join("/"))
}
fn validate_wrapping_ttl(ttl: &str) -> Result<()> {
if validate_duration_string(ttl, false) {
return Ok(());
}
Err(Error::InvalidHeader(
"wrapping TTL must be a positive duration such as 30s, 5m, or 1h".into(),
))
}
fn validate_duration_string(value: &str, allow_zero: bool) -> bool {
if value.is_empty() {
return false;
}
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
let digit_start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
if digit_start == index {
return false;
}
if !allow_zero && bytes[digit_start..index].iter().all(|byte| *byte == b'0') {
return false;
}
if index >= bytes.len() {
return false;
}
match bytes[index] {
b's' | b'm' | b'h' => index += 1,
_ => return false,
}
}
true
}
fn validate_capability_paths<I, P>(paths: I) -> Result<Vec<String>>
where
I: IntoIterator<Item = P>,
P: AsRef<str>,
{
let mut validated = Vec::new();
for path in paths {
let path = path.as_ref();
if path.trim_matches('/').is_empty() {
return Err(Error::InvalidPath(
"capability path must not be empty".into(),
));
}
validated.push(validate_secret_path(path)?.join("/"));
}
if validated.is_empty() {
return Err(Error::InvalidPath(
"at least one capability path is required".into(),
));
}
Ok(validated)
}
fn deserialize_null_default<'de, D, T>(deserializer: D) -> core::result::Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::{
LeaseDuration, PolicyList, sys_path, validate_capability_paths, validate_wrapping_ttl,
};
#[test]
fn sys_paths_are_validated() {
assert_eq!(
sys_path("sys/mounts", "secret", Some("tune"))
.unwrap_or_else(|error| panic!("{error}")),
"sys/mounts/secret/tune"
);
assert!(sys_path("sys/mounts", "../secret", None).is_err());
}
#[test]
fn capability_paths_are_validated() {
let paths = validate_capability_paths(["secret/data/app", "/sys/policy/default"])
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(paths, ["secret/data/app", "sys/policy/default"]);
assert!(validate_capability_paths([""]).is_err());
assert!(validate_capability_paths(["../secret"]).is_err());
}
#[test]
fn wrapping_ttl_is_validated() {
assert!(validate_wrapping_ttl("30s").is_ok());
assert!(validate_wrapping_ttl("5m").is_ok());
assert!(validate_wrapping_ttl("1h").is_ok());
assert!(validate_wrapping_ttl("").is_err());
assert!(validate_wrapping_ttl("0s").is_err());
assert!(validate_wrapping_ttl("-1h").is_err());
assert!(validate_wrapping_ttl("forever").is_err());
}
#[test]
fn lease_duration_rejects_untyped_json() {
assert_eq!(
serde_json::from_str::<LeaseDuration>("3600").unwrap_or_else(|error| panic!("{error}")),
LeaseDuration::Seconds(3600)
);
assert_eq!(
serde_json::from_str::<LeaseDuration>(r#""30m""#)
.unwrap_or_else(|error| panic!("{error}")),
LeaseDuration::Duration("30m".to_owned())
);
assert!(serde_json::from_str::<LeaseDuration>("-1").is_err());
assert!(serde_json::from_str::<LeaseDuration>(r#""never""#).is_err());
assert!(serde_json::from_str::<LeaseDuration>(r#"{"ttl":3600}"#).is_err());
}
#[test]
fn policy_list_is_bounded() {
let mut policies = Vec::new();
for index in 0..=crate::response::MAX_RESPONSE_STRINGS {
policies.push(format!("policy-{index}"));
}
let value = serde_json::json!({ "policies": policies });
let error = match serde_json::from_value::<PolicyList>(value) {
Ok(_) => panic!("oversized policy list unexpectedly decoded"),
Err(error) => error,
};
assert!(error.to_string().contains("exceeds item limit"));
}
#[test]
fn mount_config_header_lists_are_bounded() {
let mut headers = Vec::new();
for index in 0..=crate::response::MAX_RESPONSE_STRINGS {
headers.push(format!("x-header-{index}"));
}
let value = serde_json::json!({ "allowed_response_headers": headers });
let error = match serde_json::from_value::<super::MountConfig>(value) {
Ok(_) => panic!("oversized mount header list unexpectedly decoded"),
Err(error) => error,
};
assert!(error.to_string().contains("exceeds item limit"));
}
}