diff --git a/Cargo.lock b/Cargo.lock index a5a4ae1d..537a5e5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,6 +2091,7 @@ dependencies = [ "strum", "tokio", "tracing", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 93f99844..313c1797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", strum = { version = "0.26", features = ["derive"] } tokio = { version = "1.39", features = ["full"] } tracing = "0.1" +url = { version = "2.5.2" } xml-rs = "0.8" # [patch."https://github.com/stackabletech/operator-rs.git"] diff --git a/deploy/helm/nifi-operator/crds/crds.yaml b/deploy/helm/nifi-operator/crds/crds.yaml index 09b88a02..ec644b6c 100644 --- a/deploy/helm/nifi-operator/crds/crds.yaml +++ b/deploy/helm/nifi-operator/crds/crds.yaml @@ -33,8 +33,27 @@ spec: items: properties: authenticationClass: - description: Name of the [AuthenticationClass](https://docs.stackable.tech/home/nightly/concepts/authentication) used to authenticate users. Supported providers are `static` and `ldap`. For `static` the "admin" user needs to be present in the referenced secret, and only this user will be added to NiFi, other users are ignored. + description: A name/key which references an authentication class. To get the concrete [`AuthenticationClass`], we must resolve it. This resolution can be achieved by using [`ClientAuthenticationDetails::resolve_class`]. type: string + oidc: + description: |- + This field contains authentication provider specific configuration. + + Use [`ClientAuthenticationDetails::oidc_or_error`] to get the value or report an error to the user. + nullable: true + properties: + clientCredentialsSecret: + description: A reference to the OIDC client credentials secret. The secret contains the client id and secret. + type: string + extraScopes: + default: [] + description: An optional list of extra scopes which get merged with the scopes defined in the [`AuthenticationClass`]. + items: + type: string + type: array + required: + - clientCredentialsSecret + type: object required: - authenticationClass type: object diff --git a/docs/modules/nifi/pages/usage_guide/security.adoc b/docs/modules/nifi/pages/usage_guide/security.adoc index ec376611..b9f733e8 100644 --- a/docs/modules/nifi/pages/usage_guide/security.adoc +++ b/docs/modules/nifi/pages/usage_guide/security.adoc @@ -22,7 +22,7 @@ spec: serverSecretClass: non-default-secret-class # <1> ---- -<1> The name of the `SecretClass` that will be used for certificates for the NiFi UI. +<1> The name of the SecretClass that will be used for certificates for the NiFi UI. == Authentication @@ -49,8 +49,8 @@ spec: name: nifi-admin-credentials # <2> ---- -<1> The name of the `AuthenticationClass` that will be referenced in the NiFi cluster. -<2> The name of the `Secret` containing the admin credentials. +<1> The name of the AuthenticationClass that will be referenced in the NiFi cluster. +<2> The name of the Secret containing the admin credentials. [source,yaml] ---- @@ -63,9 +63,9 @@ stringData: bob: bob # <3> ---- -<1> The name of the `Secret` containing the admin user credentials. -<2> The user and password combination of the admin user. The username *must* be "admin" and cannot be changed. The NiFi pods will not start if they cannot mount the "admin" entry from the secret. The password can be adapted. -<3> The secret maybe used by other products of the Stackable Data Platform that allow more than one user. The Stackable Operator for Apache NiFi will ignore all users except for "admin". +<1> The name of the Secret containing the admin user credentials. +<2> The user and password combination of the admin user. The username *must* be "admin" and cannot be changed. The NiFi pods will not start if they cannot mount the "admin" entry from the Secret. The password can be adapted. +<3> The Secret maybe used by other products of the Stackable Data Platform that allow more than one user. The Stackable Operator for Apache NiFi will ignore all users except for "admin". [source,yaml] ---- @@ -75,7 +75,7 @@ spec: - authenticationClass: simple-nifi-users # <1> ---- -<1> The reference to an `AuthenticationClass`. NiFi only supports one authentication mechanism at a time. +<1> The reference to an AuthenticationClass. NiFi only supports one authentication mechanism at a time. [#authentication-ldap] === LDAP @@ -100,6 +100,64 @@ spec: You can follow the xref:tutorials:authentication_with_openldap.adoc[] tutorial to learn how to set up an AuthenticationClass for an LDAP server, as well as consulting the {crd-docs}/authentication.stackable.tech/authenticationclass/v1alpha1/[AuthenticationClass reference {external-link-icon}^]. +[#authentication-oidc] +=== OIDC + +NiFi supports xref:concepts:authentication.adoc[authentication] of users against an OIDC provider. +This requires setting up an AuthenticationClass for the OIDC provider and specifying a Secret containing the OIDC client id and client secret as part of the NiFi configuration. +The AuthenticationClass and the OIDC client credentials Secret are then referenced in the NifiCluster resource: + +[source,yaml] +---- +apiVersion: nifi.stackable.tech/v1alpha1 +kind: NifiCluster +metadata: + name: test-nifi +spec: + clusterConfig: + authentication: + - authenticationClass: oidc # <1> + oidc: + clientCredentialsSecret: nifi-oidc-client # <2> +---- + +<1> The reference to an AuthenticationClass called `oidc` +<2> The reference to an existing Secret called `nifi-oidc-client` + +[source,yaml] +---- +apiVersion: authentication.stackable.tech/v1alpha1 +kind: AuthenticationClass +metadata: + name: oidc +spec: + provider: + oidc: + hostname: keycloak.example.com + rootPath: /realms/test/ # <1> + principalClaim: preferred_username + scopes: + - openid + - email + - profile + port: 8080 + tls: null + [...] +---- + +<1> A trailing slash in `rootPath` is necessary. + +[source,yaml] +---- +apiVersion: v1 +kind: Secret +metadata: + name: nifi-oidc-client +stringData: + clientId: + clientSecret: +---- + [#authorization] == Authorization @@ -107,6 +165,7 @@ NiFi supports {nifi-docs-authorization}[multiple authorization methods], the ava Authorization is not fully implemented by the Stackable Operator for Apache NiFi. +[#authorization-single-user] === Single user With this authorization method, a single user has administrator capabilities. @@ -118,6 +177,14 @@ The operator uses the {nifi-docs-fileusergroupprovider}[`FileUserGroupProvider`] This user is then able to create and modify groups and policies in the web interface. These changes local to the Pod running NiFi and are *not* persistent. +[#authorization-oidc] +=== OIDC + +With this authorization method, all authenticated users have administrator capabilities. + +An admin user with an auto-generated password is created that can access the NiFi API. +The password for this user is stored in a Kubernetes Secret called `-oidc-admin-password`. + [#encrypting-sensitive-properties] == Encrypting sensitive properties on disk diff --git a/rust/crd/src/authentication.rs b/rust/crd/src/authentication.rs index 27c2517e..c82aa869 100644 --- a/rust/crd/src/authentication.rs +++ b/rust/crd/src/authentication.rs @@ -1,20 +1,22 @@ -use serde::{Deserialize, Serialize}; +use std::future::Future; + use snafu::{ResultExt, Snafu}; -use stackable_operator::commons::authentication::AuthenticationClassProvider; +use stackable_operator::commons::authentication::{ + ldap, oidc, static_, AuthenticationClassProvider, ClientAuthenticationDetails, +}; use stackable_operator::kube::ResourceExt; use stackable_operator::{ - client::Client, - commons::authentication::AuthenticationClass, + client::Client, commons::authentication::AuthenticationClass, kube::runtime::reflector::ObjectRef, - schemars::{self, JsonSchema}, }; +use crate::NifiCluster; + #[derive(Snafu, Debug)] pub enum Error { - #[snafu(display("Failed to retrieve AuthenticationClass {authentication_class}"))] - AuthenticationClassRetrieval { + #[snafu(display("failed to retrieve AuthenticationClass"))] + AuthenticationClassRetrievalFailed { source: stackable_operator::client::Error, - authentication_class: ObjectRef, }, #[snafu(display("The nifi-operator does not support running Nifi without any authentication. Please provide a AuthenticationClass to use."))] @@ -33,67 +35,102 @@ pub enum Error { NoLdapTlsVerificationNotSupported { authentication_class: ObjectRef, }, + + #[snafu(display("invalid OIDC configuration"))] + OidcConfigurationInvalid { + source: stackable_operator::commons::authentication::Error, + }, } type Result = std::result::Result; -#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NifiAuthenticationClassRef { - /// Name of the [AuthenticationClass](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication) used to authenticate users. - /// Supported providers are `static` and `ldap`. - /// For `static` the "admin" user needs to be present in the referenced secret, and only this user will be added to NiFi, other users are ignored. - pub authentication_class: String, +#[derive(Clone)] +pub enum AuthenticationClassResolved { + Static { + provider: static_::AuthenticationProvider, + }, + Ldap { + provider: ldap::AuthenticationProvider, + }, + Oidc { + provider: oidc::AuthenticationProvider, + oidc: oidc::ClientAuthenticationOptions<()>, + nifi: NifiCluster, + }, } -/// Retrieve all provided `AuthenticationClass` references. -pub async fn resolve_authentication_classes( - client: &Client, - authentication_class_refs: &Vec, -) -> Result> { - let mut resolved_auth_classes = vec![]; - - match authentication_class_refs.len() { - 0 => NoAuthenticationNotSupportedSnafu.fail()?, - 1 => {} - _ => MultipleAuthenticationClassesNotSupportedSnafu.fail()?, +impl AuthenticationClassResolved { + pub async fn from( + nifi: &NifiCluster, + client: &Client, + ) -> Result> { + let resolve_auth_class = |auth_details: ClientAuthenticationDetails| async move { + auth_details.resolve_class(client).await + }; + AuthenticationClassResolved::resolve(nifi, resolve_auth_class).await } - for auth_class in authentication_class_refs { - let resolved_auth_class = - AuthenticationClass::resolve(client, &auth_class.authentication_class) + /// Retrieve all provided `AuthenticationClass` references. + pub async fn resolve( + nifi: &NifiCluster, + resolve_auth_class: impl Fn(ClientAuthenticationDetails) -> R, + ) -> Result> + where + R: Future>, + { + let mut resolved_auth_classes = vec![]; + let auth_details = &nifi.spec.cluster_config.authentication; + + match auth_details.len() { + 0 => NoAuthenticationNotSupportedSnafu.fail()?, + 1 => {} + _ => MultipleAuthenticationClassesNotSupportedSnafu.fail()?, + } + + for entry in auth_details { + let auth_class = resolve_auth_class(entry.clone()) .await - .context(AuthenticationClassRetrievalSnafu { - authentication_class: ObjectRef::::new( - &auth_class.authentication_class, - ), - })?; - - let resolved_auth_class_name = resolved_auth_class.name_any(); - - match &resolved_auth_class.spec.provider { - AuthenticationClassProvider::Static(_) => {} - AuthenticationClassProvider::Ldap(ldap) => { - if ldap.tls.uses_tls() && !ldap.tls.uses_tls_verification() { - NoLdapTlsVerificationNotSupportedSnafu { - authentication_class: ObjectRef::::new( - &resolved_auth_class_name, - ), + .context(AuthenticationClassRetrievalFailedSnafu)?; + + let auth_class_name = auth_class.name_any(); + + match &auth_class.spec.provider { + AuthenticationClassProvider::Static(provider) => { + resolved_auth_classes.push(AuthenticationClassResolved::Static { + provider: provider.to_owned(), + }) + } + AuthenticationClassProvider::Ldap(provider) => { + if provider.tls.uses_tls() && !provider.tls.uses_tls_verification() { + NoLdapTlsVerificationNotSupportedSnafu { + authentication_class: ObjectRef::::new( + &auth_class_name, + ), + } + .fail()? } - .fail()? + resolved_auth_classes.push(AuthenticationClassResolved::Ldap { + provider: provider.to_owned(), + }) } - } - _ => AuthenticationClassProviderNotSupportedSnafu { - authentication_class_provider: resolved_auth_class.spec.provider.to_string(), - authentication_class: ObjectRef::::new( - &resolved_auth_class_name, - ), - } - .fail()?, - }; + AuthenticationClassProvider::Oidc(provider) => { + resolved_auth_classes.push(Ok(AuthenticationClassResolved::Oidc { + provider: provider.to_owned(), + oidc: entry + .oidc_or_error(&auth_class_name) + .context(OidcConfigurationInvalidSnafu)? + .clone(), + nifi: nifi.clone(), + })?) + } + _ => AuthenticationClassProviderNotSupportedSnafu { + authentication_class_provider: auth_class.spec.provider.to_string(), + authentication_class: ObjectRef::::new(&auth_class_name), + } + .fail()?, + }; + } - resolved_auth_classes.push(resolved_auth_class); + Ok(resolved_auth_classes) } - - Ok(resolved_auth_classes) } diff --git a/rust/crd/src/lib.rs b/rust/crd/src/lib.rs index 16acaa26..c996ab11 100644 --- a/rust/crd/src/lib.rs +++ b/rust/crd/src/lib.rs @@ -2,14 +2,13 @@ pub mod affinity; pub mod authentication; pub mod tls; -use crate::authentication::NifiAuthenticationClassRef; - use affinity::get_affinity; use serde::{Deserialize, Serialize}; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::{ commons::{ affinity::StackableAffinity, + authentication::ClientAuthenticationDetails, cluster_operation::ClusterOperation, product_image_selection::ProductImage, resources::{ @@ -18,8 +17,7 @@ use stackable_operator::{ }, }, config::{ - fragment::Fragment, - fragment::{self, ValidationError}, + fragment::{self, Fragment, ValidationError}, merge::Merge, }, k8s_openapi::{api::core::v1::Volume, apimachinery::pkg::api::resource::Quantity}, @@ -66,10 +64,13 @@ const DEFAULT_NODE_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_minutes_ pub enum Error { #[snafu(display("object has no namespace associated"))] NoNamespace, + #[snafu(display("the NiFi role [{role}] is missing from spec"))] MissingNifiRole { role: String }, + #[snafu(display("the NiFi node role group [{role_group}] is missing from spec"))] MissingNifiRoleGroup { role_group: String }, + #[snafu(display("fragment validation failure"))] FragmentValidationFailure { source: ValidationError }, } @@ -115,7 +116,7 @@ pub struct NifiClusterConfig { /// Authentication options for NiFi (required). /// Read more about authentication in the [security documentation](DOCS_BASE_URL_PLACEHOLDER/nifi/usage_guide/security). // We don't add `#[serde(default)]` here, as we require authentication - pub authentication: Vec, + pub authentication: Vec, /// TLS configuration options for the server. #[serde(default)] diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index 021e58fb..d0d074e1 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -27,6 +27,7 @@ product-config.workspace = true strum.workspace = true tokio.workspace = true tracing.workspace = true +url.workspace = true [build-dependencies] built.workspace = true diff --git a/rust/operator-binary/src/config.rs b/rust/operator-binary/src/config.rs index 7719dfce..3c1d71cc 100644 --- a/rust/operator-binary/src/config.rs +++ b/rust/operator-binary/src/config.rs @@ -10,7 +10,10 @@ use stackable_nifi_crd::{ PROTOCOL_PORT, }; use stackable_operator::{ - commons::resources::Resources, + commons::{ + authentication::oidc::{AuthenticationProvider, DEFAULT_OIDC_WELLKNOWN_PATH}, + resources::Resources, + }, memory::{BinaryMultiple, MemoryQuantity}, product_config_utils::{ transform_all_roles_to_config, validate_all_roles_and_groups_config, @@ -22,7 +25,9 @@ use strum::{Display, EnumIter}; use crate::{ operations::graceful_shutdown::graceful_shutdown_config_properties, - security::authentication::{STACKABLE_SERVER_TLS_DIR, STACKABLE_TLS_STORE_PASSWORD}, + security::authentication::{ + NifiAuthenticationConfig, STACKABLE_SERVER_TLS_DIR, STACKABLE_TLS_STORE_PASSWORD, + }, }; pub const NIFI_CONFIG_DIRECTORY: &str = "/stackable/nifi/conf"; @@ -65,23 +70,34 @@ impl NifiRepository { #[derive(Snafu, Debug)] pub enum Error { - #[snafu(display("Invalid product config"))] + #[snafu(display("invalid product config"))] InvalidProductConfig { source: stackable_operator::product_config_utils::Error, }, - #[snafu(display("Invalid memory config"))] + + #[snafu(display("invalid memory config"))] InvalidMemoryConfig { source: stackable_operator::memory::Error, }, - #[snafu(display("Failed to transform product configs"))] + + #[snafu(display("failed to transform product configs"))] ProductConfigTransform { source: stackable_operator::product_config_utils::Error, }, + #[snafu(display("failed to calculate storage quota for {repo} repository"))] CalculateStorageQuota { source: stackable_operator::memory::Error, repo: NifiRepository, }, + + #[snafu(display("invalid OIDC endpoint URL"))] + InvalidOidcEndpoint { + source: stackable_operator::commons::authentication::oidc::Error, + }, + + #[snafu(display("failed to build the OIDC wellkown path"))] + InvalidOidcWellknownPath { source: url::ParseError }, } /// Create the NiFi bootstrap.conf @@ -178,6 +194,7 @@ pub fn build_nifi_properties( spec: &NifiSpec, resource_config: &Resources, proxy_hosts: &str, + auth_config: &NifiAuthenticationConfig, overrides: BTreeMap, ) -> Result { let mut properties = BTreeMap::new(); @@ -561,6 +578,45 @@ pub fn build_nifi_properties( "nifi.cluster.protocol.is.secure".to_string(), "true".to_string(), ); + + // OIDC config + if let NifiAuthenticationConfig::Oidc { provider, oidc, .. } = auth_config { + let endpoint_url = provider + .endpoint_url() + .context(InvalidOidcEndpointSnafu)? + .join(DEFAULT_OIDC_WELLKNOWN_PATH) + .context(InvalidOidcWellknownPathSnafu)?; + properties.insert( + "nifi.security.user.oidc.discovery.url".to_string(), + endpoint_url.to_string(), + ); + let (oidc_client_id_env, oidc_client_secret_env) = + AuthenticationProvider::client_credentials_env_names( + &oidc.client_credentials_secret_ref, + ); + properties.insert( + "nifi.security.user.oidc.client.id".to_string(), + format!("${{env:{oidc_client_id_env}}}").to_string(), + ); + properties.insert( + "nifi.security.user.oidc.client.secret".to_string(), + format!("${{env:{oidc_client_secret_env}}}").to_string(), + ); + let scopes = provider.scopes.join(","); + properties.insert( + "nifi.security.user.oidc.additional.scopes".to_string(), + scopes.to_string(), + ); + properties.insert( + "nifi.security.user.oidc.claim.identifying.user".to_string(), + provider.principal_claim.to_string(), + ); + properties.insert( + "nifi.security.user.oidc.truststore.strategy".to_string(), + "NIFI".to_string(), + ); + } + // cluster node properties (only configure for cluster nodes) properties.insert("nifi.cluster.is.node".to_string(), "true".to_string()); properties.insert( @@ -580,12 +636,14 @@ pub fn build_nifi_properties( "nifi.cluster.flow.election.max.candidates".to_string(), "".to_string(), ); + // zookeeper properties, used for cluster management // this will be replaced via a container command script properties.insert( "nifi.zookeeper.connect.string".to_string(), "${env:ZOOKEEPER_HOSTS}".to_string(), ); + // this will be replaced via a container command script properties.insert( "nifi.zookeeper.root.node".to_string(), diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index bae0648a..bc9ba193 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -14,7 +14,7 @@ use product_config::{ }; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_nifi_crd::{ - authentication::resolve_authentication_classes, Container, CurrentlySupportedListenerClasses, + authentication::AuthenticationClassResolved, Container, CurrentlySupportedListenerClasses, NifiCluster, NifiConfig, NifiConfigFragment, NifiRole, NifiStatus, APP_NAME, BALANCE_PORT, BALANCE_PORT_NAME, HTTPS_PORT, HTTPS_PORT_NAME, MAX_NIFI_LOG_FILES_SIZE, MAX_PREPARE_LOG_FILE_SIZE, METRICS_PORT, METRICS_PORT_NAME, PROTOCOL_PORT, PROTOCOL_PORT_NAME, @@ -31,7 +31,10 @@ use stackable_operator::{ }, client::Client, cluster_resources::{ClusterResourceApplyStrategy, ClusterResources}, - commons::{product_image_selection::ResolvedProductImage, rbac::build_rbac_resources}, + commons::{ + authentication::oidc::AuthenticationProvider, + product_image_selection::ResolvedProductImage, rbac::build_rbac_resources, + }, config::fragment, k8s_openapi::{ api::{ @@ -46,11 +49,11 @@ use stackable_operator::{ DeepMerge, }, kube::{ - api::ListParams, runtime::controller::Action, runtime::reflector::ObjectRef, Resource, - ResourceExt, + api::ListParams, + runtime::{controller::Action, reflector::ObjectRef}, + Resource, ResourceExt, }, - kvp::Labels, - kvp::{Label, ObjectLabels}, + kvp::{Label, Labels, ObjectLabels}, logging::controller::ReconcilerError, product_logging::{ self, @@ -86,7 +89,7 @@ use crate::{ LOGIN_IDENTITY_PROVIDERS_XML_FILE_NAME, STACKABLE_SERVER_TLS_DIR, STACKABLE_TLS_STORE_PASSWORD, }, - build_tls_volume, check_or_generate_sensitive_key, + build_tls_volume, check_or_generate_oidc_admin_password, check_or_generate_sensitive_key, tls::{KEYSTORE_NIFI_CONTAINER_MOUNT, KEYSTORE_VOLUME_NAME, TRUSTSTORE_VOLUME_NAME}, }, OPERATOR_NAME, @@ -459,12 +462,18 @@ pub async fn reconcile_nifi(nifi: Arc, ctx: Arc) -> Result { + Self::SingleUser { .. } | Self::Oidc { .. } => { login_identity_provider_xml.push_str(&formatdoc! {r#" login-identity-provider @@ -76,9 +93,9 @@ impl NifiAuthenticationConfig { "#}); } - Self::Ldap(ldap) => { - login_identity_provider_xml.push_str(&get_ldap_login_identity_provider(ldap)?); - authorizers_xml.push_str(&get_ldap_authorizer(ldap)?); + Self::Ldap { provider } => { + login_identity_provider_xml.push_str(&get_ldap_login_identity_provider(provider)?); + authorizers_xml.push_str(&get_ldap_authorizer(provider)?); } } @@ -96,12 +113,12 @@ impl NifiAuthenticationConfig { let mut admin_username_file = String::new(); let mut admin_password_file = String::new(); match &self { - Self::SingleUser(_) => { + Self::SingleUser { .. } | Self::Oidc { .. } => { admin_password_file = format!("{STACKABLE_USER_VOLUME_MOUNT_PATH}/{STACKABLE_ADMIN_USERNAME}"); } - Self::Ldap(ldap) => { - if let Some((user_path, password_path)) = ldap.bind_credentials_mount_paths() { + Self::Ldap { provider } => { + if let Some((user_path, password_path)) = provider.bind_credentials_mount_paths() { admin_username_file = user_path; admin_password_file = password_path; } @@ -113,20 +130,32 @@ impl NifiAuthenticationConfig { pub fn get_additional_container_args(&self) -> Vec { let mut commands = Vec::new(); match &self { - Self::SingleUser(_) => { + Self::SingleUser { .. } => { let (_, admin_password_file) = self.get_user_and_password_file_paths(); commands.extend(vec![ format!("export STACKABLE_ADMIN_PASSWORD=\"$(cat {admin_password_file} | java -jar /bin/stackable-bcrypt.jar)\""), ]); } - Self::Ldap(ldap) => { - if let Some(ca_path) = ldap.tls.tls_ca_cert_mount_path() { + Self::Ldap { provider } => { + if let Some(ca_path) = provider.tls.tls_ca_cert_mount_path() { commands.extend(vec![ "echo Adding LDAP tls cert to global truststore".to_string(), format!("keytool -importcert -file {ca_path} -keystore {STACKABLE_SERVER_TLS_DIR}/truststore.p12 -storetype pkcs12 -noprompt -alias ldap_ca_cert -storepass {STACKABLE_TLS_STORE_PASSWORD}"), ]); } } + Self::Oidc { provider, .. } => { + let (_, admin_password_file) = self.get_user_and_password_file_paths(); + commands.extend(vec![ + format!("export STACKABLE_ADMIN_PASSWORD=\"$(cat {admin_password_file} | java -jar /bin/stackable-bcrypt.jar)\""), + ]); + if let Some(ca_path) = provider.tls.tls_ca_cert_mount_path() { + commands.extend(vec![ + "echo Adding OIDC tls cert to global truststore".to_string(), + format!("keytool -importcert -file {ca_path} -keystore {STACKABLE_SERVER_TLS_DIR}/truststore.p12 -storetype pkcs12 -noprompt -alias oidc_ca_cert -storepass {STACKABLE_TLS_STORE_PASSWORD}"), + ]); + } + } } commands } @@ -136,10 +165,10 @@ impl NifiAuthenticationConfig { pub fn add_volumes_and_mounts( &self, pod_builder: &mut PodBuilder, - container_builders: Vec<&mut ContainerBuilder>, + mut container_builders: Vec<&mut ContainerBuilder>, ) -> Result<(), Error> { match &self { - Self::SingleUser(provider) => { + Self::SingleUser { provider } => { let admin_volume = Volume { name: STACKABLE_ADMIN_USERNAME.to_string(), secret: Some(SecretVolumeSource { @@ -160,35 +189,68 @@ impl NifiAuthenticationConfig { cb.add_volume_mount(STACKABLE_ADMIN_USERNAME, STACKABLE_USER_VOLUME_MOUNT_PATH); } } - Self::Ldap(ldap) => { - ldap.add_volumes_and_mounts(pod_builder, container_builders) + Self::Ldap { provider } => { + provider + .add_volumes_and_mounts(pod_builder, container_builders) .context(AddLdapVolumesSnafu)?; } + Self::Oidc { provider, nifi, .. } => { + let admin_volume = Volume { + name: STACKABLE_ADMIN_USERNAME.to_string(), + secret: Some(SecretVolumeSource { + secret_name: Some(build_oidc_admin_password_secret_name(nifi)), + optional: Some(false), + items: Some(vec![KeyToPath { + key: STACKABLE_ADMIN_USERNAME.to_string(), + path: STACKABLE_ADMIN_USERNAME.to_string(), + ..KeyToPath::default() + }]), + ..SecretVolumeSource::default() + }), + ..Volume::default() + }; + pod_builder.add_volume(admin_volume); + + container_builders.iter_mut().for_each(|cb| { + cb.add_volume_mount(STACKABLE_ADMIN_USERNAME, STACKABLE_USER_VOLUME_MOUNT_PATH); + }); + + provider + .tls + .add_volumes_and_mounts(pod_builder, container_builders) + .context(AddOidcVolumesSnafu)?; + } } Ok(()) } - pub fn try_from(auth_classes: Vec) -> Result { + pub fn try_from( + auth_classes_resolved: Vec, + ) -> Result { // Currently only one auth mechanism is supported in NiFi. This is checked in // rust/crd/src/authentication.rs and just a fail-safe here. For Future changes, // this is not just a "from" without error handling - let auth_class = auth_classes + let auth_class_resolved = auth_classes_resolved .first() .context(SingleAuthenticationMechanismSupportedSnafu)?; - match &auth_class.spec.provider { - AuthenticationClassProvider::Static(static_provider) => { - Ok(Self::SingleUser(static_provider.clone())) - } - AuthenticationClassProvider::Ldap(ldap_provider) => { - Ok(Self::Ldap(ldap_provider.clone())) - } - AuthenticationClassProvider::Tls(_) | AuthenticationClassProvider::Oidc(_) => { - Err(Error::AuthenticationClassProviderNotSupported { - authentication_class_provider: auth_class.spec.provider.to_string(), - }) - } + match &auth_class_resolved { + AuthenticationClassResolved::Static { provider } => Ok(Self::SingleUser { + provider: provider.clone(), + }), + AuthenticationClassResolved::Ldap { provider } => Ok(Self::Ldap { + provider: provider.clone(), + }), + AuthenticationClassResolved::Oidc { + provider, + oidc, + nifi, + } => Ok(Self::Oidc { + provider: provider.clone(), + oidc: oidc.clone(), + nifi: nifi.clone(), + }), } } } diff --git a/rust/operator-binary/src/security/mod.rs b/rust/operator-binary/src/security/mod.rs index 5944e198..11090bd7 100644 --- a/rust/operator-binary/src/security/mod.rs +++ b/rust/operator-binary/src/security/mod.rs @@ -4,6 +4,7 @@ use stackable_operator::client::Client; use stackable_operator::{builder::pod::volume::SecretFormat, k8s_openapi::api::core::v1::Volume}; pub mod authentication; +pub mod oidc; pub mod sensitive_key; pub mod tls; @@ -16,6 +17,9 @@ pub enum Error { #[snafu(display("sensistive key failure"))] SensitiveKey { source: sensitive_key::Error }, + + #[snafu(display("failed to ensure OIDC admin password exists"))] + OidcAdminPassword { source: oidc::Error }, } pub async fn check_or_generate_sensitive_key(client: &Client, nifi: &NifiCluster) -> Result { @@ -24,6 +28,15 @@ pub async fn check_or_generate_sensitive_key(client: &Client, nifi: &NifiCluster .context(SensitiveKeySnafu) } +pub async fn check_or_generate_oidc_admin_password( + client: &Client, + nifi: &NifiCluster, +) -> Result { + oidc::check_or_generate_oidc_admin_password(client, nifi) + .await + .context(OidcAdminPasswordSnafu) +} + pub fn build_tls_volume( nifi: &NifiCluster, volume_name: &str, diff --git a/rust/operator-binary/src/security/oidc.rs b/rust/operator-binary/src/security/oidc.rs new file mode 100644 index 00000000..227d2877 --- /dev/null +++ b/rust/operator-binary/src/security/oidc.rs @@ -0,0 +1,94 @@ +use std::collections::{BTreeMap, HashSet}; + +use rand::{distributions::Alphanumeric, Rng}; +use snafu::{OptionExt, ResultExt, Snafu}; +use stackable_nifi_crd::NifiCluster; +use stackable_operator::{ + builder::meta::ObjectMetaBuilder, client::Client, k8s_openapi::api::core::v1::Secret, + kube::ResourceExt, +}; + +use super::authentication::STACKABLE_ADMIN_USERNAME; + +const STACKABLE_OIDC_ADMIN_PASSWORD_KEY: &str = STACKABLE_ADMIN_USERNAME; + +type Result = std::result::Result; + +#[derive(Snafu, Debug)] +pub enum Error { + #[snafu(display("the NiFi object defines no namespace"))] + ObjectHasNoNamespace, + + #[snafu(display("failed to fetch or create OIDC admin password secret"))] + OidcAdminPasswordSecret { + source: stackable_operator::client::Error, + }, + + #[snafu(display( + "found existing secret [{}/{}], but key {} is missing", + name, + namespace, + STACKABLE_OIDC_ADMIN_PASSWORD_KEY + ))] + OidcAdminPasswordKeyMissing { name: String, namespace: String }, +} + +/// Generate a secret containing the password for the admin user that can access the API. This admin user is the same as for SingleUser authentication. +pub(crate) async fn check_or_generate_oidc_admin_password( + client: &Client, + nifi: &NifiCluster, +) -> Result { + let namespace: &str = &nifi.namespace().context(ObjectHasNoNamespaceSnafu)?; + tracing::debug!("Checking for OIDC admin password configuration"); + match client + .get_opt::(&build_oidc_admin_password_secret_name(nifi), namespace) + .await + .context(OidcAdminPasswordSecretSnafu)? + { + Some(secret) => { + let keys = secret + .data + .unwrap_or_default() + .into_keys() + .collect::>(); + if keys.contains(STACKABLE_OIDC_ADMIN_PASSWORD_KEY) { + Ok(false) + } else { + OidcAdminPasswordKeyMissingSnafu { + name: build_oidc_admin_password_secret_name(nifi), + namespace, + } + .fail()? + } + } + None => { + tracing::info!("No existing oidc admin password secret found, generating new one"); + let password: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(15) + .map(char::from) + .collect(); + + let mut secret_data = BTreeMap::new(); + secret_data.insert("admin".to_string(), password); + + let new_secret = Secret { + metadata: ObjectMetaBuilder::new() + .namespace(namespace) + .name(build_oidc_admin_password_secret_name(nifi)) + .build(), + string_data: Some(secret_data), + ..Secret::default() + }; + client + .create(&new_secret) + .await + .context(OidcAdminPasswordSecretSnafu)?; + Ok(true) + } + } +} + +pub fn build_oidc_admin_password_secret_name(nifi: &NifiCluster) -> String { + format!("{}-oidc-admin-password", nifi.name_any()) +} diff --git a/tests/templates/kuttl/oidc/00-assert.yaml.j2 b/tests/templates/kuttl/oidc/00-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/oidc/00-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/oidc/00-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/oidc/00-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/oidc/00-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/oidc/00-patch-ns.yaml.j2 b/tests/templates/kuttl/oidc/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/oidc/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/oidc/01-assert.yaml b/tests/templates/kuttl/oidc/01-assert.yaml new file mode 100644 index 00000000..5f3fae52 --- /dev/null +++ b/tests/templates/kuttl/oidc/01-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-keycloak +timeout: 480 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/01-install-keycloak.yaml b/tests/templates/kuttl/oidc/01-install-keycloak.yaml new file mode 100644 index 00000000..4e07a328 --- /dev/null +++ b/tests/templates/kuttl/oidc/01-install-keycloak.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + INSTANCE_NAME=keycloak \ + REALM=test \ + USERNAME=jane.doe \ + FIRST_NAME=Jane \ + LAST_NAME=Doe \ + EMAIL=jane.doe@stackable.tech \ + PASSWORD=T8mn72D9 \ + CLIENT_ID=nifi \ + CLIENT_SECRET=R1bxHUD569vHeQdw \ + envsubst < 01_keycloak.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 b/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 new file mode 100644 index 00000000..1331f2ac --- /dev/null +++ b/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 @@ -0,0 +1,159 @@ +# The environment variables must be replaced. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: $INSTANCE_NAME-realms +data: + test-realm.json: | + { + "realm": "$REALM", + "enabled": true, + "attributes": { + "frontendUrl": "keycloak.$NAMESPACE.svc.cluster.local" + }, + "users": [ + { + "enabled": true, + "username": "$USERNAME", + "firstName" : "$FIRST_NAME", + "lastName" : "$LAST_NAME", + "email" : "$EMAIL", + "credentials": [ + { + "type": "password", + "value": "$PASSWORD" + } + ], + "realmRoles": [ + "user" + ] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + } + ] + }, + "clients": [ + { + "clientId": "$CLIENT_ID", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "$CLIENT_SECRET", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "standardFlowEnabled": true, + "protocol": "openid-connect" + } + ] + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: $INSTANCE_NAME + labels: + app: $INSTANCE_NAME +spec: + replicas: 1 + selector: + matchLabels: + app: $INSTANCE_NAME + template: + metadata: + labels: + app: $INSTANCE_NAME + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:23.0.4 + args: + - start-dev + - --import-realm +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + - --https-certificate-file=/tls/tls.crt + - --https-certificate-key-file=/tls/tls.key +{% endif %} + env: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + ports: +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + - name: https + containerPort: 8443 +{% else %} + - name: http + containerPort: 8080 +{% endif %} + volumeMounts: + - name: realms + mountPath: /opt/keycloak/data/import + - name: tls + mountPath: /tls + readinessProbe: + httpGet: + path: /realms/$REALM +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + port: 8443 + scheme: HTTPS +{% else %} + port: 8080 + scheme: HTTP +{% endif %} + volumes: + - name: realms + configMap: + name: $INSTANCE_NAME-realms + - ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: keycloak-tls-$NAMESPACE + secrets.stackable.tech/scope: service=$INSTANCE_NAME + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" + storageClassName: secrets.stackable.tech + volumeMode: Filesystem + name: tls +--- +apiVersion: v1 +kind: Service +metadata: + name: $INSTANCE_NAME +spec: + selector: + app: $INSTANCE_NAME + ports: + - protocol: TCP +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + port: 8443 +{% else %} + port: 8080 +{% endif %} +--- +apiVersion: secrets.stackable.tech/v1alpha1 +kind: SecretClass +metadata: + name: keycloak-tls-$NAMESPACE +spec: + backend: + autoTls: + ca: + autoGenerate: true + secret: + name: keycloak-tls-ca + namespace: $NAMESPACE diff --git a/tests/templates/kuttl/oidc/10-assert.yaml b/tests/templates/kuttl/oidc/10-assert.yaml new file mode 100644 index 00000000..e0766c49 --- /dev/null +++ b/tests/templates/kuttl/oidc/10-assert.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: test-zk-server-default +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/10-install-zk.yaml.j2 b/tests/templates/kuttl/oidc/10-install-zk.yaml.j2 new file mode 100644 index 00000000..8eb3bbd5 --- /dev/null +++ b/tests/templates/kuttl/oidc/10-install-zk.yaml.j2 @@ -0,0 +1,28 @@ +--- +apiVersion: zookeeper.stackable.tech/v1alpha1 +kind: ZookeeperCluster +metadata: + name: test-zk +spec: + image: + productVersion: "{{ test_scenario['values']['zookeeper-latest'] }}" + pullPolicy: IfNotPresent +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + clusterConfig: + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + servers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 +--- +apiVersion: zookeeper.stackable.tech/v1alpha1 +kind: ZookeeperZnode +metadata: + name: nifi-with-oidc-znode +spec: + clusterRef: + name: test-zk diff --git a/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 b/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 new file mode 100644 index 00000000..6efd6383 --- /dev/null +++ b/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + # We need to replace $NAMESPACE (by KUTTL) in the create-authentication-classes.yaml(.j2) + - script: envsubst < 11_authentication-classes.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/11_authentication-classes.yaml.j2 b/tests/templates/kuttl/oidc/11_authentication-classes.yaml.j2 new file mode 100644 index 00000000..8b0afe51 --- /dev/null +++ b/tests/templates/kuttl/oidc/11_authentication-classes.yaml.j2 @@ -0,0 +1,26 @@ +--- +apiVersion: authentication.stackable.tech/v1alpha1 +kind: AuthenticationClass +metadata: + name: nifi-oidc-auth-class-$NAMESPACE +spec: + provider: + oidc: + hostname: keycloak.$NAMESPACE.svc.cluster.local + rootPath: /realms/test/ + principalClaim: preferred_username + scopes: + - openid + - email + - profile +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + port: 8443 + tls: + verification: + server: + caCert: + secretClass: keycloak-tls-$NAMESPACE +{% else %} + port: 8080 + tls: null +{% endif %} diff --git a/tests/templates/kuttl/oidc/12-assert.yaml b/tests/templates/kuttl/oidc/12-assert.yaml new file mode 100644 index 00000000..2f03b6b1 --- /dev/null +++ b/tests/templates/kuttl/oidc/12-assert.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 1200 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: test-nifi-node-default +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/12-install-nifi.yaml b/tests/templates/kuttl/oidc/12-install-nifi.yaml new file mode 100644 index 00000000..edef731d --- /dev/null +++ b/tests/templates/kuttl/oidc/12-install-nifi.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: envsubst < 12_nifi.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/12_nifi.yaml.j2 b/tests/templates/kuttl/oidc/12_nifi.yaml.j2 new file mode 100644 index 00000000..957bbbf1 --- /dev/null +++ b/tests/templates/kuttl/oidc/12_nifi.yaml.j2 @@ -0,0 +1,50 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: nifi-sensitive-property-key +stringData: + nifiSensitivePropsKey: mYsUp3rS3cr3tk3y +--- +apiVersion: v1 +kind: Secret +metadata: + name: nifi-oidc-client +stringData: + clientId: nifi + clientSecret: R1bxHUD569vHeQdw +--- +apiVersion: nifi.stackable.tech/v1alpha1 +kind: NifiCluster +metadata: + name: test-nifi +spec: + image: +{% if test_scenario['values']['nifi'].find(",") > 0 %} + custom: "{{ test_scenario['values']['nifi'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['nifi'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['nifi'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + authentication: + - authenticationClass: nifi-oidc-auth-class-$NAMESPACE + oidc: + clientCredentialsSecret: nifi-oidc-client + sensitiveProperties: + keySecret: nifi-sensitive-property-key +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + zookeeperConfigMapName: nifi-with-oidc-znode + listenerClass: external-unstable + nodes: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + gracefulShutdownTimeout: 1s # let the tests run faster + roleGroups: + default: + config: {} + replicas: 1 diff --git a/tests/templates/kuttl/oidc/20-assert.yaml b/tests/templates/kuttl/oidc/20-assert.yaml new file mode 100644 index 00000000..ce21f771 --- /dev/null +++ b/tests/templates/kuttl/oidc/20-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: oidc-login-test +status: + ready: 1 # wait for the test job to start before streaming its logs in the next test step diff --git a/tests/templates/kuttl/oidc/20-login-test.yaml.j2 b/tests/templates/kuttl/oidc/20-login-test.yaml.j2 new file mode 100644 index 00000000..782711f8 --- /dev/null +++ b/tests/templates/kuttl/oidc/20-login-test.yaml.j2 @@ -0,0 +1,37 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: oidc-login-test-script +commands: + - script: kubectl create configmap oidc-login-test-script --from-file login.py -n $NAMESPACE +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: oidc-login-test +spec: + template: + spec: + containers: + - name: oidc-login-test + image: docker.stackable.tech/stackable/testing-tools:0.2.0-stackable0.0.0-dev + command: ["python", "/tmp/test-script/login.py"] + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NIFI_VERSION + value: "{{ test_scenario['values']['nifi'] }}" + - name: OIDC_USE_TLS + value: "{{ test_scenario['values']['oidc-use-tls'] }}" + volumeMounts: + - name: test-script + mountPath: /tmp/test-script + restartPolicy: OnFailure + terminationGracePeriodSeconds: 1 + volumes: + - name: test-script + configMap: + name: oidc-login-test-script diff --git a/tests/templates/kuttl/oidc/21-assert.yaml b/tests/templates/kuttl/oidc/21-assert.yaml new file mode 100644 index 00000000..f55ee23d --- /dev/null +++ b/tests/templates/kuttl/oidc/21-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 30 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: oidc-login-test +status: + succeeded: 1 diff --git a/tests/templates/kuttl/oidc/21-login-test-logs.yaml b/tests/templates/kuttl/oidc/21-login-test-logs.yaml new file mode 100644 index 00000000..092debe4 --- /dev/null +++ b/tests/templates/kuttl/oidc/21-login-test-logs.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: oidc-login-test-logs +commands: + - script: kubectl logs job/oidc-login-test -n $NAMESPACE -f diff --git a/tests/templates/kuttl/oidc/login.py b/tests/templates/kuttl/oidc/login.py new file mode 100644 index 00000000..2ab1a54e --- /dev/null +++ b/tests/templates/kuttl/oidc/login.py @@ -0,0 +1,62 @@ +import logging +import os +import requests +import sys +import json +from bs4 import BeautifulSoup + +logging.basicConfig( + level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout +) + +namespace = os.environ["NAMESPACE"] +tls = os.environ["OIDC_USE_TLS"] +nifi_version = os.environ["NIFI_VERSION"] + +session = requests.Session() + +nifi = f"test-nifi-node-default-0.test-nifi-node-default.{namespace}.svc.cluster.local" +keycloak_service = f"keycloak.{namespace}.svc.cluster.local" + +keycloak_base_url = ( + f"https://{keycloak_service}:8443" + if tls == "true" + else f"http://{keycloak_service}:8080" +) + +if nifi_version in ["2.0.0-M4"]: + auth_config_page = session.get( + f"https://{nifi}:8443/nifi-api/authentication/configuration", + verify=False, + headers={"Content-type": "application/json"} + ) + assert auth_config_page.ok, "Could not fetch auth config from NiFi" + auth_config = json.loads(auth_config_page.text) + login_url = auth_config["authenticationConfiguration"]["loginUri"] +else: + login_url = f"https://{nifi}:8443/nifi/login" + +# Open NiFi web UI which will redirect to OIDC login +login_page = session.get( + login_url, + verify=False, + headers={"Content-type": "application/json"}, +) + +print("actual: ", login_page.url) +print("expected: ", f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=") +assert login_page.ok, "Redirection from NiFi to Keycloak failed" +assert login_page.url.startswith( + f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=" +), "Redirection to Keycloak expected" + +# Login to keycloak with test user +login_page_html = BeautifulSoup(login_page.text, "html.parser") +authenticate_url = login_page_html.form["action"] +welcome_page = session.post( + authenticate_url, data={"username": "jane.doe", "password": "T8mn72D9"}, verify=False +) +assert welcome_page.ok, "Login failed" +assert ( + welcome_page.url == f"https://{nifi}:8443/nifi/" +), "Redirection to the NiFi web UI expected" diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 005b19ca..6d7ff869 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -36,6 +36,10 @@ dimensions: values: - "false" - "true" + - name: oidc-use-tls + values: + - "false" + - "true" - name: openshift values: - "false" @@ -83,6 +87,12 @@ tests: - nifi-latest - zookeeper-latest - openshift + - name: oidc + dimensions: + - nifi + - zookeeper-latest + - oidc-use-tls + - openshift suites: - name: nightly patch: @@ -93,6 +103,8 @@ suites: expr: last - name: ldap-use-tls expr: "true" + - name: oidc-use-tls + expr: "true" - name: smoke-latest select: - smoke @@ -112,3 +124,5 @@ suites: expr: last - name: ldap-use-tls expr: "true" + - name: oidc-use-tls + expr: "true"