diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c95c09..68f9cbac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Use the env var `KUBERNETES_CLUSTER_DOMAIN` or the operator Helm chart property `kubernetesClusterDomain` to set a non-default cluster domain ([#518]). - Support for `2.9.3` ([#494]). - Experimental Support for `2.10.2` ([#512]). +- Add support for OpenID Connect ([#524]) ### Changed @@ -30,6 +31,7 @@ [#494]: https://github.com/stackabletech/airflow-operator/pull/494 [#518]: https://github.com/stackabletech/airflow-operator/pull/518 [#520]: https://github.com/stackabletech/airflow-operator/pull/520 +[#524]: https://github.com/stackabletech/airflow-operator/pull/524 ## [24.7.0] - 2024-07-24 diff --git a/Cargo.lock b/Cargo.lock index 2f3bfa6e..247ba76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1037,6 +1037,12 @@ dependencies = [ "hashbrown 0.15.0", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "instant" version = "0.1.13" @@ -2168,6 +2174,7 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" name = "stackable-airflow-crd" version = "0.0.0-dev" dependencies = [ + "indoc", "product-config", "rstest", "serde", @@ -2176,6 +2183,7 @@ dependencies = [ "snafu 0.8.5", "stackable-operator", "strum", + "tokio", "tracing", ] @@ -2188,6 +2196,7 @@ dependencies = [ "clap", "fnv", "futures 0.3.31", + "indoc", "product-config", "serde", "serde_yaml", diff --git a/Cargo.nix b/Cargo.nix index b194212d..afc575fd 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -3077,6 +3077,17 @@ rec { }; resolvedDefaultFeatures = [ "default" "std" ]; }; + "indoc" = rec { + crateName = "indoc"; + version = "2.0.5"; + edition = "2021"; + sha256 = "1dgjk49rkmx4kjy07k4b90qb5vl89smgb5rcw02n0q0x9ligaj5j"; + procMacro = true; + authors = [ + "David Tolnay " + ]; + + }; "instant" = rec { crateName = "instant"; version = "0.1.13"; @@ -6675,6 +6686,10 @@ rec { "Stackable GmbH " ]; dependencies = [ + { + name = "indoc"; + packageId = "indoc"; + } { name = "product-config"; packageId = "product-config"; @@ -6701,6 +6716,11 @@ rec { packageId = "strum"; features = [ "derive" ]; } + { + name = "tokio"; + packageId = "tokio"; + features = [ "full" ]; + } { name = "tracing"; packageId = "tracing"; @@ -6751,6 +6771,10 @@ rec { packageId = "futures 0.3.31"; features = [ "compat" ]; } + { + name = "indoc"; + packageId = "indoc"; + } { name = "product-config"; packageId = "product-config"; diff --git a/Cargo.toml b/Cargo.toml index be77133e..38123e38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ built = { version = "0.7", features = ["chrono", "git2"] } clap = "4.5" fnv = "1.0" futures = { version = "0.3", features = ["compat"] } +indoc = "2.0" product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.7.0" } rstest = "0.23" semver = "1.0" diff --git a/deploy/helm/airflow-operator/crds/crds.yaml b/deploy/helm/airflow-operator/crds/crds.yaml index 32f98214..afa8785e 100644 --- a/deploy/helm/airflow-operator/crds/crds.yaml +++ b/deploy/helm/airflow-operator/crds/crds.yaml @@ -452,13 +452,27 @@ spec: properties: authentication: default: [] - description: The Airflow [authentication](https://docs.stackable.tech/home/nightly/airflow/usage-guide/security.html) settings. Currently the underlying Flask App Builder only supports one authentication mechanism at a time. This means the operator will error out if multiple references to an AuthenticationClass are provided. items: properties: authenticationClass: - description: Name of the [AuthenticationClass](https://docs.stackable.tech/home/nightly/concepts/authentication.html#authenticationclass) used to authenticate the users. At the moment only LDAP is supported. If not specified the default authentication (AUTH_DB) will be used. - nullable: true + description: Name of the [AuthenticationClass](https://docs.stackable.tech/home/nightly/concepts/authentication) used to authenticate users. type: string + oidc: + description: This field contains OIDC-specific configuration. It is only required in case OIDC is used. + 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 syncRolesAt: default: Registration description: If we should replace ALL the user's roles each login, or only on registration. Gets mapped to `AUTH_ROLES_SYNC_AT_LOGIN` @@ -474,6 +488,8 @@ spec: default: Public description: This role will be given in addition to any AUTH_ROLES_MAPPING. Gets mapped to `AUTH_USER_REGISTRATION_ROLE` type: string + required: + - authenticationClass type: object type: array credentialsSecret: diff --git a/docs/modules/airflow/pages/usage-guide/security.adoc b/docs/modules/airflow/pages/usage-guide/security.adoc index 3c8fea46..e09a5be2 100644 --- a/docs/modules/airflow/pages/usage-guide/security.adoc +++ b/docs/modules/airflow/pages/usage-guide/security.adoc @@ -1,14 +1,21 @@ = Security -:description: Secure Apache Airflow by configuring user authentication and authorization, either with built-in methods or LDAP. +:description: Secure Apache Airflow by configuring user authentication and authorization. :airflow-access-control-docs: https://airflow.apache.org/docs/apache-airflow/stable/security/access-control.html +:keycloak: https://www.keycloak.org/ Secure Apache Airflow by configuring user authentication and authorization. -Airflow provides built-in user and role management, but can also connect to a LDAP server to manage users centrally instead. +Airflow provides built-in user and role management, but can also connect to an LDAP server or an OIDC provider to manage users centrally instead. == Authentication -Users need to authenticate themselves before using Airflow, and there are two ways to configure users: -The built-in user management or LDAP. +Users need to authenticate themselves before using Airflow, and there are several ways in which this can be set up. + +[IMPORTANT] +.Multiple authentication methods +==== +Only one authentication method is supported at a time, and in case of LDAP, only one authentication class is allowed. +This means, it is not possible to configure both LDAP and OIDC authentication methods at the same time, but *it is* possible to configure multiple OIDC classes *or* one LDAP authentication class. +==== === Built-in user management @@ -19,7 +26,7 @@ image::airflow_security.png[Airflow Security menu] === LDAP -Airflow supports xref:concepts:authentication.adoc[user authentication] via LDAP. +Airflow supports xref:concepts:authentication.adoc[user authentication] against a single LDAP server. Set up an AuthenticationClass for the LDAP server and reference it in the Airflow Stacklet resource as shown: [source,yaml] @@ -30,7 +37,7 @@ metadata: name: airflow-with-ldap spec: image: - productVersion: 2.9.3 + productVersion: 2.10.2 clusterConfig: authentication: - authenticationClass: ldap # <1> @@ -48,6 +55,79 @@ The users and roles can be viewed as before in the Webserver UI, but the blue "+ image::airflow_security_ldap.png[Airflow Security menu] +=== [[oidc]]OpenID Connect + +An OpenID Connect provider can be used for authentication. +Unfortunately, there is no generic support for OpenID Connect built into Airflow. +This means that only specific OpenID Connect providers can be configured. + +IMPORTANT: Airflow deployments on the Stackable Data Platform only support {keycloak}[Keycloak]. + +[source,yaml] +---- +apiVersion: airflow.stackable.tech/v1alpha1 +kind: AirflowCluster +metadata: + name: airflow-with-oidc +spec: + image: + productVersion: 2.10.2 + clusterConfig: + authentication: + - authenticationClass: keycloak # <1> + oidc: + clientCredentialsSecret: airflow-keycloak-client # <2> + userRegistrationRole: User # <3> +---- + +<1> The reference to an AuthenticationClass called `keycloak` +<2> The reference to the Secret containing the Airflow client credentials +<3> The default role to which all users are assigned + +Users that log in with OpenID Connect are assigned to a default {airflow-access-control-docs}[role] which is specified with the `userRegistrationRole` property. + +The Secret containing the Airflow client credentials: + +[source,yaml] +---- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-keycloak-client +stringData: + clientId: airflow # <1> + clientSecret: airflow_client_secret # <2> +---- + +<1> The client ID of Airflow as defined in Keycloak +<2> The client secret as defined in Keycloak + +A minimum client configuration in Keycloak for this example looks like this: + +[source,json] +---- +{ + "clientId": "airflow", + "enabled": true, + "clientAuthenticatorType": "client-secret", # <1> + "secret": "airflow_client_secret", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "standardFlowEnabled": true, # <2> + "protocol": "openid-connect" # <3> +} +---- + +<1> Sets the OIDC type to confidential access type. +<2> Enables the OAuth2 "Authorization Code Flow". +<3> Enables OpenID Connect and OAuth2 support. + +Further information for specifying an AuthenticationClass for an OIDC provider can be found at the xref:concepts:authentication.adoc#_oidc[concepts page]. + == Authorization The Airflow Webserver delegates the {airflow-access-control-docs}[handling of user access control] to https://flask-appbuilder.readthedocs.io/en/latest/security.html[Flask AppBuilder]. @@ -74,3 +154,26 @@ spec: <1> The reference to an AuthenticationClass called `ldap` <2> All users are assigned to the `Admin` role + +=== OpenID Connect + +The mechanism for assigning roles to users described in the LDAP section also applies to OpenID Connect. +Airflow supports assigning {airflow-access-control-docs}[Roles] to users based on their OpenID Connect scopes, though this is not yet supported by the Stackable operator. +All the users logging in via OpenID Connect get assigned to the same role which you can configure via the attribute `authentication[*].userRegistrationRole` on the `AirflowCluster` object: + +[source,yaml] +---- +apiVersion: airflow.stackable.tech/v1alpha1 +kind: AirflowCluster +metadata: + name: airflow-with-oidc +spec: + clusterConfig: + authentication: + - authenticationClass: keycloak + oidc: + clientCredentialsSecret: airflow-keycloak-client + userRegistrationRole: Admin # <1> +---- + +<1> All users are assigned to the `Admin` role diff --git a/rust/crd/Cargo.toml b/rust/crd/Cargo.toml index 4548c22e..42cdff8c 100644 --- a/rust/crd/Cargo.toml +++ b/rust/crd/Cargo.toml @@ -9,12 +9,14 @@ repository.workspace = true publish = false [dependencies] +indoc.workspace = true +product-config.workspace = true serde.workspace = true serde_json.workspace = true snafu.workspace = true stackable-operator.workspace = true -product-config.workspace = true strum.workspace = true +tokio.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/rust/crd/src/authentication.rs b/rust/crd/src/authentication.rs index f401842d..d769f163 100644 --- a/rust/crd/src/authentication.rs +++ b/rust/crd/src/authentication.rs @@ -1,73 +1,105 @@ +use std::{future::Future, mem}; + use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; -use stackable_operator::commons::authentication::AuthenticationClassProvider; +use snafu::{ensure, ResultExt, Snafu}; use stackable_operator::{ client::Client, - commons::authentication::AuthenticationClass, - kube::runtime::reflector::ObjectRef, + commons::authentication::{ + ldap, + oidc::{self, IdentityProviderHint}, + AuthenticationClass, AuthenticationClassProvider, ClientAuthenticationDetails, + }, schemars::{self, JsonSchema}, }; +use std::collections::BTreeSet; +use tracing::info; -const SUPPORTED_AUTHENTICATION_CLASS_PROVIDERS: [&str; 1] = ["LDAP"]; +const SUPPORTED_AUTHENTICATION_CLASS_PROVIDERS: [&str; 2] = ["LDAP", "OIDC"]; +const SUPPORTED_OIDC_PROVIDERS: &[oidc::IdentityProviderHint] = + &[oidc::IdentityProviderHint::Keycloak]; +// The assumed OIDC provider if no hint is given in the AuthClass +pub const DEFAULT_OIDC_PROVIDER: oidc::IdentityProviderHint = oidc::IdentityProviderHint::Keycloak; #[derive(Snafu, Debug)] pub enum Error { - #[snafu(display("Failed to retrieve AuthenticationClass {authentication_class}"))] - AuthenticationClassRetrieval { + #[snafu(display( + "The AuthenticationClass {auth_class_name:?} is referenced several times which is not allowed." + ))] + DuplicateAuthenticationClassReferencesNotAllowed { auth_class_name: String }, + + #[snafu(display("Failed to retrieve AuthenticationClass"))] + AuthenticationClassRetrievalFailed { source: stackable_operator::client::Error, - authentication_class: ObjectRef, }, // TODO: Adapt message if multiple authentication classes are supported simultaneously #[snafu(display("Only one authentication class is currently supported at a time"))] MultipleAuthenticationClassesProvided, #[snafu(display( - "Failed to use authentication provider [{provider}] for authentication class [{authentication_class}] - supported providers: {SUPPORTED_AUTHENTICATION_CLASS_PROVIDERS:?}", + "Failed to use authentication provider [{provider}] for authentication class [{auth_class_name}] - supported providers: {SUPPORTED_AUTHENTICATION_CLASS_PROVIDERS:?}", ))] AuthenticationProviderNotSupported { - authentication_class: ObjectRef, + auth_class_name: String, provider: String, }, + #[snafu(display("Only one authentication type at a time is supported by Airflow, see https://github.com/dpgaspar/Flask-AppBuilder/issues/1924."))] + MultipleAuthenticationTypesNotSupported, + #[snafu(display("Only one LDAP provider at a time is supported by Airflow."))] + MultipleLdapProvidersNotSupported, + #[snafu(display("The OIDC provider {oidc_provider:?} is not yet supported (AuthenticationClass {auth_class_name:?})."))] + OidcProviderNotSupported { + auth_class_name: String, + oidc_provider: String, + }, + #[snafu(display( + "TLS verification cannot be disabled in Airflow (AuthenticationClass {auth_class_name:?})." + ))] + TlsVerificationCannotBeDisabled { auth_class_name: String }, + #[snafu(display( + "The userRegistrationRole settings must not differ between the authentication entries.", + ))] + DifferentUserRegistrationRoleSettingsNotAllowed, + #[snafu(display( + "The userRegistration settings must not differ between the authentication entries.", + ))] + DifferentUserRegistrationSettingsNotAllowed, + #[snafu(display( + "The syncRolesAt settings must not differ between the authentication entries.", + ))] + DifferentSyncRolesAtSettingsNotAllowed, + #[snafu(display("Invalid OIDC configuration"))] + OidcConfigurationInvalid { + source: stackable_operator::commons::authentication::Error, + }, + #[snafu(display( + "{configured:?} is not a supported principalClaim in Airflow for the Keycloak OIDC provider. Please use {supported:?} in the AuthenticationClass {auth_class_name:?}" + ))] + OidcPrincipalClaimNotSupported { + configured: String, + supported: String, + auth_class_name: String, + }, } type Result = std::result::Result; - -/// Resolved counter part for `AirflowAuthenticationConfig`. -pub struct AirflowAuthenticationConfigResolved { - pub authentication_class: Option, - pub user_registration: bool, - pub user_registration_role: String, - pub sync_roles_at: FlaskRolesSyncMoment, -} - #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AirflowAuthentication { - /// The Airflow [authentication](DOCS_BASE_URL_PLACEHOLDER/airflow/usage-guide/security.html) settings. - /// Currently the underlying Flask App Builder only supports one authentication mechanism - /// at a time. This means the operator will error out if multiple references to an - /// AuthenticationClass are provided. - #[serde(default)] - authentication: Vec, -} +pub struct AirflowClientAuthenticationDetails { + #[serde(flatten)] + pub common: ClientAuthenticationDetails<()>, -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AirflowAuthenticationConfig { - /// Name of the [AuthenticationClass](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication.html#authenticationclass) used to authenticate the users. - /// At the moment only LDAP is supported. - /// If not specified the default authentication (AUTH_DB) will be used. - pub authentication_class: Option, /// Allow users who are not already in the FAB DB. /// Gets mapped to `AUTH_USER_REGISTRATION` #[serde(default = "default_user_registration")] pub user_registration: bool, + /// This role will be given in addition to any AUTH_ROLES_MAPPING. /// Gets mapped to `AUTH_USER_REGISTRATION_ROLE` #[serde(default = "default_user_registration_role")] pub user_registration_role: String, + /// If we should replace ALL the user's roles each login, or only on registration. /// Gets mapped to `AUTH_ROLES_SYNC_AT_LOGIN` - #[serde(default = "default_sync_roles_at")] + #[serde(default)] pub sync_roles_at: FlaskRolesSyncMoment, } @@ -84,70 +116,831 @@ pub fn default_sync_roles_at() -> FlaskRolesSyncMoment { FlaskRolesSyncMoment::Registration } -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Default)] pub enum FlaskRolesSyncMoment { + #[default] Registration, Login, } -impl AirflowAuthentication { - pub fn authentication_class_names(&self) -> Vec<&str> { - let mut auth_classes = vec![]; - for config in &self.authentication { - if let Some(auth_config) = &config.authentication_class { - auth_classes.push(auth_config.as_str()); +/// Resolved and validated counter part for `AirflowClientAuthenticationDetails`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AirflowClientAuthenticationDetailsResolved { + pub authentication_classes_resolved: Vec, + pub user_registration: bool, + pub user_registration_role: String, + pub sync_roles_at: FlaskRolesSyncMoment, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AirflowAuthenticationClassResolved { + Ldap { + provider: ldap::AuthenticationProvider, + }, + Oidc { + provider: oidc::AuthenticationProvider, + oidc: oidc::ClientAuthenticationOptions<()>, + }, +} + +impl AirflowClientAuthenticationDetailsResolved { + pub async fn from( + auth_details: &[AirflowClientAuthenticationDetails], + client: &Client, + ) -> Result { + let resolve_auth_class = |auth_details: ClientAuthenticationDetails| async move { + auth_details.resolve_class(client).await + }; + AirflowClientAuthenticationDetailsResolved::resolve(auth_details, resolve_auth_class).await + } + pub async fn resolve( + auth_details: &[AirflowClientAuthenticationDetails], + resolve_auth_class: impl Fn(ClientAuthenticationDetails) -> R, + ) -> Result + where + R: Future>, + { + let mut resolved_auth_classes: Vec = Vec::new(); + let mut user_registration = None; + let mut user_registration_role = None; + let mut sync_roles_at = None; + + let mut auth_class_names = BTreeSet::new(); + + for entry in auth_details { + let auth_class_name = entry.common.authentication_class_name(); + + let is_new_auth_class = auth_class_names.insert(auth_class_name); + ensure!( + is_new_auth_class, + DuplicateAuthenticationClassReferencesNotAllowedSnafu { auth_class_name } + ); + + let auth_class = resolve_auth_class(entry.common.clone()) + .await + .context(AuthenticationClassRetrievalFailedSnafu)?; + + match &auth_class.spec.provider { + AuthenticationClassProvider::Ldap(provider) => { + let resolved_auth_class = AirflowAuthenticationClassResolved::Ldap { + provider: provider.to_owned(), + }; + if let Some(other) = resolved_auth_classes.first() { + ensure!( + mem::discriminant(other) == mem::discriminant(&resolved_auth_class), + MultipleAuthenticationTypesNotSupportedSnafu + ); + } + + ensure!( + resolved_auth_classes.is_empty(), + MultipleLdapProvidersNotSupportedSnafu + ); + + resolved_auth_classes.push(resolved_auth_class); + } + AuthenticationClassProvider::Oidc(provider) => { + let resolved_auth_class = + AirflowClientAuthenticationDetailsResolved::from_oidc( + auth_class_name, + provider, + entry, + )?; + + if let Some(other) = resolved_auth_classes.first() { + ensure!( + mem::discriminant(other) == mem::discriminant(&resolved_auth_class), + MultipleAuthenticationTypesNotSupportedSnafu + ); + } + resolved_auth_classes.push(resolved_auth_class); + //`&Static(_)`, `&Tls(_)` and `&Kerberos(_)` not covered + } + AuthenticationClassProvider::Kerberos(_) + | AuthenticationClassProvider::Static(_) + | AuthenticationClassProvider::Tls(_) => { + return Err(Error::AuthenticationProviderNotSupported { + auth_class_name: auth_class_name.to_owned(), + provider: auth_class.spec.provider.to_string(), + }); + } + } + + match user_registration { + Some(user_registration) => { + ensure!( + user_registration == entry.user_registration, + DifferentUserRegistrationSettingsNotAllowedSnafu + ); + } + None => user_registration = Some(entry.user_registration), + } + match &user_registration_role { + Some(user_registration_role) => { + ensure!( + user_registration_role == &entry.user_registration_role, + DifferentUserRegistrationRoleSettingsNotAllowedSnafu + ); + } + None => user_registration_role = Some(entry.user_registration_role.to_owned()), + } + match &sync_roles_at { + Some(sync_roles_at) => { + ensure!( + sync_roles_at == &entry.sync_roles_at, + DifferentSyncRolesAtSettingsNotAllowedSnafu + ); + } + None => sync_roles_at = Some(entry.sync_roles_at.to_owned()), } } - auth_classes + Ok(AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: resolved_auth_classes, + user_registration: user_registration.unwrap_or_else(default_user_registration), + user_registration_role: user_registration_role + .unwrap_or_else(default_user_registration_role), + sync_roles_at: sync_roles_at.unwrap_or_else(FlaskRolesSyncMoment::default), + }) } - /// Retrieve all provided `AuthenticationClass` references. - pub async fn resolve( - &self, - client: &Client, - ) -> Result> { - let mut resolved = vec![]; + fn from_oidc( + auth_class_name: &str, + provider: &oidc::AuthenticationProvider, + auth_details: &AirflowClientAuthenticationDetails, + ) -> Result { + let oidc_provider = match &provider.provider_hint { + None => { + info!("No OIDC provider hint given in AuthClass {auth_class_name}, assuming {default_oidc_provider_name}", + default_oidc_provider_name = serde_json::to_string(&DEFAULT_OIDC_PROVIDER).unwrap()); + DEFAULT_OIDC_PROVIDER + } + Some(oidc_provider) => oidc_provider.to_owned(), + }; + + ensure!( + SUPPORTED_OIDC_PROVIDERS.contains(&oidc_provider), + OidcProviderNotSupportedSnafu { + auth_class_name, + oidc_provider: serde_json::to_string(&oidc_provider).unwrap(), + } + ); - // TODO: adapt if multiple authentication classes are supported by airflow. - // This is currently not possible due to the Flask App Builder not supporting it. - if self.authentication.len() > 1 { - return Err(Error::MultipleAuthenticationClassesProvided); + match oidc_provider { + IdentityProviderHint::Keycloak => { + ensure!( + &provider.principal_claim == "preferred_username", + OidcPrincipalClaimNotSupportedSnafu { + configured: provider.principal_claim.clone(), + supported: "preferred_username".to_owned(), + auth_class_name, + } + ); + } } - for config in &self.authentication { - let auth_class = if let Some(auth_class) = &config.authentication_class { - let resolved = AuthenticationClass::resolve(client, auth_class) - .await - .context(AuthenticationClassRetrievalSnafu { - authentication_class: ObjectRef::::new(auth_class), - })?; - - // Checking for supported AuthenticationClass here is a little out of place, but is does not - // make sense to iterate further after finding an unsupported AuthenticationClass. - Some(match resolved.spec.provider { - AuthenticationClassProvider::Ldap(_) => resolved, - AuthenticationClassProvider::Tls(_) - | AuthenticationClassProvider::Oidc(_) - | AuthenticationClassProvider::Static(_) - | AuthenticationClassProvider::Kerberos(_) => { - return Err(Error::AuthenticationProviderNotSupported { - authentication_class: ObjectRef::from_obj(&resolved), - provider: resolved.spec.provider.to_string(), - }) + ensure!( + !provider.tls.uses_tls() || provider.tls.uses_tls_verification(), + TlsVerificationCannotBeDisabledSnafu { auth_class_name } + ); + + Ok(AirflowAuthenticationClassResolved::Oidc { + provider: provider.to_owned(), + oidc: auth_details + .common + .oidc_or_error(auth_class_name) + .context(OidcConfigurationInvalidSnafu)? + .clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use std::pin::Pin; + + use indoc::indoc; + use stackable_operator::commons::networking::HostName; + use stackable_operator::commons::tls_verification::{ + CaCert, Tls, TlsClientDetails, TlsServerVerification, TlsVerification, + }; + use stackable_operator::{commons::authentication::oidc, kube}; + + use super::*; + + #[tokio::test] + async fn resolve_without_authentication_details() { + let auth_details_resolved = test_resolve_and_expect_success("[]", "").await; + + assert_eq!( + AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: Vec::default(), + user_registration: default_user_registration(), + user_registration_role: default_user_registration_role(), + sync_roles_at: FlaskRolesSyncMoment::default() + }, + auth_details_resolved + ); + } + + #[tokio::test] + async fn resolve_ldap_with_all_authentication_details() { + // Avoid using defaults here + let auth_details_resolved = test_resolve_and_expect_success( + indoc! {" + - authenticationClass: ldap + userRegistration: false + userRegistrationRole: Gamma + syncRolesAt: Login + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: ldap + spec: + provider: + ldap: + hostname: my.ldap.server + "}, + ) + .await; + + assert_eq!( + AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: vec![AirflowAuthenticationClassResolved::Ldap { + provider: serde_yaml::from_str("hostname: my.ldap.server").unwrap() + }], + user_registration: false, + user_registration_role: "Gamma".into(), + sync_roles_at: FlaskRolesSyncMoment::Login + }, + auth_details_resolved + ); + } + + #[tokio::test] + async fn resolve_oidc_with_all_authentication_details() { + // Avoid using defaults here + let auth_details_resolved = test_resolve_and_expect_success( + indoc! {" + - authenticationClass: oidc1 + oidc: + clientCredentialsSecret: airflow-oidc-client1 + extraScopes: + - groups + userRegistration: false + userRegistrationRole: Gamma + syncRolesAt: Login + - authenticationClass: oidc2 + oidc: + clientCredentialsSecret: airflow-oidc-client2 + userRegistration: false + userRegistrationRole: Gamma + syncRolesAt: Login + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc1 + spec: + provider: + oidc: + hostname: first.oidc.server + port: 443 + rootPath: /realms/main + principalClaim: preferred_username + scopes: + - openid + - email + - profile + providerHint: Keycloak + tls: + verification: + server: + caCert: + secretClass: tls + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc2 + spec: + provider: + oidc: + hostname: second.oidc.server + rootPath: /realms/test + principalClaim: preferred_username + scopes: + - openid + - email + - profile + "}, + ) + .await; + + assert_eq!( + AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: vec![ + AirflowAuthenticationClassResolved::Oidc { + provider: oidc::AuthenticationProvider::new( + HostName::try_from("first.oidc.server".to_string()).unwrap(), + Some(443), + "/realms/main".into(), + TlsClientDetails { + tls: Some(Tls { + verification: TlsVerification::Server(TlsServerVerification { + ca_cert: CaCert::SecretClass("tls".into()) + }) + }) + }, + "preferred_username".into(), + vec!["openid".into(), "email".into(), "profile".into()], + Some(IdentityProviderHint::Keycloak) + ), + oidc: oidc::ClientAuthenticationOptions { + client_credentials_secret_ref: "airflow-oidc-client1".into(), + extra_scopes: vec!["groups".into()], + product_specific_fields: () + } + }, + AirflowAuthenticationClassResolved::Oidc { + provider: oidc::AuthenticationProvider::new( + HostName::try_from("second.oidc.server".to_string()).unwrap(), + None, + "/realms/test".into(), + TlsClientDetails { tls: None }, + "preferred_username".into(), + vec!["openid".into(), "email".into(), "profile".into()], + None + ), + oidc: oidc::ClientAuthenticationOptions { + client_credentials_secret_ref: "airflow-oidc-client2".into(), + extra_scopes: Vec::new(), + product_specific_fields: () + } } + ], + user_registration: false, + user_registration_role: "Gamma".into(), + sync_roles_at: FlaskRolesSyncMoment::Login + }, + auth_details_resolved + ); + } + + #[tokio::test] + async fn reject_duplicate_authentication_class_references() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc + oidc: + clientCredentialsSecret: airflow-oidc-client1 + - authenticationClass: oidc + oidc: + clientCredentialsSecret: airflow-oidc-client2 + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc + spec: + provider: + oidc: + hostname: my.oidc.server + principalClaim: preferred_username + scopes: [] + "}, + ) + .await; + + assert_eq!( + r#"The AuthenticationClass "oidc" is referenced several times which is not allowed."#, + error_message + ); + } + #[tokio::test] + async fn reject_different_authentication_types() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc + oidc: + clientCredentialsSecret: airflow-oidc-client + - authenticationClass: ldap + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc + spec: + provider: + oidc: + hostname: my.oidc.server + principalClaim: preferred_username + scopes: [] + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: ldap + spec: + provider: + ldap: + hostname: my.ldap.server + "}, + ) + .await; + + assert_eq!( + "Only one authentication type at a time is supported by Airflow, see https://github.com/dpgaspar/Flask-AppBuilder/issues/1924.", + error_message + ); + } + + #[tokio::test] + async fn reject_multiple_ldap_providers() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: ldap1 + - authenticationClass: ldap2 + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: ldap1 + spec: + provider: + ldap: + hostname: first.ldap.server + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: ldap2 + spec: + provider: + ldap: + hostname: second.ldap.server + "}, + ) + .await; + + assert_eq!( + "Only one LDAP provider at a time is supported by Airflow.", + error_message + ); + } + + #[tokio::test] + async fn reject_different_user_registration_settings() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc1 + oidc: + clientCredentialsSecret: superset-oidc-client1 + - authenticationClass: oidc2 + oidc: + clientCredentialsSecret: superset-oidc-client2 + userRegistration: false + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc1 + spec: + provider: + oidc: + hostname: first.oidc.server + principalClaim: preferred_username + scopes: [] + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc2 + spec: + provider: + oidc: + hostname: second.oidc.server + principalClaim: preferred_username + scopes: [] + "}, + ) + .await; + + assert_eq!( + "The userRegistration settings must not differ between the authentication entries.", + error_message + ); + } + + #[tokio::test] + async fn reject_different_user_registration_role_settings() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc1 + oidc: + clientCredentialsSecret: airflow-oidc-client1 + - authenticationClass: oidc2 + oidc: + clientCredentialsSecret: airflow-oidc-client2 + userRegistrationRole: Gamma + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc1 + spec: + provider: + oidc: + hostname: first.oidc.server + principalClaim: preferred_username + scopes: [] + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc2 + spec: + provider: + oidc: + hostname: second.oidc.server + principalClaim: preferred_username + scopes: [] + "}, + ) + .await; + + assert_eq!( + "The userRegistrationRole settings must not differ between the authentication entries.", + error_message + ); + } + + #[tokio::test] + async fn reject_different_sync_roles_at_settings() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc1 + oidc: + clientCredentialsSecret: airflow-oidc-client1 + - authenticationClass: oidc2 + oidc: + clientCredentialsSecret: airflow-oidc-client2 + syncRolesAt: Login + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc1 + spec: + provider: + oidc: + hostname: first.oidc.server + principalClaim: preferred_username + scopes: [] + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc2 + spec: + provider: + oidc: + hostname: second.oidc.server + principalClaim: preferred_username + scopes: [] + "}, + ) + .await; + + assert_eq!( + "The syncRolesAt settings must not differ between the authentication entries.", + error_message + ); + } + + #[tokio::test] + async fn reject_if_oidc_details_are_missing() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc + spec: + provider: + oidc: + hostname: my.oidc.server + principalClaim: preferred_username + scopes: [] + "}, + ) + .await; + + assert_eq!( + indoc! { r#" + Invalid OIDC configuration + + Caused by this error: + 1: authentication details for OIDC were not specified. The AuthenticationClass "oidc" uses an OIDC provider, you need to specify OIDC authentication details (such as client credentials) as well"# }, + error_message + ); + } + + #[tokio::test] + async fn reject_wrong_principal_claim() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc + oidc: + clientCredentialsSecret: airflow-oidc-client + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc + spec: + provider: + oidc: + hostname: my.oidc.server + principalClaim: sub + scopes: [] + "}, + ) + .await; + + assert_eq!( + r#""sub" is not a supported principalClaim in Airflow for the Keycloak OIDC provider. Please use "preferred_username" in the AuthenticationClass "oidc""#, + error_message + ); + } + + #[tokio::test] + async fn reject_disabled_tls_verification() { + let error_message = test_resolve_and_expect_error( + indoc! {" + - authenticationClass: oidc + oidc: + clientCredentialsSecret: airflow-oidc-client + "}, + indoc! {" + --- + apiVersion: authentication.stackable.tech/v1alpha1 + kind: AuthenticationClass + metadata: + name: oidc + spec: + provider: + oidc: + hostname: my.oidc.server + principalClaim: preferred_username + scopes: [] + tls: + verification: + none: {} + "}, + ) + .await; + + assert_eq!( + r#"TLS verification cannot be disabled in Airflow (AuthenticationClass "oidc")."#, + error_message + ); + } + + /// Call `AirflowClientAuthenticationDetailsResolved::resolve` with + /// the given lists of `AirflowClientAuthenticationDetails` and + /// `AuthenticationClass`es and return the + /// `AirflowClientAuthenticationDetailsResolved`. + /// + /// The parameters are meant to be valid and resolvable. Just fail + /// if there is an error. + async fn test_resolve_and_expect_success( + auth_details_yaml: &str, + auth_classes_yaml: &str, + ) -> AirflowClientAuthenticationDetailsResolved { + test_resolve(auth_details_yaml, auth_classes_yaml) + .await + .expect("The AirflowClientAuthenticationDetails should be resolvable.") + } + + /// Call `AirflowClientAuthenticationDetailsResolved::resolve` with + /// the given lists of `AirflowClientAuthenticationDetails` and + /// `AuthenticationClass`es and return the error message. + /// + /// The parameters are meant to be invalid or not resolvable. Just + /// fail if there is no error. + async fn test_resolve_and_expect_error( + auth_details_yaml: &str, + auth_classes_yaml: &str, + ) -> String { + let error = test_resolve(auth_details_yaml, auth_classes_yaml) + .await + .expect_err( + "The AirflowClientAuthenticationDetails are invalid and should not be resolvable.", + ); + snafu::Report::from_error(error) + .to_string() + .trim_end() + .to_owned() + } + + /// Call `AirflowClientAuthenticationDetailsResolved::resolve` with + /// the given lists of `AirflowClientAuthenticationDetails` and + /// `AuthenticationClass`es and return the result. + async fn test_resolve( + auth_details_yaml: &str, + auth_classes_yaml: &str, + ) -> Result { + let auth_details = deserialize_airflow_client_authentication_details(auth_details_yaml); + + let auth_classes = deserialize_auth_classes(auth_classes_yaml); + + let resolve_auth_class = create_auth_class_resolver(auth_classes); + + AirflowClientAuthenticationDetailsResolved::resolve(&auth_details, resolve_auth_class).await + } + + /// Deserialize the given list of + /// `AirflowClientAuthenticationDetails`. + /// + /// Fail if the given string cannot be deserialized. + fn deserialize_airflow_client_authentication_details( + input: &str, + ) -> Vec { + serde_yaml::from_str(input) + .expect("The definition of the authentication configuration should be valid.") + } + + /// Deserialize the given `AuthenticationClass` YAML documents. + /// + /// Fail if the given string cannot be deserialized. + fn deserialize_auth_classes(input: &str) -> Vec { + if input.is_empty() { + Vec::new() + } else { + let deserializer = serde_yaml::Deserializer::from_str(input); + deserializer + .map(|d| { + serde_yaml::with::singleton_map_recursive::deserialize(d) + .expect("The definition of the AuthenticationClass should be valid.") }) - } else { - None - }; - - resolved.push(AirflowAuthenticationConfigResolved { - authentication_class: auth_class, - user_registration: config.user_registration, - user_registration_role: config.user_registration_role.clone(), - sync_roles_at: config.sync_roles_at.clone(), + .collect() + } + } + /// Returns a function which resolves `AuthenticationClass` names to + /// the given list of `AuthenticationClass`es. + /// + /// Use this function in the tests to replace + /// `stackable_operator::commons::authentication::ClientAuthenticationDetails` + /// which requires a Kubernetes client. + fn create_auth_class_resolver( + auth_classes: Vec, + ) -> impl Fn( + ClientAuthenticationDetails, + ) -> Pin< + Box>>, + > { + move |auth_details: ClientAuthenticationDetails| { + let auth_classes = auth_classes.clone(); + Box::pin(async move { + auth_classes + .iter() + .find(|auth_class| { + auth_class.metadata.name.as_ref() + == Some(auth_details.authentication_class_name()) + }) + .cloned() + .ok_or_else(|| stackable_operator::client::Error::ListResources { + source: kube::Error::Api(kube::error::ErrorResponse { + code: 404, + message: "AuthenticationClass not found".into(), + reason: "NotFound".into(), + status: "Failure".into(), + }), + }) }) } - - Ok(resolved) } } diff --git a/rust/crd/src/lib.rs b/rust/crd/src/lib.rs index ada34642..d51e4e6a 100644 --- a/rust/crd/src/lib.rs +++ b/rust/crd/src/lib.rs @@ -38,7 +38,7 @@ use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; use crate::{ affinity::{get_affinity, get_executor_affinity}, - authentication::AirflowAuthentication, + authentication::AirflowClientAuthenticationDetails, }; pub mod affinity; @@ -90,6 +90,7 @@ pub enum Error { #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum AirflowConfigOptions { AuthType, + OauthProviders, AuthLdapSearch, AuthLdapSearchFilter, AuthLdapServer, @@ -114,6 +115,7 @@ impl FlaskAppConfigOptions for AirflowConfigOptions { fn python_type(&self) -> PythonType { match self { AirflowConfigOptions::AuthType => PythonType::Expression, + AirflowConfigOptions::OauthProviders => PythonType::Expression, AirflowConfigOptions::AuthUserRegistration => PythonType::BoolLiteral, AirflowConfigOptions::AuthUserRegistrationRole => PythonType::StringLiteral, AirflowConfigOptions::AuthRolesSyncAtLogin => PythonType::BoolLiteral, @@ -186,8 +188,8 @@ pub struct AirflowClusterSpec { #[derive(Clone, Deserialize, Debug, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AirflowClusterConfig { - #[serde(flatten)] - pub authentication: AirflowAuthentication, + #[serde(default)] + pub authentication: Vec, /// The name of the Secret object containing the admin user credentials and database connection details. /// Read the diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index fdd08d96..9ffd50f7 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -23,6 +23,7 @@ product-config.workspace = true strum.workspace = true tokio.workspace = true tracing.workspace = true +indoc.workspace = true [build-dependencies] diff --git a/rust/operator-binary/src/airflow_controller.rs b/rust/operator-binary/src/airflow_controller.rs index 79219119..97c96713 100644 --- a/rust/operator-binary/src/airflow_controller.rs +++ b/rust/operator-binary/src/airflow_controller.rs @@ -1,6 +1,6 @@ //! Ensures that `Pod`s are configured and running for each [`AirflowCluster`] use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap}, io::Write, str::FromStr, sync::Arc, @@ -13,8 +13,11 @@ use product_config::{ }; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_airflow_crd::{ - authentication::AirflowAuthenticationConfigResolved, build_recommended_labels, - git_sync::GitSync, AirflowCluster, AirflowClusterStatus, AirflowConfig, AirflowConfigFragment, + authentication::AirflowAuthenticationClassResolved, git_sync::GitSync, +}; +use stackable_airflow_crd::{ + authentication::AirflowClientAuthenticationDetailsResolved, build_recommended_labels, + AirflowCluster, AirflowClusterStatus, AirflowConfig, AirflowConfigFragment, AirflowConfigOptions, AirflowExecutor, AirflowRole, Container, ExecutorConfig, ExecutorConfigFragment, AIRFLOW_CONFIG_FILENAME, AIRFLOW_UID, APP_NAME, CONFIG_PATH, GIT_CONTENT, GIT_ROOT, GIT_SYNC_NAME, LOG_CONFIG_DIR, OPERATOR_NAME, STACKABLE_LOG_DIR, @@ -32,7 +35,7 @@ use stackable_operator::{ }, cluster_resources::{ClusterResourceApplyStrategy, ClusterResources}, commons::{ - authentication::{ldap, AuthenticationClass, AuthenticationClassProvider}, + authentication::{ldap, AuthenticationClass}, product_image_selection::ResolvedProductImage, rbac::build_rbac_resources, }, @@ -74,6 +77,7 @@ use stackable_operator::{ time::Duration, utils::COMMON_BASH_TRAP_FUNCTIONS, }; + use strum::{EnumDiscriminants, IntoEnumIterator, IntoStaticStr}; use crate::{ @@ -302,6 +306,16 @@ pub enum Error { source: builder::pod::container::Error, }, + #[snafu(display("failed to add LDAP Volumes and VolumeMounts"))] + AddLdapVolumesAndVolumeMounts { + source: stackable_operator::commons::authentication::ldap::Error, + }, + + #[snafu(display("failed to add TLS Volumes and VolumeMounts"))] + AddTlsVolumesAndVolumeMounts { + source: stackable_operator::commons::tls_verification::TlsClientDetailsError, + }, + #[snafu(display("AirflowCluster object is invalid"))] InvalidAirflowCluster { source: error_boundary::InvalidObject, @@ -337,13 +351,12 @@ pub async fn reconcile_airflow( let cluster_operation_cond_builder = ClusterOperationsConditionBuilder::new(&airflow.spec.cluster_operation); - let authentication_config = airflow - .spec - .cluster_config - .authentication - .resolve(client) - .await - .context(InvalidAuthenticationConfigSnafu)?; + let authentication_config = AirflowClientAuthenticationDetailsResolved::from( + &airflow.spec.cluster_config.authentication, + client, + ) + .await + .context(InvalidAuthenticationConfigSnafu)?; let mut roles = HashMap::new(); @@ -474,7 +487,7 @@ pub async fn reconcile_airflow( &airflow_role, &rolegroup, rolegroup_config, - authentication_config.as_ref(), + &authentication_config, &rbac_sa.name_unchecked(), &merged_airflow_config, airflow_executor, @@ -494,7 +507,7 @@ pub async fn reconcile_airflow( &resolved_product_image, &rolegroup, rolegroup_config, - authentication_config.as_ref(), + &authentication_config, &merged_airflow_config.logging, vector_aggregator_address.as_deref(), &Container::Airflow, @@ -543,7 +556,7 @@ async fn build_executor_template( airflow: &AirflowCluster, common_config: &CommonConfiguration, resolved_product_image: &ResolvedProductImage, - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, vector_aggregator_address: &Option, cluster_resources: &mut ClusterResources, client: &stackable_operator::client::Client, @@ -557,6 +570,7 @@ async fn build_executor_template( role: "executor".into(), role_group: "kubernetes".into(), }; + let rg_configmap = build_rolegroup_config_map( airflow, resolved_product_image, @@ -659,7 +673,7 @@ fn build_rolegroup_config_map( resolved_product_image: &ResolvedProductImage, rolegroup: &RoleGroupRef, rolegroup_config: &HashMap>, - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, logging: &Logging, vector_aggregator_address: Option<&str>, container: &Container, @@ -821,7 +835,7 @@ fn build_server_rolegroup_statefulset( airflow_role: &AirflowRole, rolegroup_ref: &RoleGroupRef, rolegroup_config: &HashMap>, - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, sa_name: &str, merged_airflow_config: &AirflowConfig, executor: &AirflowExecutor, @@ -888,6 +902,7 @@ fn build_server_rolegroup_statefulset( airflow_role, rolegroup_config, executor, + authentication_config, )); let volume_mounts = airflow.volume_mounts(); @@ -1098,7 +1113,7 @@ fn build_logging_container( fn build_executor_template_config_map( airflow: &AirflowCluster, resolved_product_image: &ResolvedProductImage, - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, sa_name: &str, merged_executor_config: &ExecutorConfig, env_overrides: &HashMap, @@ -1138,12 +1153,12 @@ fn build_executor_template_config_map( let mut airflow_container = ContainerBuilder::new(&Container::Base.to_string()).context(InvalidContainerNameSnafu)?; + // Works too, had been changed add_authentication_volumes_and_volume_mounts( authentication_config, &mut airflow_container, &mut pb, )?; - airflow_container .image_from_product_image(resolved_product_image) .resources(merged_executor_config.resources.clone().into()) @@ -1283,28 +1298,40 @@ pub fn error_policy( _ => Action::requeue(*Duration::from_secs(10)), } } +// I want to add secret volumes right here fn add_authentication_volumes_and_volume_mounts( - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, cb: &mut ContainerBuilder, pb: &mut PodBuilder, ) -> Result<()> { - // TODO: Currently there can be only one AuthenticationClass due to FlaskAppBuilder restrictions. - // Needs adaptation once FAB and airflow support multiple auth methods. - // The checks for max one AuthenticationClass and the provider are done in crd/src/authentication.rs - for config in authentication_config { - if let Some(auth_class) = &config.authentication_class { - match &auth_class.spec.provider { - AuthenticationClassProvider::Ldap(ldap) => { - ldap.add_volumes_and_mounts(pb, vec![cb]) - .context(VolumeAndMountsSnafu)?; - } - AuthenticationClassProvider::Tls(_) - | AuthenticationClassProvider::Oidc(_) - | AuthenticationClassProvider::Static(_) - | AuthenticationClassProvider::Kerberos(_) => {} + // Different authentication entries can reference the same secret + // class or TLS certificate. It must be ensured that the volumes + // and volume mounts are only added once in such a case. + + let mut ldap_authentication_providers = BTreeSet::new(); + let mut tls_client_credentials = BTreeSet::new(); + + for auth_class_resolved in &authentication_config.authentication_classes_resolved { + match auth_class_resolved { + AirflowAuthenticationClassResolved::Ldap { provider } => { + ldap_authentication_providers.insert(provider); + } + AirflowAuthenticationClassResolved::Oidc { provider, .. } => { + tls_client_credentials.insert(&provider.tls); } } } + + for provider in ldap_authentication_providers { + provider + .add_volumes_and_mounts(pb, vec![cb]) + .context(AddLdapVolumesAndVolumeMountsSnafu)?; + } + + for tls in tls_client_credentials { + tls.add_volumes_and_mounts(pb, vec![cb]) + .context(AddTlsVolumesAndVolumeMountsSnafu)?; + } Ok(()) } diff --git a/rust/operator-binary/src/config.rs b/rust/operator-binary/src/config.rs index e48a4349..2d7feff7 100644 --- a/rust/operator-binary/src/config.rs +++ b/rust/operator-binary/src/config.rs @@ -1,11 +1,13 @@ +use indoc::formatdoc; use snafu::{ResultExt, Snafu}; use stackable_airflow_crd::{ - authentication::AirflowAuthenticationConfigResolved, authentication::FlaskRolesSyncMoment, + authentication::{ + AirflowAuthenticationClassResolved, AirflowClientAuthenticationDetailsResolved, + FlaskRolesSyncMoment, DEFAULT_OIDC_PROVIDER, + }, AirflowConfigOptions, }; -use stackable_operator::commons::authentication::{ - ldap::AuthenticationProvider, AuthenticationClassProvider, -}; +use stackable_operator::commons::authentication::{ldap::AuthenticationProvider, oidc}; use stackable_operator::commons::tls_verification::TlsVerification; use std::collections::BTreeMap; @@ -28,7 +30,7 @@ pub enum Error { pub fn add_airflow_config( config: &mut BTreeMap, - authentication_config: &Vec, + authentication_config: &AirflowClientAuthenticationDetailsResolved, ) -> Result<()> { if !config.contains_key(&*AirflowConfigOptions::AuthType.to_string()) { config.insert( @@ -45,31 +47,53 @@ pub fn add_airflow_config( fn append_authentication_config( config: &mut BTreeMap, - authentication_config: &Vec, -) -> Result<()> { - // TODO: we make sure in crd/src/authentication.rs that currently there is only one - // AuthenticationClass provided. If the FlaskAppBuilder ever supports this we have - // to adapt the config here accordingly - for auth_config in authentication_config { - if let Some(auth_class) = &auth_config.authentication_class { - if let AuthenticationClassProvider::Ldap(ldap) = &auth_class.spec.provider { - append_ldap_config(config, ldap)?; + auth_config: &AirflowClientAuthenticationDetailsResolved, +) -> Result<(), Error> { + let ldap_providers = auth_config + .authentication_classes_resolved + .iter() + .filter_map(|auth_class| { + if let AirflowAuthenticationClassResolved::Ldap { provider } = auth_class { + Some(provider) + } else { + None } - } + }) + .collect::>(); - config.insert( - AirflowConfigOptions::AuthUserRegistration.to_string(), - auth_config.user_registration.to_string(), - ); - config.insert( - AirflowConfigOptions::AuthUserRegistrationRole.to_string(), - auth_config.user_registration_role.to_string(), - ); - config.insert( - AirflowConfigOptions::AuthRolesSyncAtLogin.to_string(), - (auth_config.sync_roles_at == FlaskRolesSyncMoment::Login).to_string(), - ); + let oidc_providers = auth_config + .authentication_classes_resolved + .iter() + .filter_map(|auth_class| { + if let AirflowAuthenticationClassResolved::Oidc { provider, oidc } = auth_class { + Some((provider, oidc)) + } else { + None + } + }) + .collect::>(); + + if let Some(ldap_provider) = ldap_providers.first() { + append_ldap_config(config, ldap_provider)?; } + + if !oidc_providers.is_empty() { + append_oidc_config(config, &oidc_providers); + } + + config.insert( + AirflowConfigOptions::AuthUserRegistration.to_string(), + auth_config.user_registration.to_string(), + ); + config.insert( + AirflowConfigOptions::AuthUserRegistrationRole.to_string(), + auth_config.user_registration_role.to_string(), + ); + config.insert( + AirflowConfigOptions::AuthRolesSyncAtLogin.to_string(), + (auth_config.sync_roles_at == FlaskRolesSyncMoment::Login).to_string(), + ); + Ok(()) } @@ -162,68 +186,254 @@ fn append_ldap_config( Ok(()) } +fn append_oidc_config( + config: &mut BTreeMap, + providers: &[( + &oidc::AuthenticationProvider, + &oidc::ClientAuthenticationOptions<()>, + )], +) { + // Debatable: AUTH_OAUTH or AUTH_OID + // Additionally can be set via config + config.insert( + AirflowConfigOptions::AuthType.to_string(), + "AUTH_OAUTH".into(), + ); + + let mut oauth_providers_config = Vec::new(); + + for (oidc, client_options) in providers { + let (env_client_id, env_client_secret) = + oidc::AuthenticationProvider::client_credentials_env_names( + &client_options.client_credentials_secret_ref, + ); + let mut scopes = oidc.scopes.clone(); + scopes.extend_from_slice(&client_options.extra_scopes); + + let oidc_provider = oidc + .provider_hint + .as_ref() + .unwrap_or(&DEFAULT_OIDC_PROVIDER); + + let oauth_providers_config_entry = match oidc_provider { + oidc::IdentityProviderHint::Keycloak => { + formatdoc!( + " + {{ 'name': 'keycloak', + 'icon': 'fa-key', + 'token_key': 'access_token', + 'remote_app': {{ + 'client_id': os.environ.get('{env_client_id}'), + 'client_secret': os.environ.get('{env_client_secret}'), + 'client_kwargs': {{ + 'scope': '{scopes}' + }}, + 'api_base_url': '{url}/protocol/', + 'server_metadata_url': '{url}/.well-known/openid-configuration', + }}, + }}", + url = oidc.endpoint_url().unwrap(), + scopes = scopes.join(" "), + ) + } + }; + + oauth_providers_config.push(oauth_providers_config_entry); + } + + config.insert( + AirflowConfigOptions::OauthProviders.to_string(), + formatdoc!( + "[ + {joined_oauth_providers_config} + ] + ", + joined_oauth_providers_config = oauth_providers_config.join(",\n") + ), + ); +} + #[cfg(test)] mod tests { use crate::config::add_airflow_config; + use indoc::indoc; use stackable_airflow_crd::authentication::{ - default_sync_roles_at, default_user_registration, AirflowAuthenticationConfigResolved, + default_sync_roles_at, default_user_registration, AirflowAuthenticationClassResolved, + AirflowClientAuthenticationDetailsResolved, FlaskRolesSyncMoment, }; - use stackable_airflow_crd::AirflowConfigOptions; - use stackable_operator::commons::authentication::AuthenticationClass; + use stackable_operator::commons::authentication::{ldap, oidc}; use std::collections::BTreeMap; #[test] - fn test_no_ldap() { + fn test_auth_db_config() { + let authentication_config = AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: vec![], + user_registration: true, + user_registration_role: "User".to_string(), + sync_roles_at: FlaskRolesSyncMoment::Registration, + }; + let mut result = BTreeMap::new(); - add_airflow_config(&mut result, &vec![]).expect("Ok"); + add_airflow_config(&mut result, &authentication_config).expect("Ok"); + assert_eq!( - BTreeMap::from([("AUTH_TYPE".into(), "AUTH_DB".into())]), + BTreeMap::from([ + ("AUTH_ROLES_SYNC_AT_LOGIN".into(), "false".into()), + ("AUTH_TYPE".into(), "AUTH_DB".into()), + ("AUTH_USER_REGISTRATION".into(), "true".into()), + ("AUTH_USER_REGISTRATION_ROLE".into(), "User".into()) + ]), result ); } #[test] - fn test_ldap() { - let authentication_class = " - apiVersion: authentication.stackable.tech/v1alpha1 - kind: AuthenticationClass - metadata: - name: airflow-with-ldap-server-veri-tls-ldap - spec: - provider: - ldap: - hostname: openldap.default.svc.cluster.local - port: 636 - searchBase: ou=users,dc=example,dc=org - ldapFieldNames: - uid: uid - bindCredentials: - secretClass: airflow-with-ldap-server-veri-tls-ldap-bind - tls: - verification: - server: - caCert: - secretClass: openldap-tls - "; - let deserializer = serde_yaml::Deserializer::from_str(authentication_class); - let authentication_class: AuthenticationClass = + fn test_ldap_config() { + let ldap_provider_yaml = r#" + hostname: openldap.default.svc.cluster.local + port: 636 + searchBase: ou=users,dc=example,dc=org + ldapFieldNames: + uid: uid + bindCredentials: + secretClass: airflow-with-ldap-server-veri-tls-ldap-bind + tls: + verification: + server: + caCert: + secretClass: openldap-tls + "#; + let deserializer = serde_yaml::Deserializer::from_str(ldap_provider_yaml); + let ldap_provider: ldap::AuthenticationProvider = serde_yaml::with::singleton_map_recursive::deserialize(deserializer).unwrap(); - let resolved_config = AirflowAuthenticationConfigResolved { - authentication_class: Some(authentication_class), + let authentication_config = AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: vec![AirflowAuthenticationClassResolved::Ldap { + provider: ldap_provider, + }], + user_registration: true, + user_registration_role: "Admin".to_string(), + sync_roles_at: FlaskRolesSyncMoment::Registration, + }; + + let mut result = BTreeMap::new(); + add_airflow_config(&mut result, &authentication_config).expect("Ok"); + + assert_eq!(BTreeMap::from([ + ("AUTH_LDAP_ALLOW_SELF_SIGNED".into(), "false".into()), + ("AUTH_LDAP_BIND_PASSWORD".into(), "open('/stackable/secrets/airflow-with-ldap-server-veri-tls-ldap-bind/password').read()".into()), + ("AUTH_LDAP_BIND_USER".into(), "open('/stackable/secrets/airflow-with-ldap-server-veri-tls-ldap-bind/user').read()".into()), + ("AUTH_LDAP_FIRSTNAME_FIELD".into(), "givenName".into()), + ("AUTH_LDAP_GROUP_FIELD".into(), "memberof".into()), + ("AUTH_LDAP_LASTNAME_FIELD".into(), "sn".into()), + ("AUTH_LDAP_SEARCH".into(), "ou=users,dc=example,dc=org".into()), + ("AUTH_LDAP_SEARCH_FILTER".into(), "".into()), + ("AUTH_LDAP_SERVER".into(), "ldaps://openldap.default.svc.cluster.local:636".into()), + ("AUTH_LDAP_TLS_CACERTFILE".into(), "/stackable/secrets/openldap-tls/ca.crt".into()), + ("AUTH_LDAP_TLS_DEMAND".into(), "true".into()), + ("AUTH_LDAP_UID_FIELD".into(), "uid".into()), + ("AUTH_ROLES_SYNC_AT_LOGIN".into(), "false".into()), + ("AUTH_TYPE".into(), "AUTH_LDAP".into()), + ("AUTH_USER_REGISTRATION".into(), "true".into()), + ("AUTH_USER_REGISTRATION_ROLE".into(), "Admin".into()) + ]), result); + } + + #[test] + fn test_oidc_config() { + let oidc_provider_yaml1 = r#" + hostname: my.keycloak1.server + port: 12345 + rootPath: my-root-path + tls: + verification: + server: + caCert: + secretClass: keycloak-ca-cert + principalClaim: preferred_username + scopes: + - openid + - email + - profile + provider_hint: Keycloak + "#; + let deserializer = serde_yaml::Deserializer::from_str(oidc_provider_yaml1); + let oidc_provider1: oidc::AuthenticationProvider = + serde_yaml::with::singleton_map_recursive::deserialize(deserializer).unwrap(); + + let oidc_provider_yaml2 = r#" + hostname: my.keycloak2.server + principalClaim: preferred_username + scopes: + - openid + provider_hint: Keycloak + "#; + let deserializer = serde_yaml::Deserializer::from_str(oidc_provider_yaml2); + let oidc_provider2: oidc::AuthenticationProvider = + serde_yaml::with::singleton_map_recursive::deserialize(deserializer).unwrap(); + + let authentication_config = AirflowClientAuthenticationDetailsResolved { + authentication_classes_resolved: vec![ + AirflowAuthenticationClassResolved::Oidc { + provider: oidc_provider1, + oidc: oidc::ClientAuthenticationOptions { + client_credentials_secret_ref: "test-client-secret1".to_string(), + extra_scopes: vec!["roles".to_string()], + product_specific_fields: (), + }, + }, + AirflowAuthenticationClassResolved::Oidc { + provider: oidc_provider2, + oidc: oidc::ClientAuthenticationOptions { + client_credentials_secret_ref: "test-client-secret2".to_string(), + extra_scopes: vec![], + product_specific_fields: (), + }, + }, + ], user_registration: default_user_registration(), user_registration_role: "Admin".to_string(), sync_roles_at: default_sync_roles_at(), }; let mut result = BTreeMap::new(); - add_airflow_config(&mut result, &vec![resolved_config]).expect("Ok"); + add_airflow_config(&mut result, &authentication_config).expect("Ok"); - assert_eq!( - "AUTH_LDAP", - result - .get(&AirflowConfigOptions::AuthType.to_string()) - .unwrap() - ); + assert_eq!(BTreeMap::from([ + ("AUTH_ROLES_SYNC_AT_LOGIN".into(), "false".into()), + ("AUTH_TYPE".into(), "AUTH_OAUTH".into()), + ("AUTH_USER_REGISTRATION".into(), "true".into()), + ("AUTH_USER_REGISTRATION_ROLE".into(), "Admin".into()), + ("OAUTH_PROVIDERS".into(), indoc!(" + [ + { 'name': 'keycloak', + 'icon': 'fa-key', + 'token_key': 'access_token', + 'remote_app': { + 'client_id': os.environ.get('OIDC_A96BCC4FA49835D2_CLIENT_ID'), + 'client_secret': os.environ.get('OIDC_A96BCC4FA49835D2_CLIENT_SECRET'), + 'client_kwargs': { + 'scope': 'openid email profile roles' + }, + 'api_base_url': 'https://my.keycloak1.server:12345/my-root-path/protocol/', + 'server_metadata_url': 'https://my.keycloak1.server:12345/my-root-path/.well-known/openid-configuration', + }, + }, + { 'name': 'keycloak', + 'icon': 'fa-key', + 'token_key': 'access_token', + 'remote_app': { + 'client_id': os.environ.get('OIDC_3A305E38C3B561F3_CLIENT_ID'), + 'client_secret': os.environ.get('OIDC_3A305E38C3B561F3_CLIENT_SECRET'), + 'client_kwargs': { + 'scope': 'openid' + }, + 'api_base_url': 'http://my.keycloak2.server//protocol/', + 'server_metadata_url': 'http://my.keycloak2.server//.well-known/openid-configuration', + }, + } + ] + ").into()) + ]), result); } } diff --git a/rust/operator-binary/src/env_vars.rs b/rust/operator-binary/src/env_vars.rs index 4d4a58dc..5d581090 100644 --- a/rust/operator-binary/src/env_vars.rs +++ b/rust/operator-binary/src/env_vars.rs @@ -1,15 +1,19 @@ use crate::util::env_var_from_secret; use product_config::types::PropertyNameKind; +use stackable_airflow_crd::authentication::{ + AirflowAuthenticationClassResolved, AirflowClientAuthenticationDetailsResolved, +}; use stackable_airflow_crd::git_sync::GitSync; use stackable_airflow_crd::{ AirflowCluster, AirflowConfig, AirflowExecutor, AirflowRole, ExecutorConfig, LOG_CONFIG_DIR, STACKABLE_LOG_DIR, }; use stackable_airflow_crd::{GIT_LINK, GIT_SYNC_DIR, TEMPLATE_LOCATION, TEMPLATE_NAME}; +use stackable_operator::commons::authentication::oidc; use stackable_operator::k8s_openapi::api::core::v1::EnvVar; use stackable_operator::kube::ResourceExt; use stackable_operator::product_logging::framework::create_vector_shutdown_file_command; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; const AIRFLOW__LOGGING__LOGGING_CONFIG_CLASS: &str = "AIRFLOW__LOGGING__LOGGING_CONFIG_CLASS"; const AIRFLOW__METRICS__STATSD_ON: &str = "AIRFLOW__METRICS__STATSD_ON"; @@ -44,6 +48,7 @@ pub fn build_airflow_statefulset_envs( airflow_role: &AirflowRole, rolegroup_config: &HashMap>, executor: &AirflowExecutor, + auth_config: &AirflowClientAuthenticationDetailsResolved, ) -> Vec { let mut env: BTreeMap = BTreeMap::new(); @@ -166,32 +171,46 @@ pub fn build_airflow_statefulset_envs( ); } - // Database initialization is limited to the scheduler. - // See https://github.com/stackabletech/airflow-operator/issues/259 - if airflow_role == &AirflowRole::Scheduler { - let secret = &airflow.spec.cluster_config.credentials_secret; - env.insert( - ADMIN_USERNAME.into(), - env_var_from_secret(ADMIN_USERNAME, secret, "adminUser.username"), - ); - env.insert( - ADMIN_FIRSTNAME.into(), - env_var_from_secret(ADMIN_FIRSTNAME, secret, "adminUser.firstname"), - ); - env.insert( - ADMIN_LASTNAME.into(), - env_var_from_secret(ADMIN_LASTNAME, secret, "adminUser.lastname"), - ); - env.insert( - ADMIN_EMAIL.into(), - env_var_from_secret(ADMIN_EMAIL, secret, "adminUser.email"), - ); - env.insert( - ADMIN_PASSWORD.into(), - env_var_from_secret(ADMIN_PASSWORD, secret, "adminUser.password"), - ); + match airflow_role { + // Database initialization is limited to the scheduler. + // See https://github.com/stackabletech/airflow-operator/issues/259 + AirflowRole::Scheduler => { + let secret = &airflow.spec.cluster_config.credentials_secret; + env.insert( + ADMIN_USERNAME.into(), + env_var_from_secret(ADMIN_USERNAME, secret, "adminUser.username"), + ); + env.insert( + ADMIN_FIRSTNAME.into(), + env_var_from_secret(ADMIN_FIRSTNAME, secret, "adminUser.firstname"), + ); + env.insert( + ADMIN_LASTNAME.into(), + env_var_from_secret(ADMIN_LASTNAME, secret, "adminUser.lastname"), + ); + env.insert( + ADMIN_EMAIL.into(), + env_var_from_secret(ADMIN_EMAIL, secret, "adminUser.email"), + ); + env.insert( + ADMIN_PASSWORD.into(), + env_var_from_secret(ADMIN_PASSWORD, secret, "adminUser.password"), + ); + } + AirflowRole::Webserver => { + let auth_vars = authentication_env_vars(auth_config); + env.extend(auth_vars.into_iter().map(|var| (var.name.to_owned(), var))); + env.insert( + "REQUESTS_CA_BUNDLE".into(), + EnvVar { + name: "REQUESTS_CA_BUNDLE".to_string(), + value: Some("/stackable/secrets/tls/ca.crt".to_string()), + ..Default::default() + }, + ); + } + _ => {} } - // apply overrides last of all with a fixed ordering if let Some(env_vars) = env_vars { for (k, v) in env_vars.iter().collect::>() { @@ -454,3 +473,29 @@ fn gitsync_vars_map(k: &String, env: &mut BTreeMap, v: &String) fn transform_map_to_vec(env_map: BTreeMap) -> Vec { env_map.into_values().collect::>() } + +fn authentication_env_vars( + auth_config: &AirflowClientAuthenticationDetailsResolved, +) -> Vec { + // Different OIDC authentication entries can reference the same + // client secret. It must be ensured that the env variables are only + // added once in such a case. + + let mut oidc_client_credentials_secrets = BTreeSet::new(); + + for auth_class_resolved in &auth_config.authentication_classes_resolved { + match auth_class_resolved { + AirflowAuthenticationClassResolved::Ldap { .. } => {} + AirflowAuthenticationClassResolved::Oidc { oidc, .. } => { + oidc_client_credentials_secrets + .insert(oidc.client_credentials_secret_ref.to_owned()); + } + } + } + + oidc_client_credentials_secrets + .iter() + .cloned() + .flat_map(oidc::AuthenticationProvider::client_credentials_env_var_mounts) + .collect() +} diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index a9d73738..03c70ece 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -17,10 +17,8 @@ use stackable_operator::{ k8s_openapi::api::{apps::v1::StatefulSet, core::v1::Service}, kube::{ core::DeserializeGuard, - runtime::{ - reflector::{Lookup, ObjectRef}, - watcher, Controller, - }, + runtime::{reflector::ObjectRef, watcher, Controller}, + ResourceExt, }, logging::controller::report_controller_reconciled, CustomResourceExt, @@ -135,13 +133,12 @@ fn references_authentication_class( let Ok(airflow) = &airflow.0 else { return false; }; - let Some(authn_class_name) = authentication_class.name() else { - return false; - }; + let authentication_class_name = authentication_class.name_any(); + airflow .spec .cluster_config .authentication - .authentication_class_names() - .contains(&&*authn_class_name) + .iter() + .any(|c| c.common.authentication_class_name() == &authentication_class_name) } 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/10-assert.yaml b/tests/templates/kuttl/oidc/10-assert.yaml new file mode 100644 index 00000000..319e927a --- /dev/null +++ b/tests/templates/kuttl/oidc/10-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-airflow-postgresql +timeout: 480 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-postgresql +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/10-install-postgresql.yaml b/tests/templates/kuttl/oidc/10-install-postgresql.yaml new file mode 100644 index 00000000..ab7b4004 --- /dev/null +++ b/tests/templates/kuttl/oidc/10-install-postgresql.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: >- + helm install airflow-postgresql + --namespace $NAMESPACE + --version 12.5.6 + --values helm-bitnami-postgresql-values.yaml + --repo https://charts.bitnami.com/bitnami postgresql + --wait + timeout: 600 diff --git a/tests/templates/kuttl/oidc/20-assert.yaml.j2 b/tests/templates/kuttl/oidc/20-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/oidc/20-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/20-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/oidc/20-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/oidc/20-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/30-assert.yaml b/tests/templates/kuttl/oidc/30-assert.yaml new file mode 100644 index 00000000..93965698 --- /dev/null +++ b/tests/templates/kuttl/oidc/30-assert.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-keycloak +timeout: 480 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak1 +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak2 +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/30-install-keycloak.yaml b/tests/templates/kuttl/oidc/30-install-keycloak.yaml new file mode 100644 index 00000000..f1af62f4 --- /dev/null +++ b/tests/templates/kuttl/oidc/30-install-keycloak.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + INSTANCE_NAME=keycloak1 \ + REALM=test1 \ + USERNAME=jane.doe \ + FIRST_NAME=Jane \ + LAST_NAME=Doe \ + EMAIL=jane.doe@stackable.tech \ + PASSWORD=T8mn72D9 \ + CLIENT_ID=airflow1 \ + CLIENT_SECRET=R1bxHUD569vHeQdw \ + envsubst < install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - + + INSTANCE_NAME=keycloak2 \ + REALM=test2 \ + USERNAME=richard.roe \ + FIRST_NAME=Richard \ + LAST_NAME=Roe \ + EMAIL=richard.roe@stackable.tech \ + PASSWORD=NvfpU518 \ + CLIENT_ID=airflow2 \ + CLIENT_SECRET=scWzh0D4v0GN8NrN \ + envsubst < install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/40-assert.yaml b/tests/templates/kuttl/oidc/40-assert.yaml new file mode 100644 index 00000000..ad3c8974 --- /dev/null +++ b/tests/templates/kuttl/oidc/40-assert.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: install-airflow +timeout: 1200 +commands: + - script: > + kubectl --namespace $NAMESPACE + wait --for=condition=available=true + airflowclusters.airflow.stackable.tech/airflow + --timeout 301s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-webserver-default +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-scheduler-default +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/40-install-airflow.yaml b/tests/templates/kuttl/oidc/40-install-airflow.yaml new file mode 100644 index 00000000..9ef8e0ab --- /dev/null +++ b/tests/templates/kuttl/oidc/40-install-airflow.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 300 +commands: + - script: > + envsubst '$NAMESPACE' < install-airflow.yaml | + kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/50-assert.yaml b/tests/templates/kuttl/oidc/50-assert.yaml new file mode 100644 index 00000000..58987778 --- /dev/null +++ b/tests/templates/kuttl/oidc/50-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: install-test-container +timeout: 300 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: python +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/oidc/50-install-test-container.yaml.j2 b/tests/templates/kuttl/oidc/50-install-test-container.yaml.j2 new file mode 100644 index 00000000..d1199711 --- /dev/null +++ b/tests/templates/kuttl/oidc/50-install-test-container.yaml.j2 @@ -0,0 +1,80 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: python +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: python +{% if test_scenario['values']['openshift'] == 'true' %} +rules: +- apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + resourceNames: ["privileged"] + verbs: ["use"] +{% endif %} +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: python +subjects: + - kind: ServiceAccount + name: python +roleRef: + kind: Role + name: python + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: install-test-container +timeout: 300 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: python + labels: + app: python +spec: + replicas: 1 + selector: + matchLabels: + app: python + template: + metadata: + labels: + app: python + spec: + serviceAccountName: python + securityContext: + fsGroup: 1000 + containers: + - name: python + image: docker.stackable.tech/stackable/testing-tools:0.2.0-stackable0.0.0-dev + stdin: true + tty: true + resources: + requests: + memory: "128Mi" + cpu: "512m" + limits: + memory: "128Mi" + cpu: "1" + volumeMounts: + - name: tls + mountPath: /stackable/tls + env: + - name: REQUESTS_CA_BUNDLE + value: /stackable/tls/ca.crt + volumes: + - name: tls + csi: + driver: secrets.stackable.tech + volumeAttributes: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: pod diff --git a/tests/templates/kuttl/oidc/60-assert.yaml b/tests/templates/kuttl/oidc/60-assert.yaml new file mode 100644 index 00000000..fee66ceb --- /dev/null +++ b/tests/templates/kuttl/oidc/60-assert.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: login +timeout: 300 +commands: + - script: kubectl exec -n $NAMESPACE python-0 -- python /stackable/login.py diff --git a/tests/templates/kuttl/oidc/60-login.yaml b/tests/templates/kuttl/oidc/60-login.yaml new file mode 100644 index 00000000..0745bc4b --- /dev/null +++ b/tests/templates/kuttl/oidc/60-login.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: login +commands: + - script: > + envsubst '$NAMESPACE' < login.py | + kubectl exec -n $NAMESPACE -i python-0 -- tee /stackable/login.py > /dev/null diff --git a/tests/templates/kuttl/oidc/helm-bitnami-postgresql-values.yaml.j2 b/tests/templates/kuttl/oidc/helm-bitnami-postgresql-values.yaml.j2 new file mode 100644 index 00000000..951804d6 --- /dev/null +++ b/tests/templates/kuttl/oidc/helm-bitnami-postgresql-values.yaml.j2 @@ -0,0 +1,31 @@ +--- +volumePermissions: + enabled: false + securityContext: + runAsUser: auto + +primary: + podSecurityContext: +{% if test_scenario['values']['openshift'] == 'true' %} + enabled: false +{% else %} + enabled: true +{% endif %} + containerSecurityContext: + enabled: false + resources: + requests: + memory: "128Mi" + cpu: "512m" + limits: + memory: "128Mi" + cpu: "1" + +shmVolume: + chmod: + enabled: false + +auth: + username: airflow + password: airflow + database: airflow diff --git a/tests/templates/kuttl/oidc/install-airflow.yaml.j2 b/tests/templates/kuttl/oidc/install-airflow.yaml.j2 new file mode 100644 index 00000000..49e46e46 --- /dev/null +++ b/tests/templates/kuttl/oidc/install-airflow.yaml.j2 @@ -0,0 +1,77 @@ +# $NAMESPACE will be replaced with the namespace of the test case. +--- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-credentials +type: Opaque +stringData: + adminUser.username: airflow + adminUser.firstname: Airflow + adminUser.lastname: Admin + adminUser.email: airflow@airflow.com + adminUser.password: airflow + connections.secretKey: thisISaSECRET_1234 + connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql/airflow +--- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-keycloak1-client +stringData: + clientId: airflow1 + clientSecret: R1bxHUD569vHeQdw +--- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-keycloak2-client +stringData: + clientId: airflow2 + clientSecret: scWzh0D4v0GN8NrN +--- +apiVersion: airflow.stackable.tech/v1alpha1 +kind: AirflowCluster +metadata: + name: airflow +spec: + image: +{% if test_scenario['values']['airflow'].find(",") > 0 %} + custom: "{{ test_scenario['values']['airflow'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['airflow'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['airflow'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + authentication: + - authenticationClass: keycloak1-$NAMESPACE + oidc: + clientCredentialsSecret: airflow-keycloak1-client + userRegistrationRole: Admin + - authenticationClass: keycloak2-$NAMESPACE + oidc: + clientCredentialsSecret: airflow-keycloak2-client + userRegistrationRole: Admin + credentialsSecret: airflow-credentials +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + webservers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 + kubernetesExecutors: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + schedulers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + replicas: 1 diff --git a/tests/templates/kuttl/oidc/install-keycloak.yaml.j2 b/tests/templates/kuttl/oidc/install-keycloak.yaml.j2 new file mode 100644 index 00000000..7197df2f --- /dev/null +++ b/tests/templates/kuttl/oidc/install-keycloak.yaml.j2 @@ -0,0 +1,170 @@ +# The environment variables must be replaced. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: $INSTANCE_NAME-realms +data: + test-realm.json: | + { + "realm": "$REALM", + "enabled": true, + "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: + serviceAccountName: keycloak + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:23.0.4 + args: + - start-dev + - --import-realm + - --https-certificate-file=/tls/tls.crt + - --https-certificate-key-file=/tls/tls.key + env: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + ports: + - name: https + containerPort: 8443 + volumeMounts: + - name: realms + mountPath: /opt/keycloak/data/import + - name: tls + mountPath: /tls + readinessProbe: + httpGet: + scheme: HTTPS + path: /realms/$REALM + port: 8443 + volumes: + - name: realms + configMap: + name: $INSTANCE_NAME-realms + - name: tls + csi: + driver: secrets.stackable.tech + volumeAttributes: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: service=$INSTANCE_NAME +--- +apiVersion: v1 +kind: Service +metadata: + name: $INSTANCE_NAME +spec: + selector: + app: $INSTANCE_NAME + ports: + - protocol: TCP + port: 8443 +--- +apiVersion: authentication.stackable.tech/v1alpha1 +kind: AuthenticationClass +metadata: + name: $INSTANCE_NAME-$NAMESPACE +spec: + provider: + oidc: + hostname: $INSTANCE_NAME.$NAMESPACE.svc.cluster.local + port: 8443 + rootPath: /realms/$REALM + scopes: + - email + - openid + - profile + principalClaim: preferred_username + providerHint: Keycloak + tls: + verification: + server: + caCert: + secretClass: tls +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keycloak +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: keycloak +{% if test_scenario['values']['openshift'] == 'true' %} +rules: +- apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + resourceNames: ["privileged"] + verbs: ["use"] +{% endif %} +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: keycloak +subjects: + - kind: ServiceAccount + name: keycloak +roleRef: + kind: Role + name: keycloak + apiGroup: rbac.authorization.k8s.io diff --git a/tests/templates/kuttl/oidc/login.py b/tests/templates/kuttl/oidc/login.py new file mode 100644 index 00000000..e0e22a70 --- /dev/null +++ b/tests/templates/kuttl/oidc/login.py @@ -0,0 +1,106 @@ +# $NAMESPACE will be replaced with the namespace of the test case. + +import logging +import requests +import sys +from bs4 import BeautifulSoup + +logging.basicConfig( + level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout +) + +session = requests.Session() + +# Click on "Sign In with keycloak" in Airflow +login_page = session.get("http://airflow-webserver:8080/login/keycloak?next=") + +assert login_page.ok, "Redirection from Airflow to Keycloak failed" +assert login_page.url.startswith( + "https://keycloak1.$NAMESPACE.svc.cluster.local:8443/realms/test1/protocol/openid-connect/auth?response_type=code&client_id=airflow1" +), "Redirection to the Keycloak login page expected" + +# Enter username and password into the Keycloak login page and click on "Sign In" +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"} +) + +assert welcome_page.ok, "Login failed" +assert ( + welcome_page.url == "http://airflow-webserver:8080/home" +), "Redirection to the Airflow home page expected" + +# Open the user information page in Airflow +userinfo_page = session.get("http://airflow-webserver:8080/users/userinfo/") + +assert userinfo_page.ok, "Retrieving user information failed" +assert ( + userinfo_page.url == "http://airflow-webserver:8080/users/userinfo/" +), "Redirection to the Airflow user info page expected" + +# Expect the user data provided by Keycloak in Airflow +userinfo_page_html = BeautifulSoup(userinfo_page.text, "html.parser") +table_rows = userinfo_page_html.find_all("tr") +user_data = {tr.find("th").text: tr.find("td").text for tr in table_rows} + +assert ( + user_data["First Name"] == "Jane" +), "The first name of the user in Airflow should match the one provided by Keycloak" +assert ( + user_data["Last Name"] == "Doe" +), "The last name of the user in Airflow should match the one provided by Keycloak" +assert ( + user_data["Email"] == "jane.doe@stackable.tech" +), "The email of the user in Airflow should match the one provided by Keycloak" + +# Later this can be extended to use different OIDC providers (currently only Keycloak is +# supported) +# +# It would be beneficial if the second OAuth provider keycloak2 could +# also be tested. This would ensure that the Airflow configuration is +# correct. The problem is that the Flask-AppBuilder (and hence Airflow) +# do not support multiple OAuth providers with the same name. But +# keycloak1 and keycloak2 use the same name, namely "keycloak": +# +# OAUTH_PROVIDERS = [ +# { 'name': 'keycloak', +# 'icon': 'fa-key', +# 'token_key': 'access_token', +# 'remote_app': { +# 'client_id': os.environ.get('OIDC_728D9B504A6E9A10_CLIENT_ID'), +# 'client_secret': os.environ.get('OIDC_728D9B504A6E9A10_CLIENT_SECRET'), +# 'client_kwargs': { +# 'scope': 'email openid profile' +# }, +# 'api_base_url': 'https://keycloak1.kuttl.svc.cluster.local:8443/realms/test1/protocol/', +# 'server_metadata_url': 'https://keycloak1.kuttl.svc.cluster.local:8443/realms/test1/.well-known/openid-configuration', +# }, +# }, +# { 'name': 'keycloak', +# 'icon': 'fa-key', +# 'token_key': 'access_token', +# 'remote_app': { +# 'client_id': os.environ.get('OIDC_607BA683B09BC0B8_CLIENT_ID'), +# 'client_secret': os.environ.get('OIDC_607BA683B09BC0B8_CLIENT_SECRET'), +# 'client_kwargs': { +# 'scope': 'email openid profile' +# }, +# 'api_base_url': 'https://keycloak2.kuttl.svc.cluster.local:8443/realms/test2/protocol/', +# 'server_metadata_url': 'https://keycloak2.kuttl.svc.cluster.local:8443/realms/test2/.well-known/openid-configuration', +# }, +# } +# ] +# +# This name is set in the operator and cannot be changed. The reason is +# that the name is also used in Flask-AppBuilder to determine how the +# user information must be interpreted. +# +# Airflow actually shows two "Sign In with keycloak" buttons in this +# test but the second one cannot be clicked. +# +# It is nevertheless useful to have two Keycloak instances in this test +# because it ensures that several authentication entries can be +# specified, no volumes or volume mounts are added twice, and that the +# configuration is correct to the extent that Airflow does not complain +# about it. diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 769128bb..7be72360 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -51,6 +51,10 @@ tests: - openshift - ldap-authentication - executor + - name: oidc + dimensions: + - airflow + - openshift - name: resources dimensions: - airflow-latest