From 171d4b985962a12a0ce828bb2f98931e29d94ca4 Mon Sep 17 00:00:00 2001 From: dervoeti Date: Thu, 13 Nov 2025 19:46:56 +0100 Subject: [PATCH 1/3] feat: OpenLDAP backend --- CHANGELOG.md | 6 + deploy/helm/opa-operator/crds/crds.yaml | 147 +++++++++ .../pages/usage-guide/user-info-fetcher.adoc | 63 +++- rust/operator-binary/src/controller.rs | 12 + .../src/crd/user_info_fetcher.rs | 87 +++++ rust/user-info-fetcher/src/backend/mod.rs | 1 + .../user-info-fetcher/src/backend/openldap.rs | 301 ++++++++++++++++++ rust/user-info-fetcher/src/main.rs | 13 + .../openldap-user-info/00-limit-range.yaml | 11 + .../openldap-user-info/00-patch-ns.yaml.j2 | 9 + .../openldap-user-info/01-assert.yaml.j2 | 10 + ...tor-aggregator-discovery-configmap.yaml.j2 | 9 + .../kuttl/openldap-user-info/10-assert.yaml | 6 + .../10-install-openldap.yaml | 168 ++++++++++ .../openldap-user-info/20-load-ldif.yaml | 8 + .../kuttl/openldap-user-info/30-assert.yaml | 10 + .../openldap-user-info/30-install-opa.yaml.j2 | 187 +++++++++++ .../kuttl/openldap-user-info/40-assert.yaml | 14 + .../40-install-test-regorule.yaml | 29 ++ .../kuttl/openldap-user-info/50-assert.yaml | 9 + .../50-prepare-test-regorule.yaml | 5 + .../kuttl/openldap-user-info/test-regorule.py | 207 ++++++++++++ tests/test-definition.yaml | 4 + 23 files changed, 1315 insertions(+), 1 deletion(-) create mode 100644 rust/user-info-fetcher/src/backend/openldap.rs create mode 100644 tests/templates/kuttl/openldap-user-info/00-limit-range.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/00-patch-ns.yaml.j2 create mode 100644 tests/templates/kuttl/openldap-user-info/01-assert.yaml.j2 create mode 100644 tests/templates/kuttl/openldap-user-info/01-install-vector-aggregator-discovery-configmap.yaml.j2 create mode 100644 tests/templates/kuttl/openldap-user-info/10-assert.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/10-install-openldap.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/20-load-ldif.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/30-assert.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/30-install-opa.yaml.j2 create mode 100644 tests/templates/kuttl/openldap-user-info/40-assert.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/40-install-test-regorule.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/50-assert.yaml create mode 100644 tests/templates/kuttl/openldap-user-info/50-prepare-test-regorule.yaml create mode 100755 tests/templates/kuttl/openldap-user-info/test-regorule.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bad0e7f3..949330cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Add support for OpenLDAP backend to user-info-fetcher ([#779]). + +[#779]: https://github.com/stackabletech/opa-operator/pull/779 + ## [25.11.0] - 2025-11-07 ## [25.11.0-rc1] - 2025-11-06 diff --git a/deploy/helm/opa-operator/crds/crds.yaml b/deploy/helm/opa-operator/crds/crds.yaml index 7e2b9c38..ac1b26b0 100644 --- a/deploy/helm/opa-operator/crds/crds.yaml +++ b/deploy/helm/opa-operator/crds/crds.yaml @@ -84,6 +84,8 @@ spec: - experimentalActiveDirectory - required: - experimentalEntra + - required: + - experimentalOpenLdap properties: experimentalActiveDirectory: description: Backend that fetches user information from Active Directory @@ -245,6 +247,151 @@ spec: - clientCredentialsSecret - tenantId type: object + experimentalOpenLdap: + description: Backend that fetches user information from OpenLDAP + properties: + bindCredentials: + description: |- + Credentials for binding to the LDAP server. + + The bind account is used to search for users and groups in the LDAP directory. + properties: + scope: + description: |- + [Scope](https://docs.stackable.tech/home/nightly/secret-operator/scope) of the + [SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass). + nullable: true + properties: + listenerVolumes: + default: [] + description: |- + The listener volume scope allows Node and Service scopes to be inferred from the applicable listeners. + This must correspond to Volume names in the Pod that mount Listeners. + items: + type: string + type: array + node: + default: false + description: |- + The node scope is resolved to the name of the Kubernetes Node object that the Pod is running on. + This will typically be the DNS name of the node. + type: boolean + pod: + default: false + description: |- + The pod scope is resolved to the name of the Kubernetes Pod. + This allows the secret to differentiate between StatefulSet replicas. + type: boolean + services: + default: [] + description: |- + The service scope allows Pod objects to specify custom scopes. + This should typically correspond to Service objects that the Pod participates in. + items: + type: string + type: array + type: object + secretClass: + description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.' + type: string + required: + - secretClass + type: object + customAttributeMappings: + additionalProperties: + type: string + default: {} + description: Custom attributes, and their LDAP attribute names. + type: object + groupMemberAttribute: + default: member + description: |- + LDAP attribute on group objects that contains member references. + + Common values: + - `member`: For `groupOfNames` objects (uses full DN) + - `memberUid`: For `posixGroup` objects (uses username) + + Defaults to `member`. + type: string + groupsSearchBase: + description: |- + LDAP search base for groups, e.g. `ou=groups,dc=example,dc=org`. + + If not specified, uses the main `searchBase`. + nullable: true + type: string + hostname: + description: Hostname of the LDAP server, e.g. `my.ldap.server`. + type: string + port: + description: Port of the LDAP server. If TLS is used defaults to `636`, otherwise to `389`. + format: uint16 + maximum: 65535.0 + minimum: 0.0 + nullable: true + type: integer + searchBase: + default: '' + description: LDAP search base, e.g. `ou=users,dc=example,dc=org`. + type: string + tls: + description: Use a TLS connection. If not specified no TLS will be used. + nullable: true + properties: + verification: + description: The verification method used to verify the certificates of the server and/or the client. + oneOf: + - required: + - none + - required: + - server + properties: + none: + description: Use TLS but don't verify certificates. + type: object + server: + description: Use TLS and a CA certificate to verify the server. + properties: + caCert: + description: CA cert to verify the server. + oneOf: + - required: + - webPki + - required: + - secretClass + properties: + secretClass: + description: |- + Name of the [SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) which will provide the CA certificate. + Note that a SecretClass does not need to have a key but can also work with just a CA certificate, + so if you got provided with a CA cert but don't have access to the key you can still use this method. + type: string + webPki: + description: |- + Use TLS and the CA certificates trusted by the common web browsers to verify the server. + This can be useful when you e.g. use public AWS S3 or other public available services. + type: object + type: object + required: + - caCert + type: object + type: object + required: + - verification + type: object + userIdAttribute: + default: entryUUID + description: LDAP attribute used for the user's unique identifier. Defaults to `entryUUID`. + type: string + userNameAttribute: + default: uid + description: LDAP attribute used for the username. Defaults to `uid`. + type: string + required: + - bindCredentials + - hostname + type: object experimentalXfscAas: description: |- Backend that fetches user information from the Gaia-X diff --git a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc index 6945a284..4e50d132 100644 --- a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc +++ b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc @@ -54,6 +54,7 @@ Currently the following backends are supported: * xref:#backend-keycloak[] * xref:#backend-activedirectory[] * xref:#backend-entra[] +* xref:#backend-openldap[] [#backends] == Backends @@ -206,6 +207,66 @@ spec: <2> The Entra tenant ID <3> A secret containing the `clientId` and `clientSecret` keys +[#backend-openldap] +=== OpenLDAP + +WARNING: The OpenLDAP backend is experimental, and subject to change. + +Fetches user attributes and groups over LDAP from OpenLDAP servers using simple bind authentication. + +OpenLDAP supports different group schemas: + +* *groupOfNames* (default): Groups contain a `member` attribute with full user DNs +* *posixGroup*: Groups contain a `memberUid` attribute with usernames + +[source,yaml] +---- +spec: + clusterConfig: + userInfo: + backend: + experimentalOpenLdap: # <1> + hostname: openldap.default.svc.cluster.local # <2> + port: 1636 # <3> + searchBase: dc=example,dc=org # <4> + bindCredentials: # <5> + secretClass: openldap-bind-credentials # <6> + userIdAttribute: entryUUID # <7> + userNameAttribute: uid # <8> + groupsSearchBase: ou=groups,dc=example,dc=org # <9> + groupMemberAttribute: member # <10> + customAttributeMappings: # <11> + email: mail + displayName: cn + givenName: givenName + surname: sn + tls: # <12> + verification: + server: + caCert: + secretClass: openldap-tls # <13> + cache: # optional, enabled by default + entryTimeToLive: 60s # optional, defaults to 60s +---- +<1> Enables the OpenLDAP backend +<2> The hostname of the LDAP server +<3> The port of the LDAP server. Defaults to `636` for LDAPS, or `389` for plain LDAP +<4> The base distinguished name to search. Users outside of this will not be seen +<5> Configuration for LDAP bind credentials +<6> The name of the SecretClass that provides the bind credentials. The secret must contain `user` and `password` keys +<7> LDAP attribute used for the user's unique identifier. Defaults to `entryUUID` +<8> LDAP attribute used for the username. Defaults to `uid` +<9> LDAP search base for groups. If not specified, uses the main `searchBase` +<10> LDAP attribute on group objects that contains member references. Use `member` for `groupOfNames` (default) or `memberUid` for `posixGroup` +<11> Arbitrary LDAP attributes can be requested to be fetched and returned in the user info response. Use this to map custom LDAP attributes to custom attribute names in the response +<12> Optional TLS configuration for secure LDAP connections +<13> The name of the SecretClass that contains the LDAP server's root CA certificate(s) + +When retrieving user information from OpenLDAP, the user info fetcher first searches for the user by the `userNameAttribute` (defaults to `uid`) or `userIdAttribute` (defaults to `entryUUID`) depending on the request type. When a user is found, it searches for groups containing the user: + +* If `groupMemberAttribute` is `memberUid`: Searches for groups where `memberUid` equals the username (for `posixGroup`) +* Otherwise (e.g. if `groupMemberAttribute` is `member`, which is the default): Searches for groups where `member` equals the user's full DN (for `groupOfNames`) + == User info fetcher API User information can be retrieved from regorules using the functions `userInfoByUsername(username)` and `userInfoById(id)` in `data.stackable.opa.userinfo.v1`. @@ -229,7 +290,7 @@ NOTE: The exact formats of `id` and `groups` will vary depending on the xref:#ba === Debug request To debug the user-info-fetcher you can `curl` it's API for a given user. -To achieve this shell into the `user-info-fetcher` container and execute +To achieve this shell into the `opa` container and execute [source,bash] ---- diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index b5a6e934..dc11fcb4 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -33,6 +33,7 @@ use stackable_operator::{ secret_class::{SecretClassVolume, SecretClassVolumeScope}, tls_verification::{TlsClientDetails, TlsClientDetailsError}, }, + crd::authentication::ldap, k8s_openapi::{ DeepMerge, api::{ @@ -313,6 +314,11 @@ pub enum Error { ))] UserInfoFetcherTlsVolumeAndMounts { source: TlsClientDetailsError }, + #[snafu(display( + "failed to build volume or volume mount spec for the User Info Fetcher LDAP config" + ))] + UserInfoFetcherLdapVolumeAndMounts { source: ldap::v1alpha1::Error }, + #[snafu(display("failed to configure logging"))] ConfigureLogging { source: LoggingError }, @@ -1072,6 +1078,12 @@ fn build_server_rolegroup_daemonset( .add_volumes_and_mounts(&mut pb, vec![&mut cb_user_info_fetcher]) .context(UserInfoFetcherTlsVolumeAndMountsSnafu)?; } + user_info_fetcher::v1alpha1::Backend::OpenLdap(openldap) => { + openldap + .to_ldap_provider() + .add_volumes_and_mounts(&mut pb, vec![&mut cb_user_info_fetcher]) + .context(UserInfoFetcherLdapVolumeAndMountsSnafu)?; + } } pb.add_container(cb_user_info_fetcher.build()); diff --git a/rust/operator-binary/src/crd/user_info_fetcher.rs b/rust/operator-binary/src/crd/user_info_fetcher.rs index 13b51d2d..a5dc8eeb 100644 --- a/rust/operator-binary/src/crd/user_info_fetcher.rs +++ b/rust/operator-binary/src/crd/user_info_fetcher.rs @@ -4,8 +4,10 @@ use serde::{Deserialize, Serialize}; use stackable_operator::{ commons::{ networking::HostName, + secret_class::SecretClassVolume, tls_verification::{CaCert, Tls, TlsClientDetails, TlsServerVerification, TlsVerification}, }, + crd::authentication::ldap, schemars::{self, JsonSchema}, shared::time::Duration, versioned::versioned, @@ -45,6 +47,10 @@ pub mod versioned { /// Backend that fetches user information from Microsoft Entra #[serde(rename = "experimentalEntra")] Entra(v1alpha1::EntraBackend), + + /// Backend that fetches user information from OpenLDAP + #[serde(rename = "experimentalOpenLdap")] + OpenLdap(v1alpha1::OpenLdapBackend), } #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] @@ -150,6 +156,56 @@ pub mod versioned { pub client_credentials_secret: String, } + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct OpenLdapBackend { + /// Hostname of the LDAP server, e.g. `my.ldap.server`. + pub hostname: HostName, + + /// Port of the LDAP server. If TLS is used defaults to `636`, otherwise to `389`. + pub port: Option, + + /// LDAP search base, e.g. `ou=users,dc=example,dc=org`. + #[serde(default)] + pub search_base: String, + + /// Credentials for binding to the LDAP server. + /// + /// The bind account is used to search for users and groups in the LDAP directory. + pub bind_credentials: SecretClassVolume, + + /// Use a TLS connection. If not specified no TLS will be used. + #[serde(flatten)] + pub tls: TlsClientDetails, + + /// LDAP attribute used for the user's unique identifier. Defaults to `entryUUID`. + #[serde(default = "openldap_default_user_id_attribute")] + pub user_id_attribute: String, + + /// LDAP attribute used for the username. Defaults to `uid`. + #[serde(default = "openldap_default_user_name_attribute")] + pub user_name_attribute: String, + + /// LDAP search base for groups, e.g. `ou=groups,dc=example,dc=org`. + /// + /// If not specified, uses the main `searchBase`. + pub groups_search_base: Option, + + /// LDAP attribute on group objects that contains member references. + /// + /// Common values: + /// - `member`: For `groupOfNames` objects (uses full DN) + /// - `memberUid`: For `posixGroup` objects (uses username) + /// + /// Defaults to `member`. + #[serde(default = "openldap_default_group_member_attribute")] + pub group_member_attribute: String, + + /// Custom attributes, and their LDAP attribute names. + #[serde(default)] + pub custom_attribute_mappings: BTreeMap, + } + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Cache { @@ -189,6 +245,18 @@ fn aas_default_port() -> u16 { 5000 } +fn openldap_default_user_id_attribute() -> String { + "entryUUID".to_string() +} + +fn openldap_default_user_name_attribute() -> String { + "uid".to_string() +} + +fn openldap_default_group_member_attribute() -> String { + "member".to_string() +} + impl v1alpha1::Cache { const fn default_entry_time_to_live() -> Duration { Duration::from_minutes_unchecked(1) @@ -202,3 +270,22 @@ impl Default for v1alpha1::Cache { } } } + +impl v1alpha1::OpenLdapBackend { + /// Returns an LDAP [`AuthenticationProvider`](ldap::v1alpha1::AuthenticationProvider) for + /// connecting to the OpenLDAP server. + /// + /// Converts this OpenLdap backend configuration into a standard LDAP authentication provider + /// that can be used by the user-info-fetcher to establish connections and query user data. + pub fn to_ldap_provider(&self) -> ldap::v1alpha1::AuthenticationProvider { + ldap::v1alpha1::AuthenticationProvider { + hostname: self.hostname.clone(), + port: self.port, + search_base: self.search_base.clone(), + search_filter: String::new(), + ldap_field_names: ldap::v1alpha1::FieldNames::default(), + bind_credentials: Some(self.bind_credentials.clone()), + tls: self.tls.clone(), + } + } +} diff --git a/rust/user-info-fetcher/src/backend/mod.rs b/rust/user-info-fetcher/src/backend/mod.rs index c9a32709..638e79ee 100644 --- a/rust/user-info-fetcher/src/backend/mod.rs +++ b/rust/user-info-fetcher/src/backend/mod.rs @@ -1,4 +1,5 @@ pub mod active_directory; pub mod entra; pub mod keycloak; +pub mod openldap; pub mod xfsc_aas; diff --git a/rust/user-info-fetcher/src/backend/openldap.rs b/rust/user-info-fetcher/src/backend/openldap.rs new file mode 100644 index 00000000..569a9b95 --- /dev/null +++ b/rust/user-info-fetcher/src/backend/openldap.rs @@ -0,0 +1,301 @@ +use std::collections::{BTreeMap, HashMap}; + +use hyper::StatusCode; +use ldap3::{LdapConnAsync, LdapConnSettings, LdapError, Scope, SearchEntry, ldap_escape}; +use snafu::{OptionExt, ResultExt, Snafu}; +use stackable_operator::crd::authentication::ldap; + +use crate::{ErrorRenderUserInfoRequest, UserInfo, UserInfoRequest, http_error, utils}; + +#[derive(Snafu, Debug)] +pub enum Error { + #[snafu(display("failed to configure TLS"))] + ConfigureTls { source: utils::tls::Error }, + + #[snafu(display("failed to connect to LDAP"))] + ConnectLdap { source: LdapError }, + + #[snafu(display("failed to send LDAP request"))] + RequestLdap { source: LdapError }, + + #[snafu(display("failed to bind LDAP credentials"))] + BindLdap { source: LdapError }, + + #[snafu(display("failed to search LDAP for users"))] + FindUserLdap { source: LdapError }, + + #[snafu(display("unable to find user {request}"))] + UserNotFound { request: ErrorRenderUserInfoRequest }, + + #[snafu(display("failed to parse LDAP endpoint URL"))] + ParseLdapEndpointUrl { source: ldap::v1alpha1::Error }, + + #[snafu(display("failed to read bind user file from {path:?}"))] + ReadBindUser { + source: std::io::Error, + path: String, + }, + + #[snafu(display("failed to read bind password file 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 { + fn status_code(&self) -> StatusCode { + match *self { + Error::ConfigureTls { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::ConnectLdap { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::RequestLdap { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::BindLdap { .. } => StatusCode::SERVICE_UNAVAILABLE, + 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, + } + } +} + +#[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"); + + 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) + .await + .context(ReadBindPasswordSnafu { + path: password_path, + })?; + + 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(ConnectLdapSnafu)?; + ldap3::drive!(ldap_conn); + + ldap.simple_bind(&bind_user, &bind_password) + .await + .context(RequestLdapSnafu)? + .success() + .context(BindLdapSnafu)?; + + 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) + ) + } + }; + + 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, + ) + .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. +/// +/// This function performs an LDAP search to find all groups where the user is a member. +/// The search strategy depends on the `group_member_attribute`: +/// - `member`: Searches for groups where `member=` (DN-based, for `groupOfNames`) +/// - `memberUid`: Searches for groups where `memberUid=` +/// (username-based, for `posixGroup`) +#[tracing::instrument(skip(ldap, user, config), fields(user.dn))] +async fn search_user_groups( + ldap: &mut ldap3::Ldap, + user: &SearchEntry, + config: &stackable_opa_operator::crd::user_info_fetcher::v1alpha1::OpenLdapBackend, +) -> Result, Error> { + let group_member_attribute = &config.group_member_attribute; + let groups_search_base = config + .groups_search_base + .as_ref() + .unwrap_or(&config.search_base); + + // Determine the search value based on the attribute type + let search_value = if group_member_attribute == "memberUid" { + // Use username for posixGroup style + user.attrs + .get(&config.user_name_attribute) + .and_then(|values| values.first()) + .map(|s| s.as_str()) + .context(MissingUsernameAttributeSnafu { + attribute: config.user_name_attribute.clone(), + })? + } else { + // Use full DN for groupOfNames style + &user.dn + }; + + let group_filter = format!( + "{}={}", + ldap_escape(group_member_attribute), + ldap_escape(search_value) + ); + + tracing::debug!( + group_filter, + groups_search_base, + "searching for user's groups" + ); + + let group_results = ldap + .search( + groups_search_base, + Scope::Subtree, + &group_filter, + vec!["cn"], + ) + .await + .context(RequestLdapSnafu)? + .success() + .context(FindUserLdapSnafu)? + .0; + + let groups = group_results + .into_iter() + .map(SearchEntry::construct) + .filter_map(|group| { + group + .attrs + .get("cn") + .and_then(|values| values.first()) + .cloned() + }) + .collect(); + + tracing::debug!(?groups, "found user groups"); + Ok(groups) +} + +#[tracing::instrument( + skip(user_id_attribute, user_name_attribute, user, custom_attribute_mappings), + fields(user.dn), +)] +async fn user_attributes( + user_id_attribute: &str, + user_name_attribute: &str, + user: &SearchEntry, + groups: Vec, + custom_attribute_mappings: &BTreeMap, +) -> Result { + let id = user + .attrs + .get(user_id_attribute) + .and_then(|values| values.first()) + .cloned(); + let username = user + .attrs + .get(user_name_attribute) + .and_then(|values| values.first()) + .cloned(); + + let custom_attributes = custom_attribute_mappings + .iter() + .filter_map(|(uif_key, ldap_key)| { + let Some(values) = user.attrs.get(ldap_key) else { + if user.bin_attrs.contains_key(ldap_key) { + tracing::warn!( + ?uif_key, + ?ldap_key, + "LDAP custom attribute is only returned as binary, which is not supported", + ); + } + return None; + }; + Some(( + uif_key.clone(), + serde_json::Value::Array( + values + .iter() + .cloned() + .map(serde_json::Value::String) + .collect::>(), + ), + )) + }) + .collect::>(); + + Ok(UserInfo { + id, + username, + groups, + custom_attributes, + }) +} diff --git a/rust/user-info-fetcher/src/main.rs b/rust/user-info-fetcher/src/main.rs index 386b67bd..227a1f72 100644 --- a/rust/user-info-fetcher/src/main.rs +++ b/rust/user-info-fetcher/src/main.rs @@ -151,6 +151,10 @@ async fn main() -> Result<(), StartupError> { 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(); @@ -272,6 +276,9 @@ enum GetUserInfoError { #[snafu(display("failed to get user information from Entra"))] Entra { source: backend::entra::Error }, + + #[snafu(display("failed to get user information from OpenLDAP"))] + OpenLdap { source: backend::openldap::Error }, } impl http_error::Error for GetUserInfoError { @@ -287,6 +294,7 @@ impl http_error::Error for GetUserInfoError { Self::ExperimentalXfscAas { source } => source.status_code(), Self::ActiveDirectory { source } => source.status_code(), Self::Entra { source } => source.status_code(), + Self::OpenLdap { source } => source.status_code(), } } } @@ -352,6 +360,11 @@ async fn get_user_info( .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) + } } }) .await?, diff --git a/tests/templates/kuttl/openldap-user-info/00-limit-range.yaml b/tests/templates/kuttl/openldap-user-info/00-limit-range.yaml new file mode 100644 index 00000000..7b6cb30e --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/00-limit-range.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: limit-request-ratio +spec: + limits: + - type: "Container" + maxLimitRequestRatio: + cpu: 5 + memory: 1 diff --git a/tests/templates/kuttl/openldap-user-info/00-patch-ns.yaml.j2 b/tests/templates/kuttl/openldap-user-info/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/00-patch-ns.yaml.j2 @@ -0,0 +1,9 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}' + timeout: 120 +{% endif %} diff --git a/tests/templates/kuttl/openldap-user-info/01-assert.yaml.j2 b/tests/templates/kuttl/openldap-user-info/01-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/01-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/openldap-user-info/01-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/openldap-user-info/01-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/01-install-vector-aggregator-discovery-configmap.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/openldap-user-info/10-assert.yaml b/tests/templates/kuttl/openldap-user-info/10-assert.yaml new file mode 100644 index 00000000..8c361389 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/10-assert.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +commands: + - script: kubectl wait --for=condition=ready pod/test-openldap-0 -n $NAMESPACE --timeout=300s diff --git a/tests/templates/kuttl/openldap-user-info/10-install-openldap.yaml b/tests/templates/kuttl/openldap-user-info/10-install-openldap.yaml new file mode 100644 index 00000000..0e55a00b --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/10-install-openldap.yaml @@ -0,0 +1,168 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl apply -n $NAMESPACE -f - < 0 %} + custom: "{{ test_scenario['values']['opa-latest'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['opa-latest'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['opa-latest'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + userInfo: + backend: + experimentalOpenLdap: + hostname: test-openldap.${NAMESPACE}.svc.cluster.local + port: 1636 + searchBase: ou=users,dc=example,dc=org + bindCredentials: + secretClass: ldap-bind-test-$NAMESPACE + groupsSearchBase: ou=groups,dc=example,dc=org + customAttributeMappings: + hdir: homeDirectory + displayName: cn + surname: sn + tls: + verification: + server: + caCert: + secretClass: ldap-tls-test-$NAMESPACE + cache: + entryTimeToLive: 60s +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + servers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 + podOverrides: + spec: + containers: + - name: bundle-builder + imagePullPolicy: IfNotPresent + - name: user-info-fetcher + imagePullPolicy: IfNotPresent + env: + - name: CONSOLE_LOG + value: DEBUG + - name: CONSOLE_LOG_LEVEL + value: DEBUG + + --- + # OPA Cluster 2: groupOfNames without TLS + apiVersion: opa.stackable.tech/v1alpha1 + kind: OpaCluster + metadata: + name: test-opa-groupofnames-notls + spec: + image: +{% if test_scenario['values']['opa-latest'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opa-latest'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['opa-latest'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['opa-latest'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + userInfo: + backend: + experimentalOpenLdap: + hostname: test-openldap.${NAMESPACE}.svc.cluster.local + port: 1389 + searchBase: ou=users,dc=example,dc=org + bindCredentials: + secretClass: ldap-bind-test-$NAMESPACE + groupsSearchBase: ou=groups,dc=example,dc=org + cache: + entryTimeToLive: 60s +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + servers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 + podOverrides: + spec: + containers: + - name: bundle-builder + imagePullPolicy: IfNotPresent + - name: user-info-fetcher + imagePullPolicy: IfNotPresent + env: + - name: CONSOLE_LOG + value: DEBUG + - name: CONSOLE_LOG_LEVEL + value: DEBUG + + --- + # OPA Cluster 3: posixGroup with TLS + apiVersion: opa.stackable.tech/v1alpha1 + kind: OpaCluster + metadata: + name: test-opa-posixgroup-tls + spec: + image: +{% if test_scenario['values']['opa-latest'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opa-latest'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['opa-latest'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['opa-latest'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + userInfo: + backend: + experimentalOpenLdap: + hostname: test-openldap.${NAMESPACE}.svc.cluster.local + port: 1636 + searchBase: ou=users,dc=example,dc=org + bindCredentials: + secretClass: ldap-bind-test-$NAMESPACE + groupsSearchBase: ou=posixgroups,dc=example,dc=org + groupMemberAttribute: memberUid + tls: + verification: + server: + caCert: + secretClass: ldap-tls-test-$NAMESPACE + cache: + entryTimeToLive: 60s +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + servers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 + podOverrides: + spec: + containers: + - name: bundle-builder + imagePullPolicy: IfNotPresent + - name: user-info-fetcher + imagePullPolicy: IfNotPresent + env: + - name: CONSOLE_LOG + value: DEBUG + - name: CONSOLE_LOG_LEVEL + value: DEBUG + EOF diff --git a/tests/templates/kuttl/openldap-user-info/40-assert.yaml b/tests/templates/kuttl/openldap-user-info/40-assert.yaml new file mode 100644 index 00000000..7912d1c5 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/40-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-regorule +timeout: 300 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: test-regorule +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/openldap-user-info/40-install-test-regorule.yaml b/tests/templates/kuttl/openldap-user-info/40-install-test-regorule.yaml new file mode 100644 index 00000000..816f6148 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/40-install-test-regorule.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: test-regorule + labels: + app: test-regorule +spec: + replicas: 1 + selector: + matchLabels: + app: test-regorule + template: + metadata: + labels: + app: test-regorule + spec: + containers: + - name: test-regorule + image: oci.stackable.tech/sdp/testing-tools:0.2.0-stackable0.0.0-dev + stdin: true + tty: true + resources: + requests: + memory: "128Mi" + cpu: "512m" + limits: + memory: "128Mi" + cpu: "1" diff --git a/tests/templates/kuttl/openldap-user-info/50-assert.yaml b/tests/templates/kuttl/openldap-user-info/50-assert.yaml new file mode 100644 index 00000000..58986227 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/50-assert.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-regorule +commands: + - script: kubectl exec -n $NAMESPACE test-regorule-0 -- python /tmp/test-regorule.py -u 'http://test-opa-groupofnames-tls-server:8081/v1/data/test' -t groupofnames-tls + - script: kubectl exec -n $NAMESPACE test-regorule-0 -- python /tmp/test-regorule.py -u 'http://test-opa-groupofnames-notls-server:8081/v1/data/test' -t groupofnames-notls + - script: kubectl exec -n $NAMESPACE test-regorule-0 -- python /tmp/test-regorule.py -u 'http://test-opa-posixgroup-tls-server:8081/v1/data/test' -t posixgroup-tls diff --git a/tests/templates/kuttl/openldap-user-info/50-prepare-test-regorule.yaml b/tests/templates/kuttl/openldap-user-info/50-prepare-test-regorule.yaml new file mode 100644 index 00000000..4cf60f6a --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/50-prepare-test-regorule.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: +- script: kubectl cp -n $NAMESPACE ./test-regorule.py test-regorule-0:/tmp diff --git a/tests/templates/kuttl/openldap-user-info/test-regorule.py b/tests/templates/kuttl/openldap-user-info/test-regorule.py new file mode 100755 index 00000000..78761c81 --- /dev/null +++ b/tests/templates/kuttl/openldap-user-info/test-regorule.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python +import argparse +import json +from dataclasses import dataclass, field + +import requests + + +@dataclass +class Fixture: + expected_username: str + expected_groups: list[str] = field(default_factory=list) + expected_custom_attributes: dict[str, list[str]] = field(default_factory=dict) + + +groupofnames_fixtures = { + "alice": Fixture( + expected_username="alice", + expected_groups=[ + "admins", + "developers", + "readers", + ], + expected_custom_attributes={ + "displayName": ["User1", "alice"], + "hdir": ["/home/alice"], + "surname": ["Bar1"], + }, + ), + "bob": Fixture( + expected_username="bob", + expected_groups=[ + "developers", + "readers", + ], + expected_custom_attributes={ + "displayName": ["User2", "bob"], + "hdir": ["/home/bob"], + "surname": ["Bar2"], + }, + ), +} + +posixgroup_fixtures = { + "alice": Fixture( + expected_username="alice", + expected_groups=[ + "posix-admins", + "posix-developers", + ], + expected_custom_attributes={}, + ), + "bob": Fixture( + expected_username="bob", + expected_groups=[ + "posix-developers", + ], + expected_custom_attributes={}, + ), +} + + +def assertions( + username, response, opa_attribute, fixture, should_have_custom_attributes=False +): + assert "result" in response + result = response["result"] + assert opa_attribute in result, f"expected {opa_attribute} in {result}" + + assert "customAttributes" in result[opa_attribute] + assert "groups" in result[opa_attribute] + assert "id" in result[opa_attribute] + assert "username" in result[opa_attribute] + + assert result[opa_attribute]["username"] == fixture.expected_username, ( + f"for {username=} got user name {result[opa_attribute]['username']}, expected: {fixture.expected_username}" + ) + + groups = sorted(result[opa_attribute]["groups"]) + expected_groups = sorted(fixture.expected_groups) + assert groups == expected_groups, ( + f"for {username=} got {groups=}, expected: {expected_groups=}" + ) + + custom_attributes = result[opa_attribute]["customAttributes"] + if should_have_custom_attributes: + assert custom_attributes == fixture.expected_custom_attributes, ( + f"for {username=} got {custom_attributes=}, expected: {fixture.expected_custom_attributes}" + ) + else: + # For clusters without custom attribute mappings, should be empty + assert custom_attributes == {}, ( + f"for {username=} expected empty custom attributes but got {custom_attributes=}" + ) + + +def test_user_not_found(url): + params = {"strict-builtin-errors": "true"} + expected_status_code = 200 + + payload = {"input": {"username": "nonexistent"}} + response = requests.post(url, data=json.dumps(payload), params=params) + assert response.status_code == expected_status_code, ( + f"got {response.status_code}, expected: {expected_status_code}" + ) + response = response.json() + assert "result" in response + result = response["result"] + assert "currentUserInfoByUsername" in result + assert "error" in result["currentUserInfoByUsername"] + error = result["currentUserInfoByUsername"]["error"] + assert "message" in error + assert error["message"] == "failed to get user information from OpenLDAP" + assert "causes" in error + assert error["causes"][0] == 'unable to find user with username "nonexistent"' + + payload = {"input": {"id": "00000000-0000-0000-0000-000000000000"}} + response = requests.post(url, data=json.dumps(payload), params=params) + assert response.status_code == expected_status_code, ( + f"got {response.status_code}, expected: {expected_status_code}" + ) + response = response.json() + assert "result" in response + result = response["result"] + assert "currentUserInfoById" in result + assert "error" in result["currentUserInfoById"] + error = result["currentUserInfoById"]["error"] + assert "message" in error + assert error["message"] == "failed to get user information from OpenLDAP" + assert "causes" in error + assert ( + error["causes"][0] + == 'unable to find user with id "00000000-0000-0000-0000-000000000000"' + ) + + +if __name__ == "__main__": + all_args = argparse.ArgumentParser() + all_args.add_argument("-u", "--url", required=True, help="OPA service url") + all_args.add_argument( + "-t", + "--test-type", + required=True, + choices=["groupofnames-tls", "groupofnames-notls", "posixgroup-tls"], + help="Type of test to run", + ) + args = vars(all_args.parse_args()) + params = {"strict-builtin-errors": "true"} + + # Select the appropriate fixtures based on test type + if args["test_type"].startswith("groupofnames"): + fixtures = groupofnames_fixtures + # Only groupofnames-tls has custom attribute mappings configured + has_custom_attributes = args["test_type"] == "groupofnames-tls" + else: + fixtures = posixgroup_fixtures + has_custom_attributes = False + + def make_request(payload): + response = requests.post(args["url"], data=json.dumps(payload), params=params) + expected_status_code = 200 + assert response.status_code == expected_status_code, ( + f"got {response.status_code}, expected: {expected_status_code}" + ) + return response.json() + + for username, fixture in fixtures.items(): + try: + # Test by username + payload = {"input": {"username": username}} + response = make_request(payload) + assertions( + username, + response, + "currentUserInfoByUsername", + fixture, + has_custom_attributes, + ) + + # Test by ID (reverse lookup) + user_id = response["result"]["currentUserInfoByUsername"]["id"] + payload = {"input": {"id": user_id}} + response = make_request(payload) + assertions( + username, + response, + "currentUserInfoById", + fixture, + has_custom_attributes, + ) + except Exception as e: + print(f"exception: {e}") + if response is not None: + print(f"request body: {payload}") + print(f"response body: {response}") + raise e + + # Test user not found scenarios + try: + print(f"Testing user not found scenarios for {args['test_type']}...") + test_user_not_found(args["url"]) + print("User not found tests passed!") + except Exception as e: + print(f"User not found test failed: {e}") + raise e + + print(f"All tests passed for {args['test_type']}!") diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index b43f9a39..f87f9b24 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -54,6 +54,10 @@ tests: dimensions: - opa-latest - openshift + - name: openldap-user-info + dimensions: + - opa-latest + - openshift suites: - name: nightly patch: From 494e97695e074a0a9f99b9e549a917db3a4f49ca Mon Sep 17 00:00:00 2001 From: Lukas Krug Date: Wed, 19 Nov 2025 15:26:10 +0100 Subject: [PATCH 2/3] Update docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc Co-authored-by: Andrew Kenworthy <1712947+adwk67@users.noreply.github.com> --- docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc index 4e50d132..2caa6971 100644 --- a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc +++ b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc @@ -212,7 +212,7 @@ spec: WARNING: The OpenLDAP backend is experimental, and subject to change. -Fetches user attributes and groups over LDAP from OpenLDAP servers using simple bind authentication. +Fetch user attributes and groups over LDAP from OpenLDAP servers using simple bind authentication. OpenLDAP supports different group schemas: From 05849741460f081127a28fc983a81203e8abb921 Mon Sep 17 00:00:00 2001 From: dervoeti Date: Wed, 19 Nov 2025 18:09:06 +0100 Subject: [PATCH 3/3] docs: add comment about LDAP volume mounting --- rust/operator-binary/src/controller.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index dc11fcb4..cee53c7f 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -1079,6 +1079,8 @@ fn build_server_rolegroup_daemonset( .context(UserInfoFetcherTlsVolumeAndMountsSnafu)?; } user_info_fetcher::v1alpha1::Backend::OpenLdap(openldap) => { + // Reuse the logic from the LDAP `AuthenticationProvider` which handles + // volume mounting of TLS secrets and LDAP bind credentials openldap .to_ldap_provider() .add_volumes_and_mounts(&mut pb, vec![&mut cb_user_info_fetcher])