use std::collections::BTreeMap;
use reqwest::Method;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use crate::{
Authenticated, Client, Error, Result, Unauthenticated,
path::validate_mount_path,
response::{
Empty, ListEntries, ResponseEnvelope, deserialize_bounded_string_map_or_default,
deserialize_bounded_string_vec,
},
};
#[derive(Debug)]
pub struct KubernetesAuth<'a> {
client: &'a Client<Unauthenticated>,
mount: String,
}
#[derive(Debug)]
pub struct KubernetesAuthAdmin<'a> {
client: &'a Client<Authenticated>,
mount: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct KubernetesConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubernetes_host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubernetes_ca_cert: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_reviewer_jwt: Option<SecretString>,
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pem_keys: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_local_ca_jwt: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct KubernetesRole {
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub bound_service_account_names: Vec<String>,
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub bound_service_account_namespaces: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias_name_source: Option<String>,
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub policies: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_ttl: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_max_ttl: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_period: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct KubernetesRoleList {
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
pub keys: Vec<String>,
}
impl ListEntries for KubernetesRoleList {
fn entries(&self) -> &[String] {
&self.keys
}
}
#[derive(Debug, Deserialize)]
pub struct KubernetesLoginMetadata {
pub accessor: SecretString,
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
pub policies: Vec<String>,
#[serde(default)]
pub lease_duration: u64,
#[serde(default)]
pub renewable: bool,
#[serde(
default,
deserialize_with = "deserialize_bounded_string_map_or_default"
)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct KubernetesLoginRequest<'a> {
role: &'a str,
jwt: &'a str,
}
#[derive(Serialize)]
struct KubernetesConfigPayload<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
kubernetes_host: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
kubernetes_ca_cert: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
token_reviewer_jwt: Option<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pem_keys: Vec<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_local_ca_jwt: Option<bool>,
}
#[derive(Deserialize)]
struct KubernetesLoginResponse {
auth: Option<KubernetesLoginAuth>,
}
#[derive(Deserialize)]
struct KubernetesLoginAuth {
client_token: SecretString,
accessor: SecretString,
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
policies: Vec<String>,
#[serde(default)]
lease_duration: u64,
#[serde(default)]
renewable: bool,
#[serde(
default,
deserialize_with = "deserialize_bounded_string_map_or_default"
)]
metadata: BTreeMap<String, String>,
}
impl Client<Unauthenticated> {
pub fn kubernetes(&self) -> Result<KubernetesAuth<'_>> {
self.kubernetes_at("kubernetes")
}
pub fn kubernetes_at(&self, mount: impl Into<String>) -> Result<KubernetesAuth<'_>> {
Ok(KubernetesAuth {
client: self,
mount: validate_mount_path(&mount.into())?.join("/"),
})
}
pub async fn login_kubernetes(
self,
role: &str,
jwt: SecretString,
) -> Result<(Client<Authenticated>, KubernetesLoginMetadata)> {
let response = self.kubernetes()?.login_response(role, &jwt).await?;
let (token, metadata) = split_login_auth(response);
Ok((self.try_with_token(token)?, metadata))
}
}
impl Client<Authenticated> {
pub fn kubernetes_admin(&self) -> Result<KubernetesAuthAdmin<'_>> {
self.kubernetes_admin_at("kubernetes")
}
pub fn kubernetes_admin_at(&self, mount: impl Into<String>) -> Result<KubernetesAuthAdmin<'_>> {
Ok(KubernetesAuthAdmin {
client: self,
mount: validate_mount_path(&mount.into())?.join("/"),
})
}
}
impl KubernetesAuth<'_> {
pub async fn login(
self,
role: &str,
jwt: SecretString,
) -> Result<(Client<Authenticated>, KubernetesLoginMetadata)> {
let response = self.login_response(role, &jwt).await?;
let (token, metadata) = split_login_auth(response);
Ok((
self.client.clone_without_state().try_with_token(token)?,
metadata,
))
}
async fn login_response(&self, role: &str, jwt: &SecretString) -> Result<KubernetesLoginAuth> {
let role = validate_mount_path(role)?.join("/");
let request = KubernetesLoginRequest {
role: &role,
jwt: jwt.expose_secret(),
};
let response: KubernetesLoginResponse = self
.client
.request_json(
Method::POST,
&format!("auth/{}/login", self.mount),
Some(&request),
)
.await?;
response.auth.ok_or(Error::MissingField("auth"))
}
}
impl KubernetesAuthAdmin<'_> {
pub async fn configure(&self, config: &KubernetesConfig) -> Result<Empty> {
let payload = KubernetesConfigPayload {
kubernetes_host: config.kubernetes_host.as_deref(),
kubernetes_ca_cert: config.kubernetes_ca_cert.as_deref(),
token_reviewer_jwt: config
.token_reviewer_jwt
.as_ref()
.map(SecretString::expose_secret),
pem_keys: config.pem_keys.iter().map(String::as_str).collect(),
disable_local_ca_jwt: config.disable_local_ca_jwt,
};
self.client
.request_json(
Method::POST,
&format!("auth/{}/config", self.mount),
Some(&payload),
)
.await
}
pub async fn read_config(&self) -> Result<KubernetesConfig> {
let envelope: ResponseEnvelope<KubernetesConfig> = self
.client
.request_json(
Method::GET,
&format!("auth/{}/config", self.mount),
Option::<&Empty>::None,
)
.await?;
Ok(envelope.data)
}
pub async fn write_role(&self, name: &str, role: &KubernetesRole) -> Result<Empty> {
let name = validate_mount_path(name)?.join("/");
self.client
.request_json(
Method::POST,
&format!("auth/{}/role/{name}", self.mount),
Some(role),
)
.await
}
pub async fn read_role(&self, name: &str) -> Result<KubernetesRole> {
let name = validate_mount_path(name)?.join("/");
let envelope: ResponseEnvelope<KubernetesRole> = self
.client
.request_json(
Method::GET,
&format!("auth/{}/role/{name}", self.mount),
Option::<&Empty>::None,
)
.await?;
Ok(envelope.data)
}
pub async fn list_roles(&self) -> Result<KubernetesRoleList> {
let method =
Method::from_bytes(b"LIST").map_err(|error| Error::InvalidHeader(error.to_string()))?;
let envelope: ResponseEnvelope<KubernetesRoleList> = self
.client
.request_json(
method,
&format!("auth/{}/role", self.mount),
Option::<&Empty>::None,
)
.await?;
Ok(envelope.data)
}
pub async fn delete_role(&self, name: &str) -> Result<Empty> {
let name = validate_mount_path(name)?.join("/");
self.client
.request_json_accepting(
Method::DELETE,
&format!("auth/{}/role/{name}", self.mount),
Option::<&Empty>::None,
&[reqwest::StatusCode::OK, reqwest::StatusCode::NO_CONTENT],
)
.await
}
}
fn split_login_auth(auth: KubernetesLoginAuth) -> (SecretString, KubernetesLoginMetadata) {
let KubernetesLoginAuth {
client_token,
accessor,
policies,
lease_duration,
renewable,
metadata,
} = auth;
let metadata = KubernetesLoginMetadata {
accessor,
policies,
lease_duration,
renewable,
metadata,
};
(client_token, metadata)
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use secrecy::ExposeSecret;
use super::{KubernetesLoginResponse, KubernetesRoleList};
#[test]
fn kubernetes_login_auth_deserializes_secret_token_fields() {
let response: KubernetesLoginResponse = serde_json::from_str(
r#"{"auth":{"client_token":"token-value","accessor":"accessor-value","metadata":{"role":"web"}}}"#,
)
.unwrap_or_else(|error| panic!("{error}"));
let auth = response.auth.unwrap_or_else(|| panic!("auth missing"));
assert_eq!(auth.client_token.expose_secret(), "token-value");
assert_eq!(auth.accessor.expose_secret(), "accessor-value");
assert_eq!(auth.metadata.get("role").map(String::as_str), Some("web"));
}
#[test]
fn kubernetes_role_list_is_bounded() {
let mut keys = Vec::new();
for index in 0..=crate::response::MAX_RESPONSE_STRINGS {
keys.push(format!("role-{index}"));
}
let value = serde_json::json!({ "keys": keys });
let error = match serde_json::from_value::<KubernetesRoleList>(value) {
Ok(_) => panic!("oversized Kubernetes role list unexpectedly decoded"),
Err(error) => error,
};
assert!(error.to_string().contains("exceeds item limit"));
}
}