use crate::{
crd::KanidmRef,
kanidm::{
crd::Kanidm,
reconcile::secret::{
ADMIN_PASSWORD_KEY, ADMIN_USER, IDM_ADMIN_PASSWORD_KEY, IDM_ADMIN_USER,
},
},
};
use kanidm_client::{KanidmClient, KanidmClientBuilder};
use kaniop_k8s_util::error::{Error, Result};
use std::collections::HashMap;
use std::fmt::Debug;
use std::io::Write;
use std::sync::Arc;
use k8s_openapi::api::core::v1::{Namespace, Secret};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
use kube::ResourceExt;
use kube::api::Api;
use kube::client::Client;
use kube::core::{Selector, SelectorExt};
use kube::runtime::reflector::Store;
use openssl::x509::X509;
use serde::Serialize;
use tempfile::NamedTempFile;
use tracing::{debug, trace};
pub trait KanidmResource: ResourceExt {
/// Returns the KanidmRef from the resource's spec
fn kanidm_ref_spec(&self) -> &KanidmRef;
/// Returns the namespace selector field for this resource type from the Kanidm spec
fn get_namespace_selector(kanidm: &Kanidm) -> &Option<LabelSelector>;
/// Returns the optional Kanidm entity name override from the spec
fn kanidm_name_override(&self) -> Option<&str>;
/// Returns the name of the referenced Kanidm resource
fn kanidm_name(&self) -> String {
self.kanidm_ref_spec().name.clone()
}
/// Returns the namespace of the referenced Kanidm resource
/// Uses the explicitly specified namespace in kanidm_ref, or falls back to the resource's own namespace
fn kanidm_namespace(&self) -> String {
self.kanidm_ref_spec()
.namespace
.clone()
// safe unwrap: all resources implementing this trait are namespaced scoped
.unwrap_or_else(|| self.namespace().unwrap())
}
/// Returns a string representation of the Kanidm reference in "namespace/name" format
fn kanidm_ref(&self) -> String {
format!("{}/{}", self.kanidm_namespace(), self.kanidm_name())
}
/// Returns the entity name to use in Kanidm.
/// If `kanidmName` is specified in the spec, uses that; otherwise uses the K8s resource name.
fn kanidm_entity_name(&self) -> String {
self.kanidm_name_override()
.map(|s| s.to_string())
.unwrap_or_else(|| self.name_any())
}
}
/// Check if a LabelSelector matches all namespaces (empty selector with no constraints)
fn selector_matches_all(selector: &LabelSelector) -> bool {
selector.match_labels.is_none() || selector.match_labels.as_ref().is_some_and(|l| l.is_empty())
}
/// Generic function to check if a resource is watched based on namespace selectors
///
/// This function implements the common logic for checking whether a resource should be
/// reconciled based on the namespace selector configuration in the referenced Kanidm resource.
pub async fn is_resource_watched<T>(
resource: &T,
kanidm: &Kanidm,
namespace_store: &Store<Namespace>,
k8s_client: &Client,
) -> bool
where
T: KanidmResource,
{
let namespace = resource.namespace().unwrap();
trace!(%namespace, "check if resource is watched");
let namespace_selector = if let Some(selector) = T::get_namespace_selector(kanidm) {
selector
} else {
trace!("no namespace selector found, defaulting to current namespace");
return kanidm.namespace().unwrap() == namespace;
};
if selector_matches_all(namespace_selector) {
trace!("namespace selector matches all namespaces, fast-track accepted");
return true;
}
let selector: Selector = if let Ok(s) = namespace_selector.clone().try_into() {
s
} else {
trace!("failed to parse namespace selector, defaulting to current namespace");
return kanidm.namespace().unwrap() == namespace;
};
trace!(?selector, "namespace selector");
let found_in_store = namespace_store
.state()
.iter()
.filter(|n| selector.matches(n.metadata.labels.as_ref().unwrap_or(&Default::default())))
.any(|n| n.name_any() == namespace);
if found_in_store {
return true;
}
trace!(%namespace, "namespace not found in store, fetching from K8s API");
let namespace_api: Api<Namespace> = Api::all(k8s_client.clone());
match namespace_api.get(&namespace).await {
Ok(ns) => {
let matches =
selector.matches(ns.metadata.labels.as_ref().unwrap_or(&Default::default()));
trace!(%namespace, matches, "namespace fetched from API");
matches
}
Err(e) => {
trace!(%namespace, ?e, "failed to fetch namespace from API, treating as not watched");
false
}
}
}
#[derive(Serialize, Clone, Debug, PartialEq, Eq, Hash)]
pub enum KanidmUser {
IdmAdmin,
Admin,
}
const TLS_CERT_KEY: &str = "tls.crt";
const CA_CERT_KEY: &str = "ca.crt";
fn certificate_trust_anchor(
certificate_bundle: &[u8],
key: &str,
namespace: &str,
secret_name: &str,
) -> Result<Vec<u8>> {
let certificates = X509::stack_from_pem(certificate_bundle).map_err(|e| {
Error::ParseError(format!(
"failed to parse {key} from TLS secret {namespace}/{secret_name}: {e}"
))
})?;
let trust_anchor = certificates.last().ok_or_else(|| {
Error::MissingData(format!(
"no certificates found in {key} from TLS secret {namespace}/{secret_name}"
))
})?;
trust_anchor.to_pem().map_err(|e| {
Error::ParseError(format!(
"failed to encode trust anchor from TLS secret {namespace}/{secret_name}: {e}"
))
})
}
fn tls_trust_anchor(secret: &Secret, namespace: &str, secret_name: &str) -> Result<Vec<u8>> {
let data = secret.data.as_ref().ok_or_else(|| {
Error::MissingData(format!(
"failed to get data in TLS secret: {namespace}/{secret_name}"
))
})?;
// cert-manager intentionally keeps the issuing CA separate from tls.crt when it knows the
// issuer. Prefer ca.crt so private/self-signed PKIs can be used without requiring the root CA
// to be appended to the served certificate chain.
if let Some(certificate_bundle) = data.get(CA_CERT_KEY) {
return certificate_trust_anchor(
&certificate_bundle.0,
CA_CERT_KEY,
namespace,
secret_name,
);
}
// Keep the existing behavior for Secrets that only provide tls.crt. The final certificate in
// that bundle is treated as the trust anchor, which supports the common leaf+intermediate
// layout and preserves compatibility with operator-managed certificates.
let certificate_bundle = data.get(TLS_CERT_KEY).ok_or_else(|| {
Error::MissingData(format!(
"missing {TLS_CERT_KEY} in TLS secret: {namespace}/{secret_name}"
))
})?;
certificate_trust_anchor(&certificate_bundle.0, TLS_CERT_KEY, namespace, secret_name)
}
#[derive(Default)]
pub struct KanidmClients(HashMap<KanidmKey, Arc<KanidmClient>>);
impl KanidmClients {
pub fn get(&self, key: &KanidmKey) -> Option<&Arc<KanidmClient>> {
self.0.get(key)
}
pub fn insert(
&mut self,
key: KanidmKey,
client: Arc<KanidmClient>,
) -> Option<Arc<KanidmClient>> {
self.0.insert(key, client)
}
pub fn remove(&mut self, key: &KanidmKey) -> Option<Arc<KanidmClient>> {
let client = self.0.remove(key);
self.0.shrink_to_fit();
client
}
pub async fn create_client(
namespace: &str,
name: &str,
user: KanidmUser,
k_client: Client,
) -> Result<Arc<KanidmClient>> {
debug!(namespace, name, "create Kanidm client");
let secret_api = Api::<Secret>::namespaced(k_client.clone(), namespace);
let kanidm_api = Api::<Kanidm>::namespaced(k_client.clone(), namespace);
let kanidm = kanidm_api.get(name).await.map_err(|e| {
Error::KubeError(
format!("failed to get Kanidm: {namespace}/{name}"),
Box::new(e),
)
})?;
let tls_secret_name = kanidm.effective_tls_secret_name();
let tls_secret = secret_api.get(&tls_secret_name).await.map_err(|e| {
Error::KubeError(
format!("failed to get TLS secret: {namespace}/{tls_secret_name}"),
Box::new(e),
)
})?;
let trust_anchor = tls_trust_anchor(&tls_secret, namespace, &tls_secret_name)?;
let mut trust_anchor_file = NamedTempFile::new_in("/tmp").map_err(|e| {
Error::ParseError(format!(
"failed to create temporary Kanidm trust anchor: {e}"
))
})?;
trust_anchor_file.write_all(&trust_anchor).map_err(|e| {
Error::ParseError(format!(
"failed to write temporary Kanidm trust anchor: {e}"
))
})?;
let trust_anchor_path = trust_anchor_file.path().to_str().ok_or_else(|| {
Error::ParseError("temporary Kanidm trust anchor path is not valid UTF-8".to_string())
})?;
let client = KanidmClientBuilder::new()
// The operator connects to the Kubernetes Service DNS name while Kanidm's certificate
// represents its configured public domain. Verify the certificate chain against the
// exact TLS Secret, but do not pretend the Service DNS name is present in the SAN.
.enable_native_ca_roots(false)
.danger_accept_invalid_hostnames(true)
.add_root_certificate_filepath(trust_anchor_path)
.map_err(|e| {
Error::KanidmClientError(
"failed to configure Kanidm TLS trust".to_string(),
Box::new(e),
)
})?
.address(format!("https://{name}.{namespace}.svc:8443"))
.connect_timeout(5)
.build()
.map_err(|e| {
Error::KanidmClientError("failed to build Kanidm client".to_string(), Box::new(e))
})?;
drop(trust_anchor_file);
let secret_name = format!("{name}-admin-passwords");
let admin_secret = secret_api.get(&secret_name).await.map_err(|e| {
Error::KubeError(
format!("failed to get secret: {namespace}/{secret_name}"),
Box::new(e),
)
})?;
let secret_data = admin_secret.data.ok_or_else(|| {
Error::MissingData(format!(
"failed to get data in secret: {namespace}/{secret_name}"
))
})?;
let (username, password_key) = match user {
KanidmUser::Admin => (ADMIN_USER, ADMIN_PASSWORD_KEY),
KanidmUser::IdmAdmin => (IDM_ADMIN_USER, IDM_ADMIN_PASSWORD_KEY),
};
trace!(
namespace,
name, secret_name, "fetch Kanidm {username} password"
);
let password_bytes = secret_data.get(password_key).ok_or_else(|| {
Error::MissingData(format!(
"missing password for {username} in secret: {namespace}/{secret_name}"
))
})?;
let password = std::str::from_utf8(&password_bytes.0)
.map_err(|e| Error::Utf8Error("failed to convert password to string".to_string(), e))?;
trace!(
namespace,
name, "authenticating with new client and user {username}"
);
client
.auth_simple_password(username, password)
.await
.map_err(|e| {
Error::KanidmClientError("client failed to authenticate".to_string(), Box::new(e))
})?;
Ok(Arc::new(client))
}
}
#[derive(Clone, PartialEq, Hash, Eq)]
pub struct KanidmKey {
pub namespace: String,
pub name: String,
}
#[derive(Clone, PartialEq, Hash, Eq, Debug)]
pub struct ClientLockKey {
pub namespace: String,
pub name: String,
pub user: KanidmUser,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use k8s_openapi::ByteString;
const LEAF_CERT: &[u8] = b"-----BEGIN CERTIFICATE-----\nMIICGjCCAb+gAwIBAgIUHpT08nqX951u//GR+v8XT79r9SUwCgYIKoZIzj0EAwIw\nRDELMAkGA1UEBhMCQVUxDDAKBgNVBAgMA1FMRDEPMA0GA1UECgwGS2FuaWRtMRYw\nFAYDVQQDDA1LYW5pb3AgRTJFIENBMB4XDTI2MDgwODA2MTkxOVoXDTM2MDgwNTA2\nMTkxOVowRjELMAkGA1UEBhMCQVUxDDAKBgNVBAgMA1FMRDEPMA0GA1UECgwGS2Fu\naWRtMRgwFgYDVQQDDA9pZG0uZXhhbXBsZS5jb20wWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAAQvppDjypVndfeojNUQ4o1r0v/+ry6an9tRRgdaqpAWycCsHHwqzxRG\nvQmGifZQ5dsBle7+3df8YBfXmikDRTEeo4GMMIGJMAkGA1UdEwQCMAAwCwYDVR0P\nBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBoGA1UdEQQTMBGCD2lkbS5leGFt\ncGxlLmNvbTAdBgNVHQ4EFgQU08vzk3TPxjTZYSarIJ/X8483q5MwHwYDVR0jBBgw\nFoAU/oFjdY0iaHDwDEsG9K2kLqnKaCswCgYIKoZIzj0EAwIDSQAwRgIhAOAaimcS\nz/IUkI03CYbicyGIQDmXBruN584Uk0wLmOxBAiEA1T6y7HbX3F1oyftd5wABZPDB\nCpREB0kqGwMUURezf4w=\n-----END CERTIFICATE-----\n";
const CA_CERT: &[u8] = b"-----BEGIN CERTIFICATE-----\nMIIB3DCCAYOgAwIBAgIUfsv6cZIgDmqN1h4xCuD9CjVRLkgwCgYIKoZIzj0EAwIw\nRDELMAkGA1UEBhMCQVUxDDAKBgNVBAgMA1FMRDEPMA0GA1UECgwGS2FuaWRtMRYw\nFAYDVQQDDA1LYW5pb3AgRTJFIENBMB4XDTI2MDgwODA2MTkxOVoXDTM2MDgwNTA2\nMTkxOVowRDELMAkGA1UEBhMCQVUxDDAKBgNVBAgMA1FMRDEPMA0GA1UECgwGS2Fu\naWRtMRYwFAYDVQQDDA1LYW5pb3AgRTJFIENBMFkwEwYHKoZIzj0CAQYIKoZIzj0D\nAQcDQgAEy84lnsJddCODwnayK4yoqLf6jVGTWIT0mpUh01Ghoq8GrXSrvYGIjxZ0\nYFPEwstiso8GJP15JKXzoGJTUs4a6aNTMFEwHQYDVR0OBBYEFP6BY3WNImhw8AxL\nBvStpC6pymgrMB8GA1UdIwQYMBaAFP6BY3WNImhw8AxLBvStpC6pymgrMA8GA1Ud\nEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDRwAwRAIgLqbXmVvrEP9zjuMcU0j+R79Z\nFzsMIBS59ZhCJVTa3NACIG2rT7suWcwoc2Wkv7y0AWdpRoZcpLwL0kGNzN5yidHS\n-----END CERTIFICATE-----\n";
fn tls_secret(entries: &[(&str, &[u8])]) -> Secret {
let data: BTreeMap<String, ByteString> = entries
.iter()
.map(|(key, value)| ((*key).to_string(), ByteString(value.to_vec())))
.collect();
Secret {
data: Some(data),
..Default::default()
}
}
#[test]
fn tls_trust_anchor_prefers_ca_crt() -> Result<()> {
let secret = tls_secret(&[(TLS_CERT_KEY, LEAF_CERT), (CA_CERT_KEY, CA_CERT)]);
let trust_anchor = tls_trust_anchor(&secret, "default", "kanidm-certificate")?;
assert_eq!(trust_anchor, CA_CERT);
Ok(())
}
#[test]
fn tls_trust_anchor_falls_back_to_last_tls_crt_certificate() -> Result<()> {
let mut tls_bundle = LEAF_CERT.to_vec();
tls_bundle.extend_from_slice(CA_CERT);
let secret = tls_secret(&[(TLS_CERT_KEY, &tls_bundle)]);
let trust_anchor = tls_trust_anchor(&secret, "default", "kanidm-certificate")?;
assert_eq!(trust_anchor, CA_CERT);
Ok(())
}
}