Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cmd/webhook/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ kaniop-oauth2 = { workspace = true }
kaniop-service-account = { workspace = true }
clap = { workspace = true, features = ["cargo", "env"] }
k8s-openapi = { workspace = true }
kanidm_client = { workspace = true }
kube = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
anyhow = "1.0"
Expand Down
45 changes: 38 additions & 7 deletions cmd/webhook/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::admission::{AdmissionResponse, AdmissionReview};
use crate::state::WebhookState;
use crate::validator::{HasKanidmRef, check_duplicate};
use crate::validator::{EntityType, HasKanidmRef, check_duplicate_with_internal};

use axum::extract::State;
use axum::response::Json;
Expand All @@ -9,10 +9,11 @@ use tracing::{debug, error};

/// Generic validation handler
pub async fn validate_resource<T>(
_state: &WebhookState,
state: &WebhookState,
store: &kube::runtime::reflector::Store<T>,
review: AdmissionReview<T>,
resource_name: &str,
entity_type: EntityType,
) -> Json<AdmissionReview<()>>
where
T: Resource + ResourceExt + Clone + HasKanidmRef,
Expand Down Expand Up @@ -52,8 +53,16 @@ where
}
};

// Check for duplicates
match check_duplicate(object, resource_name, store) {
// Check for duplicates (both CRD and internal cache)
match check_duplicate_with_internal(
object,
resource_name,
store,
&state.internal_cache,
entity_type,
)
.await
{
Ok(_) => {
debug!(
"Validation passed for {} {}/{}",
Expand Down Expand Up @@ -81,21 +90,42 @@ pub async fn validate_kanidm_group(
State(state): State<WebhookState>,
Json(review): Json<AdmissionReview<kaniop_group::crd::KanidmGroup>>,
) -> Json<AdmissionReview<()>> {
validate_resource(&state, &state.group_store, review, "KanidmGroup").await
validate_resource(
&state,
&state.group_store,
review,
"KanidmGroup",
EntityType::Group,
)
.await
}

pub async fn validate_kanidm_person(
State(state): State<WebhookState>,
Json(review): Json<AdmissionReview<kaniop_person::crd::KanidmPersonAccount>>,
) -> Json<AdmissionReview<()>> {
validate_resource(&state, &state.person_store, review, "KanidmPersonAccount").await
validate_resource(
&state,
&state.person_store,
review,
"KanidmPersonAccount",
EntityType::Person,
)
.await
}

pub async fn validate_kanidm_oauth2(
State(state): State<WebhookState>,
Json(review): Json<AdmissionReview<kaniop_oauth2::crd::KanidmOAuth2Client>>,
) -> Json<AdmissionReview<()>> {
validate_resource(&state, &state.oauth2_store, review, "KanidmOAuth2Client").await
validate_resource(
&state,
&state.oauth2_store,
review,
"KanidmOAuth2Client",
EntityType::OAuth2Client,
)
.await
}

pub async fn validate_kanidm_service_account(
Expand All @@ -107,6 +137,7 @@ pub async fn validate_kanidm_service_account(
&state.service_account_store,
review,
"KanidmServiceAccount",
EntityType::ServiceAccount,
)
.await
}
217 changes: 217 additions & 0 deletions cmd/webhook/src/kanidm_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
use kaniop_operator::kanidm::crd::Kanidm;

use std::collections::HashMap;
use std::sync::Arc;

use k8s_openapi::api::core::v1::Secret;
use kanidm_client::{KanidmClient, KanidmClientBuilder};
use kube::{Api, Client, ResourceExt};
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, trace};

const IDM_ADMIN_USER: &str = "idm_admin";
const IDM_ADMIN_PASSWORD_KEY: &str = "idm_admin";

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Kanidm client error: {0}: {1:?}")]
KanidmClient(String, kanidm_client::ClientError),
#[error("Kubernetes error: {0}")]
Kube(String, #[source] kube::Error),
#[error("Missing data: {0}")]
MissingData(String),
#[error("UTF-8 conversion error: {0}")]
Utf8(String, #[source] std::str::Utf8Error),
}

pub type Result<T> = std::result::Result<T, Error>;

#[derive(Clone, PartialEq, Hash, Eq, Debug)]
pub enum KanidmUser {
IdmAdmin,
Admin,
}

#[derive(Clone, PartialEq, Hash, Eq, Debug)]
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,
}

/// Cache of Kanidm clients
#[derive(Clone, Default)]
pub struct KanidmClientCache {
/// Maps Kanidm cluster to authenticated client
idm_clients: Arc<RwLock<HashMap<KanidmKey, Arc<KanidmClient>>>>,
/// Locks to prevent concurrent client creation
client_creation_locks: Arc<RwLock<HashMap<ClientLockKey, Arc<Mutex<()>>>>>,
}

impl KanidmClientCache {
pub fn new() -> Self {
Self::default()
}

/// Get a valid Kanidm client for the specified cluster
/// Creates a new client if one doesn't exist or is invalid
pub async fn get_client(
&self,
kanidm: &Kanidm,
kube_client: Client,
) -> Result<Arc<KanidmClient>> {
let namespace = kanidm
.namespace()
.ok_or_else(|| Error::MissingData("Kanidm namespace missing".to_string()))?;
let name = kanidm.name_any();

let key = KanidmKey {
namespace: namespace.clone(),
name: name.clone(),
};

// Fast path: check if we have a valid cached client
if let Some(client) = self.get_valid_cached_client(&key, &namespace, &name).await {
return Ok(client);
}

// Slow path: acquire lock to prevent concurrent creation
let lock_key = ClientLockKey {
namespace: namespace.clone(),
name: name.clone(),
user: KanidmUser::IdmAdmin,
};

let creation_lock = self
.client_creation_locks
.write()
.await
.entry(lock_key)
.or_insert_with(Arc::default)
.clone();

let _guard = creation_lock.lock().await;

// Double-check after acquiring lock
if let Some(client) = self.get_valid_cached_client(&key, &namespace, &name).await {
return Ok(client);
}

// Create new client
let client = Self::create_client(&namespace, &name, kube_client).await?;
self.idm_clients.write().await.insert(key, client.clone());

Ok(client)
}

/// Check if a valid client exists in cache
async fn get_valid_cached_client(
&self,
key: &KanidmKey,
namespace: &str,
name: &str,
) -> Option<Arc<KanidmClient>> {
let client = self.idm_clients.read().await.get(key).cloned()?;

trace!(
msg = "check existing Kanidm client session",
namespace, name
);

if client.auth_valid().await.is_ok() {
trace!(msg = "reuse Kanidm client session", namespace, name);
Some(client)
} else {
None
}
}

/// Create a new Kanidm client and authenticate
async fn create_client(
namespace: &str,
name: &str,
k_client: Client,
) -> Result<Arc<KanidmClient>> {
debug!(msg = "create Kanidm client", namespace, name);

let client = KanidmClientBuilder::new()
.danger_accept_invalid_certs(true)
.address(format!("https://{name}.{namespace}.svc:8443"))
.connect_timeout(5)
.build()
.map_err(|e| Error::KanidmClient("failed to build Kanidm client".to_string(), e))?;

let secret_api = Api::<Secret>::namespaced(k_client, namespace);
let secret_name = format!("{name}-admin-passwords");
let admin_secret = secret_api.get(&secret_name).await.map_err(|e| {
Error::Kube(
format!("failed to get secret: {namespace}/{secret_name}"),
e,
)
})?;

let secret_data = admin_secret.data.ok_or_else(|| {
Error::MissingData(format!(
"failed to get data in secret: {namespace}/{secret_name}"
))
})?;

let username = IDM_ADMIN_USER;
let password_key = IDM_ADMIN_PASSWORD_KEY;

trace!(
msg = format!("fetch Kanidm {username} password"),
namespace, name, secret_name
);

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::Utf8("failed to convert password to string".to_string(), e))?;

trace!(
msg = format!("authenticating with new client and user {username}"),
namespace, name
);

client
.auth_simple_password(username, password)
.await
.map_err(|e| Error::KanidmClient("client failed to authenticate".to_string(), e))?;

Ok(Arc::new(client))
}

/// Remove a client from the cache (e.g., when Kanidm is deleted)
pub async fn remove_client(&self, namespace: &str, name: &str) {
let key = KanidmKey {
namespace: namespace.to_string(),
name: name.to_string(),
};

self.idm_clients.write().await.remove(&key);

// Clean up locks
let mut locks = self.client_creation_locks.write().await;
locks.remove(&ClientLockKey {
namespace: namespace.to_string(),
name: name.to_string(),
user: KanidmUser::IdmAdmin,
});
locks.remove(&ClientLockKey {
namespace: namespace.to_string(),
name: name.to_string(),
user: KanidmUser::Admin,
});
}
}
Loading
Loading