diff --git a/CHANGELOG.md b/CHANGELOG.md index 949330cb..4394ef71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ All notable changes to this project will be documented in this file. - Add support for OpenLDAP backend to user-info-fetcher ([#779]). +### Changed + +- user-info-fetcher: Move backend initialization and credential resolution into backend-specific implementations ([#782]). + [#779]: https://github.com/stackabletech/opa-operator/pull/779 +[#782]: https://github.com/stackabletech/opa-operator/pull/782 ## [25.11.0] - 2025-11-07 diff --git a/rust/user-info-fetcher/src/backend/entra.rs b/rust/user-info-fetcher/src/backend/entra.rs index f261df50..5d6e663b 100644 --- a/rust/user-info-fetcher/src/backend/entra.rs +++ b/rust/user-info-fetcher/src/backend/entra.rs @@ -1,13 +1,17 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::Path}; use hyper::StatusCode; +use reqwest::ClientBuilder; use serde::Deserialize; use snafu::{ResultExt, Snafu}; use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; use stackable_operator::commons::{networking::HostName, tls_verification::TlsClientDetails}; use url::Url; -use crate::{Credentials, UserInfo, UserInfoRequest, http_error, utils::http::send_json_request}; +use crate::{ + UserInfo, UserInfoRequest, http_error, + utils::{self, http::send_json_request}, +}; #[derive(Snafu, Debug)] pub enum Error { @@ -40,6 +44,24 @@ pub enum Error { source: url::ParseError, endpoint: String, }, + + #[snafu(display("failed to construct HTTP client"))] + ConstructHttpClient { source: reqwest::Error }, + + #[snafu(display("failed to configure TLS"))] + ConfigureTls { source: utils::tls::Error }, + + #[snafu(display("failed to read client ID from {path:?}"))] + ReadClientId { + source: std::io::Error, + path: String, + }, + + #[snafu(display("failed to read client secret from {path:?}"))] + ReadClientSecret { + source: std::io::Error, + path: String, + }, } impl http_error::Error for Error { @@ -50,6 +72,10 @@ impl http_error::Error for Error { Self::UserNotFoundById { .. } => StatusCode::NOT_FOUND, Self::RequestUserGroups { .. } => StatusCode::BAD_GATEWAY, Self::BuildEntraEndpointFailed { .. } => StatusCode::BAD_REQUEST, + Self::ConstructHttpClient { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigureTls { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ReadClientId { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ReadClientSecret { .. } => StatusCode::SERVICE_UNAVAILABLE, } } } @@ -80,81 +106,134 @@ struct GroupMembership { display_name: Option, } -pub(crate) async fn get_user_info( - req: &UserInfoRequest, - http: &reqwest::Client, - credentials: &Credentials, - config: &v1alpha1::EntraBackend, -) -> Result { - let v1alpha1::EntraBackend { - client_credentials_secret: _, - token_hostname, - user_info_hostname, - port, - tenant_id, - tls, - } = config; - - let entra_backend = EntraBackend::try_new( - token_hostname, - user_info_hostname, - *port, - tenant_id, - TlsClientDetails { tls: tls.clone() }.uses_tls(), - )?; - - let token_url = entra_backend.oauth2_token(); - let authn = send_json_request::(http.post(token_url).form(&[ - ("client_id", credentials.client_id.as_str()), - ("client_secret", credentials.client_secret.as_str()), - ("scope", "https://graph.microsoft.com/.default"), - ("grant_type", "client_credentials"), - ])) - .await - .context(AccessTokenSnafu)?; - - let user_info = match req { - UserInfoRequest::UserInfoRequestById(req) => { - let user_id = &req.id; - send_json_request::( - http.get(entra_backend.user_info(user_id)) - .bearer_auth(&authn.access_token), - ) - .await - .with_context(|_| UserNotFoundByIdSnafu { - user_id: user_id.clone(), - })? - } - UserInfoRequest::UserInfoRequestByName(req) => { - let username = &req.username; - send_json_request::( - http.get(entra_backend.user_info(username)) - .bearer_auth(&authn.access_token), - ) +/// Entra backend with resolved credentials. +/// +/// This struct combines the CRD configuration with credentials loaded from the filesystem. +/// Credentials and the HTTP client are initialized once at startup and stored internally. +pub struct ResolvedEntraBackend { + config: v1alpha1::EntraBackend, + client_id: String, + client_secret: String, + http_client: reqwest::Client, +} + +impl ResolvedEntraBackend { + /// Resolves an Entra backend by loading credentials from the filesystem. + /// + /// Reads `clientId` and `clientSecret` from the credentials directory and initializes + /// the HTTP client with appropriate TLS configuration. + pub async fn resolve( + config: v1alpha1::EntraBackend, + credentials_dir: &Path, + ) -> Result { + let client_id_path = credentials_dir.join("clientId"); + let client_secret_path = credentials_dir.join("clientSecret"); + + let client_id = + tokio::fs::read_to_string(&client_id_path) + .await + .context(ReadClientIdSnafu { + path: client_id_path.display().to_string(), + })?; + let client_secret = tokio::fs::read_to_string(&client_secret_path) .await - .with_context(|_| SearchForUserSnafu { - username: username.clone(), - })? - } - }; - - let groups = send_json_request::( - http.get(entra_backend.group_info(&user_info.id)) - .bearer_auth(&authn.access_token), - ) - .await - .with_context(|_| RequestUserGroupsSnafu { - username: user_info.user_principal_name.clone(), - user_id: user_info.id.clone(), - })? - .value; - - Ok(UserInfo { - id: Some(user_info.id), - username: Some(user_info.user_principal_name), - groups: groups.into_iter().filter_map(|g| g.display_name).collect(), - custom_attributes: user_info.attributes, - }) + .context(ReadClientSecretSnafu { + path: client_secret_path.display().to_string(), + })?; + + let mut client_builder = ClientBuilder::new(); + client_builder = utils::tls::configure_reqwest( + &TlsClientDetails { + tls: config.tls.clone(), + }, + client_builder, + ) + .await + .context(ConfigureTlsSnafu)?; + let http_client = client_builder.build().context(ConstructHttpClientSnafu)?; + + Ok(Self { + config, + client_id, + client_secret, + http_client, + }) + } + + pub(crate) async fn get_user_info(&self, req: &UserInfoRequest) -> Result { + let v1alpha1::EntraBackend { + client_credentials_secret: _, + token_hostname, + user_info_hostname, + port, + tenant_id, + tls, + } = &self.config; + + let entra_backend = EntraBackend::try_new( + token_hostname, + user_info_hostname, + *port, + tenant_id, + TlsClientDetails { tls: tls.clone() }.uses_tls(), + )?; + + let token_url = entra_backend.oauth2_token(); + let authn = send_json_request::(self.http_client.post(token_url).form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("scope", "https://graph.microsoft.com/.default"), + ("grant_type", "client_credentials"), + ])) + .await + .context(AccessTokenSnafu)?; + + let user_info = match req { + UserInfoRequest::UserInfoRequestById(req) => { + let user_id = &req.id; + send_json_request::( + self.http_client + .get(entra_backend.user_info(user_id)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| UserNotFoundByIdSnafu { + user_id: user_id.clone(), + })? + } + UserInfoRequest::UserInfoRequestByName(req) => { + let username = &req.username; + send_json_request::( + self.http_client + .get(entra_backend.user_info(username)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| SearchForUserSnafu { + username: username.clone(), + })? + } + }; + + let groups = send_json_request::( + self.http_client + .get(entra_backend.group_info(&user_info.id)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| RequestUserGroupsSnafu { + username: user_info.user_principal_name.clone(), + user_id: user_info.id.clone(), + })? + .value; + + Ok(UserInfo { + id: Some(user_info.id), + username: Some(user_info.user_principal_name), + groups: groups.into_iter().filter_map(|g| g.display_name).collect(), + custom_attributes: user_info.attributes, + }) + } } struct EntraBackend { diff --git a/rust/user-info-fetcher/src/backend/keycloak.rs b/rust/user-info-fetcher/src/backend/keycloak.rs index 07dbd8a4..7d07e7d4 100644 --- a/rust/user-info-fetcher/src/backend/keycloak.rs +++ b/rust/user-info-fetcher/src/backend/keycloak.rs @@ -1,12 +1,16 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::Path}; use hyper::StatusCode; +use reqwest::ClientBuilder; use serde::Deserialize; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; use stackable_operator::crd::authentication::oidc; -use crate::{Credentials, UserInfo, UserInfoRequest, http_error, utils::http::send_json_request}; +use crate::{ + UserInfo, UserInfoRequest, http_error, + utils::{self, http::send_json_request}, +}; #[derive(Snafu, Debug)] pub enum Error { @@ -42,6 +46,24 @@ pub enum Error { #[snafu(display("failed to construct OIDC endpoint path"))] ConstructOidcEndpointPath { source: url::ParseError }, + + #[snafu(display("failed to construct HTTP client"))] + ConstructHttpClient { source: reqwest::Error }, + + #[snafu(display("failed to configure TLS"))] + ConfigureTls { source: utils::tls::Error }, + + #[snafu(display("failed to read client ID from {path:?}"))] + ReadClientId { + source: std::io::Error, + path: String, + }, + + #[snafu(display("failed to read client secret from {path:?}"))] + ReadClientSecret { + source: std::io::Error, + path: String, + }, } impl http_error::Error for Error { @@ -55,6 +77,10 @@ impl http_error::Error for Error { Self::RequestUserGroups { .. } => StatusCode::BAD_GATEWAY, Self::ParseOidcEndpointUrl { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::ConstructOidcEndpointPath { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::ConstructHttpClient { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigureTls { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ReadClientId { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ReadClientSecret { .. } => StatusCode::SERVICE_UNAVAILABLE, } } } @@ -86,110 +112,160 @@ struct GroupMembership { path: String, } -pub(crate) async fn get_user_info( - req: &UserInfoRequest, - http: &reqwest::Client, - credentials: &Credentials, - config: &v1alpha1::KeycloakBackend, -) -> Result { - let v1alpha1::KeycloakBackend { - client_credentials_secret: _, - admin_realm, - user_realm, - hostname, - port, - root_path, - tls, - } = config; - - // We re-use existent functionality from operator-rs, besides it being a bit of miss-use. - // Some attributes (such as principal_claim) are irrelevant, and will not be read by the code-flow we trigger. - let wrapping_auth_provider = oidc::v1alpha1::AuthenticationProvider::new( - hostname.clone(), - *port, - root_path.clone(), - tls.clone(), - String::new(), - Vec::new(), - None, - ); - let keycloak_url = wrapping_auth_provider - .endpoint_url() - .context(ParseOidcEndpointUrlSnafu)?; - - let authn = send_json_request::( - http.post( - keycloak_url - .join(&format!( - "realms/{admin_realm}/protocol/openid-connect/token" - )) - .context(ConstructOidcEndpointPathSnafu)?, - ) - .basic_auth(&credentials.client_id, Some(&credentials.client_secret)) - .form(&[("grant_type", "client_credentials")]), - ) - .await - .context(AccessTokenSnafu)?; - - let users_base_url = keycloak_url - .join(&format!("admin/realms/{user_realm}/users/")) - .context(ConstructOidcEndpointPathSnafu)?; - - let user_info = match req { - UserInfoRequest::UserInfoRequestById(req) => { - let user_id = req.id.clone(); - send_json_request::( - http.get( - users_base_url - .join(&req.id) - .context(ConstructOidcEndpointPathSnafu)?, - ) - .bearer_auth(&authn.access_token), - ) +/// Keycloak backend with resolved credentials. +/// +/// This struct combines the CRD configuration with credentials loaded from the filesystem. +/// Credentials and the HTTP client are initialized once at startup and stored internally. +pub struct ResolvedKeycloakBackend { + config: v1alpha1::KeycloakBackend, + client_id: String, + client_secret: String, + http_client: reqwest::Client, +} + +impl ResolvedKeycloakBackend { + /// Resolves a Keycloak backend by loading credentials from the filesystem. + /// + /// Reads `clientId` and `clientSecret` from the credentials directory and initializes + /// the HTTP client with appropriate TLS configuration. + pub async fn resolve( + config: v1alpha1::KeycloakBackend, + credentials_dir: &Path, + ) -> Result { + let client_id_path = credentials_dir.join("clientId"); + let client_secret_path = credentials_dir.join("clientSecret"); + + let client_id = + tokio::fs::read_to_string(&client_id_path) + .await + .context(ReadClientIdSnafu { + path: client_id_path.display().to_string(), + })?; + let client_secret = tokio::fs::read_to_string(&client_secret_path) .await - .context(UserNotFoundByIdSnafu { user_id })? - } - UserInfoRequest::UserInfoRequestByName(req) => { - let username = &req.username; - let users_url = users_base_url - .join(&format!("?username={username}&exact=true")) - .context(ConstructOidcEndpointPathSnafu)?; - - let users = send_json_request::>( - http.get(users_url).bearer_auth(&authn.access_token), - ) + .context(ReadClientSecretSnafu { + path: client_secret_path.display().to_string(), + })?; + + let mut client_builder = ClientBuilder::new(); + client_builder = utils::tls::configure_reqwest(&config.tls, client_builder) .await - .context(SearchForUserSnafu)?; + .context(ConfigureTlsSnafu)?; + let http_client = client_builder.build().context(ConstructHttpClientSnafu)?; + + Ok(Self { + config, + client_id, + client_secret, + http_client, + }) + } - if users.len() > 1 { - return TooManyUsersReturnedSnafu.fail(); + pub(crate) async fn get_user_info(&self, req: &UserInfoRequest) -> Result { + let v1alpha1::KeycloakBackend { + client_credentials_secret: _, + admin_realm, + user_realm, + hostname, + port, + root_path, + tls, + } = &self.config; + + // We re-use existent functionality from operator-rs, besides it being a bit of miss-use. + // Some attributes (such as principal_claim) are irrelevant, and will not be read by the code-flow we trigger. + let wrapping_auth_provider = oidc::v1alpha1::AuthenticationProvider::new( + hostname.clone(), + *port, + root_path.clone(), + tls.clone(), + String::new(), + Vec::new(), + None, + ); + let keycloak_url = wrapping_auth_provider + .endpoint_url() + .context(ParseOidcEndpointUrlSnafu)?; + + let authn = send_json_request::( + self.http_client + .post( + keycloak_url + .join(&format!( + "realms/{admin_realm}/protocol/openid-connect/token" + )) + .context(ConstructOidcEndpointPathSnafu)?, + ) + .basic_auth(&self.client_id, Some(&self.client_secret)) + .form(&[("grant_type", "client_credentials")]), + ) + .await + .context(AccessTokenSnafu)?; + + let users_base_url = keycloak_url + .join(&format!("admin/realms/{user_realm}/users/")) + .context(ConstructOidcEndpointPathSnafu)?; + + let user_info = match req { + UserInfoRequest::UserInfoRequestById(req) => { + let user_id = req.id.clone(); + send_json_request::( + self.http_client + .get( + users_base_url + .join(&req.id) + .context(ConstructOidcEndpointPathSnafu)?, + ) + .bearer_auth(&authn.access_token), + ) + .await + .context(UserNotFoundByIdSnafu { user_id })? } + UserInfoRequest::UserInfoRequestByName(req) => { + let username = &req.username; + let users_url = users_base_url + .join(&format!("?username={username}&exact=true")) + .context(ConstructOidcEndpointPathSnafu)?; - users - .first() - .cloned() - .context(UserNotFoundByNameSnafu { username })? - } - }; + let users = send_json_request::>( + self.http_client + .get(users_url) + .bearer_auth(&authn.access_token), + ) + .await + .context(SearchForUserSnafu)?; - let groups = send_json_request::>( - http.get( - users_base_url - .join(&format!("{}/groups", user_info.id)) - .context(ConstructOidcEndpointPathSnafu)?, + if users.len() > 1 { + return TooManyUsersReturnedSnafu.fail(); + } + + users + .first() + .cloned() + .context(UserNotFoundByNameSnafu { username })? + } + }; + + let groups = send_json_request::>( + self.http_client + .get( + users_base_url + .join(&format!("{}/groups", user_info.id)) + .context(ConstructOidcEndpointPathSnafu)?, + ) + .bearer_auth(&authn.access_token), ) - .bearer_auth(&authn.access_token), - ) - .await - .context(RequestUserGroupsSnafu { - username: user_info.username.clone(), - user_id: user_info.id.clone(), - })?; - - Ok(UserInfo { - id: Some(user_info.id), - username: Some(user_info.username), - groups: groups.into_iter().map(|g| g.path).collect(), - custom_attributes: user_info.attributes, - }) + .await + .context(RequestUserGroupsSnafu { + username: user_info.username.clone(), + user_id: user_info.id.clone(), + })?; + + Ok(UserInfo { + id: Some(user_info.id), + username: Some(user_info.username), + groups: groups.into_iter().map(|g| g.path).collect(), + custom_attributes: user_info.attributes, + }) + } } diff --git a/rust/user-info-fetcher/src/backend/openldap.rs b/rust/user-info-fetcher/src/backend/openldap.rs index 569a9b95..632fb88a 100644 --- a/rust/user-info-fetcher/src/backend/openldap.rs +++ b/rust/user-info-fetcher/src/backend/openldap.rs @@ -30,20 +30,20 @@ pub enum Error { #[snafu(display("failed to parse LDAP endpoint URL"))] ParseLdapEndpointUrl { source: ldap::v1alpha1::Error }, - #[snafu(display("failed to read bind user file from {path:?}"))] + #[snafu(display("unable to get username attribute \"{attribute}\" from LDAP user"))] + MissingUsernameAttribute { attribute: String }, + + #[snafu(display("failed to read bind user from {path:?}"))] ReadBindUser { source: std::io::Error, path: String, }, - #[snafu(display("failed to read bind password file from {path:?}"))] + #[snafu(display("failed to read bind password from {path:?}"))] ReadBindPassword { source: std::io::Error, path: String, }, - - #[snafu(display("unable to get username attribute \"{attribute}\" from LDAP user"))] - MissingUsernameAttribute { attribute: String }, } impl http_error::Error for Error { @@ -56,117 +56,138 @@ impl http_error::Error for Error { Error::FindUserLdap { .. } => StatusCode::SERVICE_UNAVAILABLE, Error::UserNotFound { .. } => StatusCode::NOT_FOUND, Error::ParseLdapEndpointUrl { .. } => StatusCode::INTERNAL_SERVER_ERROR, - Error::ReadBindUser { .. } => StatusCode::INTERNAL_SERVER_ERROR, - Error::ReadBindPassword { .. } => StatusCode::INTERNAL_SERVER_ERROR, Error::MissingUsernameAttribute { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Error::ReadBindUser { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::ReadBindPassword { .. } => StatusCode::SERVICE_UNAVAILABLE, } } } -#[tracing::instrument(skip(config))] -pub(crate) async fn get_user_info( - request: &UserInfoRequest, - config: &stackable_opa_operator::crd::user_info_fetcher::v1alpha1::OpenLdapBackend, -) -> Result { - // Construct the LDAP provider from the config - let ldap_provider = config.to_ldap_provider(); - - // Read bind credentials from mounted secret - // Bind credentials are guaranteed to be present because they are required in the CRD - let (user_path, password_path) = ldap_provider - .bind_credentials_mount_paths() - .expect("bind credentials must be configured for OpenLDAP backend"); +/// OpenLDAP backend with resolved credentials. +/// +/// This struct combines the CRD configuration with credentials loaded from the filesystem. +/// Credentials are loaded once at startup and stored internally. +pub struct ResolvedOpenLdapBackend { + config: stackable_opa_operator::crd::user_info_fetcher::v1alpha1::OpenLdapBackend, + bind_user: String, + bind_password: String, +} - let bind_user = tokio::fs::read_to_string(&user_path) - .await - .context(ReadBindUserSnafu { path: user_path })?; - let bind_password = - tokio::fs::read_to_string(&password_path) +impl ResolvedOpenLdapBackend { + /// Resolves an OpenLDAP backend by loading credentials from the filesystem. + /// + /// Reads bind credentials from paths specified in the configuration. + pub async fn resolve( + config: stackable_opa_operator::crd::user_info_fetcher::v1alpha1::OpenLdapBackend, + ) -> Result { + let ldap_provider = config.to_ldap_provider(); + // Bind credentials are guaranteed to be present because they are required in the CRD + let (user_path, password_path) = ldap_provider + .bind_credentials_mount_paths() + .expect("bind credentials must be configured for OpenLDAP backend"); + + let bind_user = tokio::fs::read_to_string(&user_path) .await - .context(ReadBindPasswordSnafu { - path: password_path, - })?; + .context(ReadBindUserSnafu { path: user_path })?; + let bind_password = + tokio::fs::read_to_string(&password_path) + .await + .context(ReadBindPasswordSnafu { + path: password_path, + })?; + + Ok(Self { + config, + bind_user, + bind_password, + }) + } - let ldap_url = ldap_provider - .endpoint_url() - .context(ParseLdapEndpointUrlSnafu)?; + #[tracing::instrument(skip(self))] + pub(crate) async fn get_user_info(&self, request: &UserInfoRequest) -> Result { + let ldap_provider = self.config.to_ldap_provider(); - let ldap_tls = utils::tls::configure_native_tls(&ldap_provider.tls) - .await - .context(ConfigureTlsSnafu)?; - let (ldap_conn, mut ldap) = LdapConnAsync::with_settings( - LdapConnSettings::new().set_connector(ldap_tls), - ldap_url.as_str(), - ) - .await - .context(ConnectLdapSnafu)?; - ldap3::drive!(ldap_conn); - - ldap.simple_bind(&bind_user, &bind_password) + let ldap_url = ldap_provider + .endpoint_url() + .context(ParseLdapEndpointUrlSnafu)?; + + let ldap_tls = utils::tls::configure_native_tls(&ldap_provider.tls) + .await + .context(ConfigureTlsSnafu)?; + let (ldap_conn, mut ldap) = LdapConnAsync::with_settings( + LdapConnSettings::new().set_connector(ldap_tls), + ldap_url.as_str(), + ) .await - .context(RequestLdapSnafu)? - .success() - .context(BindLdapSnafu)?; + .context(ConnectLdapSnafu)?; + ldap3::drive!(ldap_conn); - let user_id_attribute = &config.user_id_attribute; - let user_name_attribute = &config.user_name_attribute; - let user_filter = match request { - UserInfoRequest::UserInfoRequestById(id) => { - format!("{}={}", ldap_escape(user_id_attribute), ldap_escape(&id.id)) - } - UserInfoRequest::UserInfoRequestByName(username) => { - format!( - "{}={}", - ldap_escape(user_name_attribute), - ldap_escape(&username.username) + ldap.simple_bind(&self.bind_user, &self.bind_password) + .await + .context(RequestLdapSnafu)? + .success() + .context(BindLdapSnafu)?; + + let user_id_attribute = &self.config.user_id_attribute; + let user_name_attribute = &self.config.user_name_attribute; + let user_filter = match request { + UserInfoRequest::UserInfoRequestById(id) => { + format!("{}={}", ldap_escape(user_id_attribute), ldap_escape(&id.id)) + } + UserInfoRequest::UserInfoRequestByName(username) => { + format!( + "{}={}", + ldap_escape(user_name_attribute), + ldap_escape(&username.username) + ) + } + }; + + let user_search_dn = &ldap_provider.search_base; + let requested_user_attrs = [user_id_attribute.as_str(), user_name_attribute.as_str()] + .into_iter() + .chain( + self.config + .custom_attribute_mappings + .values() + .map(String::as_str), ) - } - }; - - let user_search_dn = &ldap_provider.search_base; - let requested_user_attrs = [user_id_attribute.as_str(), user_name_attribute.as_str()] - .into_iter() - .chain( - config - .custom_attribute_mappings - .values() - .map(String::as_str), - ) - .collect::>(); - tracing::debug!( - user_filter, - ?requested_user_attrs, - "requesting user from LDAP" - ); - let user = ldap - .search( - user_search_dn, - Scope::Subtree, - &user_filter, - requested_user_attrs, + .collect::>(); + tracing::debug!( + user_filter, + ?requested_user_attrs, + "requesting user from LDAP" + ); + let user = ldap + .search( + user_search_dn, + Scope::Subtree, + &user_filter, + requested_user_attrs, + ) + .await + .context(RequestLdapSnafu)? + .success() + .context(FindUserLdapSnafu)? + .0 + .into_iter() + .next() + .context(UserNotFoundSnafu { request })?; + let user = SearchEntry::construct(user); + tracing::debug!(?user, "got user from LDAP"); + + // Search for groups that contain this user + let groups = search_user_groups(&mut ldap, &user, &self.config).await?; + + user_attributes( + user_id_attribute, + user_name_attribute, + &user, + groups, + &self.config.custom_attribute_mappings, ) .await - .context(RequestLdapSnafu)? - .success() - .context(FindUserLdapSnafu)? - .0 - .into_iter() - .next() - .context(UserNotFoundSnafu { request })?; - let user = SearchEntry::construct(user); - tracing::debug!(?user, "got user from LDAP"); - - // Search for groups that contain this user - let groups = search_user_groups(&mut ldap, &user, config).await?; - - user_attributes( - user_id_attribute, - user_name_attribute, - &user, - groups, - &config.custom_attribute_mappings, - ) - .await + } } /// Searches for groups that contain the given user. diff --git a/rust/user-info-fetcher/src/backend/xfsc_aas.rs b/rust/user-info-fetcher/src/backend/xfsc_aas.rs index 8b747635..ec057810 100644 --- a/rust/user-info-fetcher/src/backend/xfsc_aas.rs +++ b/rust/user-info-fetcher/src/backend/xfsc_aas.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use hyper::StatusCode; +use reqwest::ClientBuilder; use serde::Deserialize; use snafu::{ResultExt, Snafu}; use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; @@ -38,6 +39,9 @@ pub enum Error { #[snafu(display("the XFSC AAS does not support querying by username, only by user ID"))] UserInfoByUsernameNotSupported {}, + + #[snafu(display("failed to construct HTTP client"))] + ConstructHttpClient { source: reqwest::Error }, } impl http_error::Error for Error { @@ -46,6 +50,7 @@ impl http_error::Error for Error { Self::ParseAasEndpointUrl { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::Request { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::UserInfoByUsernameNotSupported { .. } => StatusCode::NOT_IMPLEMENTED, + Self::ConstructHttpClient { .. } => StatusCode::SERVICE_UNAVAILABLE, } } } @@ -77,35 +82,53 @@ impl TryFrom for UserInfo { /// Endpoint definition: /// `` /// -/// Only `UserInfoRequestById` is supported because the enpoint has no username concept. -pub(crate) async fn get_user_info( - req: &UserInfoRequest, - http: &reqwest::Client, - config: &v1alpha1::AasBackend, -) -> Result { - let v1alpha1::AasBackend { hostname, port } = config; - - let cip_endpoint_raw = format!("http://{hostname}:{port}{API_PATH}"); - let cip_endpoint = Url::parse(&cip_endpoint_raw).context(ParseAasEndpointUrlSnafu { - url: cip_endpoint_raw, - })?; - - let subject_id = match req { - UserInfoRequest::UserInfoRequestById(r) => &r.id, - UserInfoRequest::UserInfoRequestByName(_) => UserInfoByUsernameNotSupportedSnafu.fail()?, +/// This struct combines the CRD configuration with an HTTP client initialized at startup. +pub struct ResolvedXfscAasBackend { + config: v1alpha1::AasBackend, + http_client: reqwest::Client, +} + +impl ResolvedXfscAasBackend { + /// Resolves an XFSC AAS backend by initializing the HTTP client. + pub fn resolve(config: v1alpha1::AasBackend) -> Result { + let http_client = ClientBuilder::new() + .build() + .context(ConstructHttpClientSnafu)?; + + Ok(Self { + config, + http_client, + }) } - .as_ref(); - let query_parameters: HashMap<&str, &str> = [ - (SUB_CLAIM, subject_id), - (SCOPE_CLAIM, OPENID_SCOPE), // we only request the openid scope because that is the only scope that the AAS supports - ] - .into(); + /// Only `UserInfoRequestById` is supported because the endpoint has no username concept. + pub(crate) async fn get_user_info(&self, req: &UserInfoRequest) -> Result { + let v1alpha1::AasBackend { hostname, port } = &self.config; - let user_claims: UserClaims = - send_json_request(http.get(cip_endpoint).query(&query_parameters)) - .await - .context(RequestSnafu)?; + let cip_endpoint_raw = format!("http://{hostname}:{port}{API_PATH}"); + let cip_endpoint = Url::parse(&cip_endpoint_raw).context(ParseAasEndpointUrlSnafu { + url: cip_endpoint_raw, + })?; - user_claims.try_into() + let subject_id = match req { + UserInfoRequest::UserInfoRequestById(r) => &r.id, + UserInfoRequest::UserInfoRequestByName(_) => { + UserInfoByUsernameNotSupportedSnafu.fail()? + } + } + .as_ref(); + + let query_parameters: HashMap<&str, &str> = [ + (SUB_CLAIM, subject_id), + (SCOPE_CLAIM, OPENID_SCOPE), // we only request the openid scope because that is the only scope that the AAS supports + ] + .into(); + + let user_claims: UserClaims = + send_json_request(self.http_client.get(cip_endpoint).query(&query_parameters)) + .await + .context(RequestSnafu)?; + + user_claims.try_into() + } } diff --git a/rust/user-info-fetcher/src/main.rs b/rust/user-info-fetcher/src/main.rs index 227a1f72..c2f18026 100644 --- a/rust/user-info-fetcher/src/main.rs +++ b/rust/user-info-fetcher/src/main.rs @@ -9,13 +9,10 @@ use axum::{Json, Router, extract::State, routing::post}; use clap::Parser; use futures::{FutureExt, future, pin_mut}; use moka::future::Cache; -use reqwest::ClientBuilder; use serde::{Deserialize, Serialize}; use snafu::{ResultExt, Snafu}; use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; -use stackable_operator::{ - cli::CommonOptions, commons::tls_verification::TlsClientDetails, telemetry::Tracing, -}; +use stackable_operator::{cli::CommonOptions, telemetry::Tracing}; use tokio::net::TcpListener; mod backend; @@ -42,16 +39,27 @@ pub struct Args { #[derive(Clone)] struct AppState { - config: Arc, - http: reqwest::Client, - credentials: Arc, + backend: Arc, user_info_cache: Cache, } -struct Credentials { - // TODO: Find a better way of sharing behavior between different backends - client_id: String, - client_secret: String, +/// Backend with resolved credentials. +/// +/// This enum wraps backend-specific implementations that have already loaded their credentials +/// and initialized their HTTP clients. +enum ResolvedBackend { + None, + Keycloak(backend::keycloak::ResolvedKeycloakBackend), + ExperimentalXfscAas(backend::xfsc_aas::ResolvedXfscAasBackend), + ActiveDirectory { + ldap_server: String, + tls: stackable_operator::commons::tls_verification::TlsClientDetails, + base_distinguished_name: String, + custom_attribute_mappings: std::collections::BTreeMap, + additional_group_attribute_filters: std::collections::BTreeMap, + }, + Entra(backend::entra::ResolvedEntraBackend), + OpenLdap(backend::openldap::ResolvedOpenLdapBackend), } #[derive(Snafu, Debug)] @@ -74,16 +82,22 @@ enum StartupError { #[snafu(display("failed to run server"))] RunServer { source: std::io::Error }, - #[snafu(display("failed to construct http client"))] - ConstructHttpClient { source: reqwest::Error }, - - #[snafu(display("failed to configure TLS"))] - ConfigureTls { source: utils::tls::Error }, - #[snafu(display("failed to initialize stackable-telemetry"))] TracingInit { source: stackable_operator::telemetry::tracing::Error, }, + + #[snafu(display("failed to resolve Keycloak backend"))] + ResolveKeycloakBackend { source: backend::keycloak::Error }, + + #[snafu(display("failed to resolve Entra backend"))] + ResolveEntraBackend { source: backend::entra::Error }, + + #[snafu(display("failed to resolve OpenLDAP backend"))] + ResolveOpenLdapBackend { source: backend::openldap::Error }, + + #[snafu(display("failed to resolve XFSC AAS backend"))] + ResolveXfscAasBackend { source: backend::xfsc_aas::Error }, } async fn read_config_file(path: &Path) -> Result { @@ -92,6 +106,50 @@ async fn read_config_file(path: &Path) -> Result { .context(ReadConfigFileSnafu { path }) } +/// Resolves a backend configuration by loading credentials and creating the appropriate backend implementation. +/// +/// This function reads credentials from the filesystem once at startup and returns a backend that +/// contains both the configuration and the resolved credentials. +async fn resolve_backend( + backend: v1alpha1::Backend, + credentials_dir: &Path, +) -> Result { + match backend { + v1alpha1::Backend::None {} => Ok(ResolvedBackend::None), + v1alpha1::Backend::Keycloak(config) => { + let resolved = + backend::keycloak::ResolvedKeycloakBackend::resolve(config, credentials_dir) + .await + .context(ResolveKeycloakBackendSnafu)?; + Ok(ResolvedBackend::Keycloak(resolved)) + } + v1alpha1::Backend::ExperimentalXfscAas(config) => { + let resolved = backend::xfsc_aas::ResolvedXfscAasBackend::resolve(config) + .context(ResolveXfscAasBackendSnafu)?; + Ok(ResolvedBackend::ExperimentalXfscAas(resolved)) + } + v1alpha1::Backend::ActiveDirectory(config) => Ok(ResolvedBackend::ActiveDirectory { + ldap_server: config.ldap_server, + tls: config.tls, + base_distinguished_name: config.base_distinguished_name, + custom_attribute_mappings: config.custom_attribute_mappings, + additional_group_attribute_filters: config.additional_group_attribute_filters, + }), + v1alpha1::Backend::Entra(config) => { + let resolved = backend::entra::ResolvedEntraBackend::resolve(config, credentials_dir) + .await + .context(ResolveEntraBackendSnafu)?; + Ok(ResolvedBackend::Entra(resolved)) + } + v1alpha1::Backend::OpenLdap(config) => { + let resolved = backend::openldap::ResolvedOpenLdapBackend::resolve(config) + .await + .context(ResolveOpenLdapBackendSnafu)?; + Ok(ResolvedBackend::OpenLdap(resolved)) + } + } +} + #[tokio::main] #[snafu::report] async fn main() -> Result<(), StartupError> { @@ -126,58 +184,10 @@ async fn main() -> Result<(), StartupError> { } }; - let config = Arc::::new( - serde_json::from_str(&read_config_file(&args.config).await?).context(ParseConfigSnafu)?, - ); - let credentials = Arc::new(match &config.backend { - // TODO: factor this out into each backend (e.g. when we add LDAP support) - v1alpha1::Backend::None {} => Credentials { - client_id: "".to_string(), - client_secret: "".to_string(), - }, - v1alpha1::Backend::Keycloak(_) => Credentials { - client_id: read_config_file(&args.credentials_dir.join("clientId")).await?, - client_secret: read_config_file(&args.credentials_dir.join("clientSecret")).await?, - }, - v1alpha1::Backend::ExperimentalXfscAas(_) => Credentials { - client_id: "".to_string(), - client_secret: "".to_string(), - }, - v1alpha1::Backend::ActiveDirectory(_) => Credentials { - client_id: "".to_string(), - client_secret: "".to_string(), - }, - v1alpha1::Backend::Entra(_) => Credentials { - client_id: read_config_file(&args.credentials_dir.join("clientId")).await?, - client_secret: read_config_file(&args.credentials_dir.join("clientSecret")).await?, - }, - v1alpha1::Backend::OpenLdap(_) => Credentials { - client_id: "".to_string(), - client_secret: "".to_string(), - }, - }); - - let mut client_builder = ClientBuilder::new(); - - // TODO: I'm not so sure we should be doing all this keycloak specific stuff here. - // We could factor it out in the provider specific implementation (e.g. when we add LDAP support). - // I know it is for setting up the client, but an idea: make a trait for implementing backends - // The trait can do all this for a genric client using an implementation on the trait (eg: get_http_client() which will call self.uses_tls()) - if let v1alpha1::Backend::Keycloak(keycloak) = &config.backend { - client_builder = utils::tls::configure_reqwest(&keycloak.tls, client_builder) - .await - .context(ConfigureTlsSnafu)?; - } else if let v1alpha1::Backend::Entra(entra) = &config.backend { - client_builder = utils::tls::configure_reqwest( - &TlsClientDetails { - tls: entra.tls.clone(), - }, - client_builder, - ) - .await - .context(ConfigureTlsSnafu)?; - } - let http = client_builder.build().context(ConstructHttpClientSnafu)?; + let config: v1alpha1::Config = + serde_json::from_str(&read_config_file(&args.config).await?).context(ParseConfigSnafu)?; + + let backend = Arc::new(resolve_backend(config.backend, &args.credentials_dir).await?); let user_info_cache = { let v1alpha1::Cache { entry_time_to_live } = config.cache; @@ -189,9 +199,7 @@ async fn main() -> Result<(), StartupError> { let app = Router::new() .route("/user", post(get_user_info)) .with_state(AppState { - config, - http, - credentials, + backend, user_info_cache, }); let listener = TcpListener::bind("127.0.0.1:9476") @@ -304,16 +312,14 @@ async fn get_user_info( Json(req): Json, ) -> Result, http_error::JsonResponse>> { let AppState { - config, - http, - credentials, + backend, user_info_cache, } = state; Ok(Json( user_info_cache .try_get_with_by_ref(&req, async { - match &config.backend { - v1alpha1::Backend::None {} => { + match backend.as_ref() { + ResolvedBackend::None => { let user_id = match &req { UserInfoRequest::UserInfoRequestById(UserInfoRequestById { id }) => { Some(id) @@ -333,38 +339,38 @@ async fn get_user_info( custom_attributes: HashMap::new(), }) } - v1alpha1::Backend::Keycloak(keycloak) => { - backend::keycloak::get_user_info(&req, &http, &credentials, keycloak) - .await - .context(get_user_info_error::KeycloakSnafu) - } - v1alpha1::Backend::ExperimentalXfscAas(aas) => { - backend::xfsc_aas::get_user_info(&req, &http, aas) - .await - .context(get_user_info_error::ExperimentalXfscAasSnafu) - } - v1alpha1::Backend::ActiveDirectory(ad) => { - backend::active_directory::get_user_info( - &req, - &ad.ldap_server, - &ad.tls, - &ad.base_distinguished_name, - &ad.custom_attribute_mappings, - &ad.additional_group_attribute_filters, - ) + ResolvedBackend::Keycloak(keycloak) => keycloak + .get_user_info(&req) .await - .context(get_user_info_error::ActiveDirectorySnafu) - } - v1alpha1::Backend::Entra(entra) => { - backend::entra::get_user_info(&req, &http, &credentials, entra) - .await - .context(get_user_info_error::EntraSnafu) - } - v1alpha1::Backend::OpenLdap(openldap) => { - backend::openldap::get_user_info(&req, openldap) - .await - .context(get_user_info_error::OpenLdapSnafu) - } + .context(get_user_info_error::KeycloakSnafu), + ResolvedBackend::ExperimentalXfscAas(aas) => aas + .get_user_info(&req) + .await + .context(get_user_info_error::ExperimentalXfscAasSnafu), + ResolvedBackend::ActiveDirectory { + ldap_server, + tls, + base_distinguished_name, + custom_attribute_mappings, + additional_group_attribute_filters, + } => backend::active_directory::get_user_info( + &req, + ldap_server, + tls, + base_distinguished_name, + custom_attribute_mappings, + additional_group_attribute_filters, + ) + .await + .context(get_user_info_error::ActiveDirectorySnafu), + ResolvedBackend::Entra(entra) => entra + .get_user_info(&req) + .await + .context(get_user_info_error::EntraSnafu), + ResolvedBackend::OpenLdap(openldap) => openldap + .get_user_info(&req) + .await + .context(get_user_info_error::OpenLdapSnafu), } }) .await?,