diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f29f29..5ea3b239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Run a `containerdebug` process in the background of each Superset container to collect debugging information ([#578]). - Aggregate emitted Kubernetes events on the CustomResources ([#585]). +- Support OPA role mapping as optional custom security manager for Superset ([#582]). - Support for version `4.1.1` ([#595]). ### Changed @@ -13,6 +14,7 @@ - Default to OCI for image metadata and product image selection ([#586]). [#578]: https://github.com/stackabletech/superset-operator/pull/578 +[#582]: https://github.com/stackabletech/superset-operator/pull/582 [#585]: https://github.com/stackabletech/superset-operator/pull/585 [#586]: https://github.com/stackabletech/superset-operator/pull/586 [#595]: https://github.com/stackabletech/superset-operator/pull/595 diff --git a/Cargo.lock b/Cargo.lock index 99bbea5f..a9062843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,8 +2331,8 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stackable-operator" -version = "0.85.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#59506c6202778889a27b6ae8153457e60a49c68d" +version = "0.86.0" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#e5bfee596cc918b05f3e1d7e667c25951317cf31" dependencies = [ "chrono", "clap", @@ -2370,7 +2370,7 @@ dependencies = [ [[package]] name = "stackable-operator-derive" version = "0.3.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#59506c6202778889a27b6ae8153457e60a49c68d" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#e5bfee596cc918b05f3e1d7e667c25951317cf31" dependencies = [ "darling", "proc-macro2", @@ -2381,7 +2381,7 @@ dependencies = [ [[package]] name = "stackable-shared" version = "0.0.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#59506c6202778889a27b6ae8153457e60a49c68d" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#e5bfee596cc918b05f3e1d7e667c25951317cf31" dependencies = [ "kube", "semver", diff --git a/Cargo.nix b/Cargo.nix index 6072d45c..229631dd 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -1558,7 +1558,7 @@ rec { "default" = [ "Debug" "Clone" "Copy" "PartialEq" "Eq" "PartialOrd" "Ord" "Hash" "Default" "Deref" "DerefMut" "Into" ]; "full" = [ "syn/full" ]; }; - resolvedDefaultFeatures = [ "Clone" "Debug" "Default" "Hash" "PartialEq" ]; + resolvedDefaultFeatures = [ "Clone" "Debug" "Default" "Eq" "Hash" "PartialEq" ]; }; "either" = rec { crateName = "either"; @@ -7229,13 +7229,13 @@ rec { }; "stackable-operator" = rec { crateName = "stackable-operator"; - version = "0.85.0"; + version = "0.86.0"; edition = "2021"; workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/stackabletech/operator-rs.git"; - rev = "59506c6202778889a27b6ae8153457e60a49c68d"; - sha256 = "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5"; + rev = "e5bfee596cc918b05f3e1d7e667c25951317cf31"; + sha256 = "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1"; }; libName = "stackable_operator"; authors = [ @@ -7268,7 +7268,7 @@ rec { name = "educe"; packageId = "educe"; usesDefaultFeatures = false; - features = [ "Clone" "Debug" "Default" "PartialEq" ]; + features = [ "Clone" "Debug" "Default" "PartialEq" "Eq" ]; } { name = "either"; @@ -7394,8 +7394,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/stackabletech/operator-rs.git"; - rev = "59506c6202778889a27b6ae8153457e60a49c68d"; - sha256 = "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5"; + rev = "e5bfee596cc918b05f3e1d7e667c25951317cf31"; + sha256 = "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1"; }; procMacro = true; libName = "stackable_operator_derive"; @@ -7429,8 +7429,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/stackabletech/operator-rs.git"; - rev = "59506c6202778889a27b6ae8153457e60a49c68d"; - sha256 = "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5"; + rev = "e5bfee596cc918b05f3e1d7e667c25951317cf31"; + sha256 = "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1"; }; libName = "stackable_shared"; authors = [ diff --git a/Cargo.toml b/Cargo.toml index 3a1f6c62..3c7b54e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" snafu = "0.8" -stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", tag = "stackable-operator-0.85.0" } +stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", tag = "stackable-operator-0.86.0" } strum = { version = "0.26", features = ["derive"] } tokio = { version = "1.40", features = ["full"] } tracing = "0.1" diff --git a/crate-hashes.json b/crate-hashes.json index 290d87f2..7edbbe57 100644 --- a/crate-hashes.json +++ b/crate-hashes.json @@ -1,6 +1,6 @@ { - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#stackable-operator-derive@0.3.1": "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#stackable-operator@0.85.0": "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.85.0#stackable-shared@0.0.1": "0rh476rmn5850yj85hq8znwmlfhd7l5bkxz0n5i9m4cddxhi2cl5", + "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#stackable-operator-derive@0.3.1": "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1", + "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#stackable-operator@0.86.0": "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1", + "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.86.0#stackable-shared@0.0.1": "04a866w46mbrsqv7iq9x6l2kh1bnykkmfnjwwfrqk6njn91arvf1", "git+https://github.com/stackabletech/product-config.git?tag=0.7.0#product-config@0.7.0": "0gjsm80g6r75pm3824dcyiz4ysq1ka4c1if6k1mjm9cnd5ym0gny" } \ No newline at end of file diff --git a/deploy/helm/superset-operator/crds/crds.yaml b/deploy/helm/superset-operator/crds/crds.yaml index 935b5bc1..686550e4 100644 --- a/deploy/helm/superset-operator/crds/crds.yaml +++ b/deploy/helm/superset-operator/crds/crds.yaml @@ -71,6 +71,46 @@ spec: - authenticationClass type: object type: array + authorization: + description: |- + Authorization options for Superset. + + Currently only role assignment is supported. This means that roles are assigned to users in OPA but, due to the way Superset is implemented, the database also needs to be updated to reflect these assignments. Therefore, user roles and permissions must already exist in the Superset database before they can be assigned to a user. Warning: Any user roles assigned with the Superset UI are discarded. + nullable: true + properties: + roleMappingFromOpa: + description: Configure the OPA stacklet [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery) and the name of the Rego package containing your authorization rules. Consult the [OPA authorization documentation](https://docs.stackable.tech/home/nightly/concepts/opa) to learn how to deploy Rego authorization rules with OPA. + properties: + cache: + default: + entryTimeToLive: 30s + maxEntries: 10000 + description: Configuration for an Superset internal cache for calls to OPA + properties: + entryTimeToLive: + default: 30s + description: Time to live per entry + type: string + maxEntries: + default: 10000 + description: Maximum number of entries in the cache; If this threshold is reached then the least recently used item is removed. + format: uint32 + minimum: 0.0 + type: integer + type: object + configMapName: + description: The [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery) for the OPA stacklet that should be used for authorization requests. + type: string + package: + description: The name of the Rego package containing the Rego rules for the product. + nullable: true + type: string + required: + - configMapName + type: object + required: + - roleMappingFromOpa + type: object clusterOperation: default: reconciliationPaused: false diff --git a/docs/modules/superset/pages/usage-guide/security.adoc b/docs/modules/superset/pages/usage-guide/security.adoc index ca1e49d4..27ac1c75 100644 --- a/docs/modules/superset/pages/usage-guide/security.adoc +++ b/docs/modules/superset/pages/usage-guide/security.adoc @@ -126,6 +126,71 @@ Further information for specifying an AuthenticationClass for an OIDC provider c Superset has a concept called `Roles` which allows you to grant user permissions based on roles. Have a look at the {superset-security}[Superset documentation on Security^{external-link-icon}^]. +[opa] +=== OPA role mapping + +Stackable ships a custom security manager that makes it possible to assign roles to users via the Open Policy Agent integration. +The roles must exist in the Superset database before they can be assigned to users. +If a role is not present in the Superset database, an error will be logged by the security manager and the user login will proceed without it. +Also the role names must match exactly the output of the Rego rule named `user_roles`. +In the following example, a rego package is defined that assigns roles to the users `admin` and `guest`. + +[source,yaml] +---- +apiVersion: v1 +kind: ConfigMap +metadata: + name: superset-opa-regorules + labels: + opa.stackable.tech/bundle: "true" +data: + roles.rego: | + package superset + + default user_roles := [] + + user_roles := roles if { + some user in users + roles := user.roles + user.username == input.username + } + users := [ + {"username": "admin", "roles": ["Admin", "Test"]}, #<1> + {"username": "guest", "roles": ["Gamma"]} #<2> + ] +---- + +<1> Assign the roles `Admin` and `Test` to the `admin` user. The `Test` role is not a standard Superset role and must be created before the assignment. +<2> Assign the `Gamma` role to the `guest` user. + +OPA rules can make use of the xref:opa:usage-guide:user-info-fetcher[user-info-fetcher] integration. + +The following snippet shows how to use the OPA security manager in a Superset stacklet. + +[source,yaml] +---- +apiVersion: superset.stackable.tech/v1alpha1 +kind: SupersetCluster +metadata: + name: superset-with-opa-role-mapping +spec: + clusterConfig: + authorization: + roleMappingFromOpa: + configMapName: superset-opa-regorules # <1> + package: superset + cache: # <2> + entryTimeToLive: 10s # <3> + maxEntries: 5 # <4> +---- + +<1> ConfigMap name containing rego rules +<2> Mandatory Opa caching. If not set, default settings apply. +<3> Time for cached entries per user can live. Defaults to 30s. +<4> Number of maximum entries, defaults to 1000. Cache will be disabled for maxEntries: 0. + +IMPORTANT: Any role assignments done in the Superset UI are discarded and will be overridden by the OPA security manager. + === Superset database You can view all the available roles in the web interface of Superset and can also assign users to these roles. diff --git a/rust/crd/src/lib.rs b/rust/crd/src/lib.rs index 837f18ae..a33ea587 100644 --- a/rust/crd/src/lib.rs +++ b/rust/crd/src/lib.rs @@ -7,7 +7,9 @@ use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::{ commons::{ affinity::StackableAffinity, + cache::UserInformationCache, cluster_operation::ClusterOperation, + opa::OpaConfig, product_image_selection::ProductImage, resources::{ CpuLimitsFragment, MemoryLimitsFragment, NoRuntimeLimits, NoRuntimeLimitsFragment, @@ -89,6 +91,12 @@ pub enum SupersetConfigOptions { AuthLdapTlsCertfile, AuthLdapTlsKeyfile, AuthLdapTlsCacertfile, + CustomSecurityManager, + AuthOpaRequestUrl, + AuthOpaPackage, + AuthOpaRule, + AuthOpaCacheMaxEntries, + AuthOpaCacheTtlInSec, } impl SupersetConfigOptions { @@ -119,6 +127,7 @@ impl FlaskAppConfigOptions for SupersetConfigOptions { SupersetConfigOptions::LoggingConfigurator => PythonType::Expression, SupersetConfigOptions::AuthType => PythonType::Expression, SupersetConfigOptions::AuthUserRegistration => PythonType::BoolLiteral, + // Going to be an expression as we default it from env, if and only if opa is used SupersetConfigOptions::AuthUserRegistrationRole => PythonType::StringLiteral, SupersetConfigOptions::AuthRolesSyncAtLogin => PythonType::BoolLiteral, SupersetConfigOptions::AuthLdapServer => PythonType::StringLiteral, @@ -136,6 +145,13 @@ impl FlaskAppConfigOptions for SupersetConfigOptions { SupersetConfigOptions::AuthLdapTlsCertfile => PythonType::StringLiteral, SupersetConfigOptions::AuthLdapTlsKeyfile => PythonType::StringLiteral, SupersetConfigOptions::AuthLdapTlsCacertfile => PythonType::StringLiteral, + // Configuration options used by CustomOpaSecurityManager + SupersetConfigOptions::CustomSecurityManager => PythonType::Expression, + SupersetConfigOptions::AuthOpaRequestUrl => PythonType::StringLiteral, + SupersetConfigOptions::AuthOpaPackage => PythonType::StringLiteral, + SupersetConfigOptions::AuthOpaRule => PythonType::StringLiteral, + SupersetConfigOptions::AuthOpaCacheMaxEntries => PythonType::IntLiteral, + SupersetConfigOptions::AuthOpaCacheTtlInSec => PythonType::IntLiteral, } } } @@ -179,6 +195,17 @@ pub struct SupersetClusterConfig { #[serde(default)] pub authentication: Vec, + /// Authorization options for Superset. + /// + /// Currently only role assignment is supported. This means that roles are assigned to users in + /// OPA but, due to the way Superset is implemented, the database also needs to be updated + /// to reflect these assignments. + /// Therefore, user roles and permissions must already exist in the Superset database before + /// they can be assigned to a user. + /// Warning: Any user roles assigned with the Superset UI are discarded. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization: Option, + /// The name of the Secret object containing the admin user credentials and database connection details. /// Read the /// [getting started guide first steps](DOCS_BASE_URL_PLACEHOLDER/superset/getting_started/first_steps) @@ -242,6 +269,22 @@ impl CurrentlySupportedListenerClasses { } } } +#[derive(Clone, Deserialize, Serialize, Eq, JsonSchema, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SupersetOpaRoleMappingConfig { + #[serde(flatten)] + pub opa: OpaConfig, + + /// Configuration for an Superset internal cache for calls to OPA + #[serde(default)] + pub cache: UserInformationCache, +} + +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupersetAuthorization { + pub role_mapping_from_opa: SupersetOpaRoleMappingConfig, +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -476,6 +519,14 @@ impl SupersetCluster { } } + pub fn get_opa_config(&self) -> Option<&SupersetOpaRoleMappingConfig> { + self.spec + .cluster_config + .authorization + .as_ref() + .map(|a| &a.role_mapping_from_opa) + } + /// Retrieve and merge resource configs for role and role groups pub fn merged_config( &self, diff --git a/rust/operator-binary/src/authorization/mod.rs b/rust/operator-binary/src/authorization/mod.rs new file mode 100644 index 00000000..932ba472 --- /dev/null +++ b/rust/operator-binary/src/authorization/mod.rs @@ -0,0 +1 @@ +pub mod opa; diff --git a/rust/operator-binary/src/authorization/opa.rs b/rust/operator-binary/src/authorization/opa.rs new file mode 100644 index 00000000..c1898120 --- /dev/null +++ b/rust/operator-binary/src/authorization/opa.rs @@ -0,0 +1,55 @@ +use std::collections::BTreeMap; + +use stackable_operator::{client::Client, commons::opa::OpaApiVersion, time::Duration}; +use stackable_superset_crd::{SupersetCluster, SupersetOpaRoleMappingConfig}; + +pub const OPA_IMPORTS: &[&str] = + &["from opa_authorizer.opa_manager import OpaSupersetSecurityManager"]; + +pub struct SupersetOpaConfigResolved { + opa_endpoint: String, + cache_max_entries: u32, + cache_ttl: Duration, +} + +impl SupersetOpaConfigResolved { + pub async fn from_opa_config( + client: &Client, + superset: &SupersetCluster, + opa_config: &SupersetOpaRoleMappingConfig, + ) -> Result { + let opa_endpoint = opa_config + .opa + .full_document_url_from_config_map(client, superset, None, OpaApiVersion::V1) + .await?; + + Ok(SupersetOpaConfigResolved { + opa_endpoint, + cache_max_entries: opa_config.cache.max_entries.to_owned(), + cache_ttl: opa_config.cache.entry_time_to_live.to_owned(), + }) + } + + // Adding necessary configurations. Imports are solved in config.rs + pub fn as_config(&self) -> BTreeMap { + BTreeMap::from([ + ( + "CUSTOM_SECURITY_MANAGER".to_string(), + "OpaSupersetSecurityManager".to_string(), + ), + ( + "AUTH_OPA_REQUEST_URL".to_string(), + self.opa_endpoint.to_owned(), + ), + ( + "AUTH_OPA_CACHE_MAX_ENTRIES".to_string(), + self.cache_max_entries.to_string(), + ), + ( + "AUTH_OPA_CACHE_TTL_IN_SEC".to_string(), + self.cache_ttl.as_secs().to_string(), + ), + ("AUTH_OPA_RULE".to_string(), "user_roles".to_string()), + ]) + } +} diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 15a69c34..d02f3445 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -29,6 +29,7 @@ use crate::{ superset_controller::SUPERSET_FULL_CONTROLLER_NAME, }; +mod authorization; mod commands; mod config; mod controller_commons; diff --git a/rust/operator-binary/src/superset_controller.rs b/rust/operator-binary/src/superset_controller.rs index 2fb54405..f492b3ec 100644 --- a/rust/operator-binary/src/superset_controller.rs +++ b/rust/operator-binary/src/superset_controller.rs @@ -76,6 +76,7 @@ use stackable_superset_crd::{ use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ + authorization::opa::{SupersetOpaConfigResolved, OPA_IMPORTS}, commands::add_cert_to_python_certifi_command, config::{self, PYTHON_IMPORTS}, controller_commons::{self, CONFIG_VOLUME_NAME, LOG_CONFIG_VOLUME_NAME, LOG_VOLUME_NAME}, @@ -286,6 +287,11 @@ pub enum Error { InvalidSupersetCluster { source: error_boundary::InvalidObject, }, + + #[snafu(display("invalid OPA config"))] + InvalidOpaConfig { + source: stackable_operator::commons::opa::Error, + }, } type Result = std::result::Result; @@ -374,6 +380,15 @@ pub async fn reconcile_superset( ) .context(CreateClusterResourcesSnafu)?; + let superset_opa_config = match superset.get_opa_config() { + Some(opa_config) => Some( + SupersetOpaConfigResolved::from_opa_config(client, superset, opa_config) + .await + .context(InvalidOpaConfigSnafu)?, + ), + None => None, + }; + let (rbac_sa, rbac_rolebinding) = build_rbac_resources( superset, APP_NAME, @@ -415,6 +430,7 @@ pub async fn reconcile_superset( &rolegroup, rolegroup_config, &auth_config, + &superset_opa_config, &config.logging, vector_aggregator_address.as_deref(), )?; @@ -536,17 +552,19 @@ fn build_node_role_service( } /// The rolegroup [`ConfigMap`] configures the rolegroup based on the configuration given by the administrator +#[allow(clippy::too_many_arguments)] fn build_rolegroup_config_map( superset: &SupersetCluster, resolved_product_image: &ResolvedProductImage, rolegroup: &RoleGroupRef, rolegroup_config: &HashMap>, authentication_config: &SupersetClientAuthenticationDetailsResolved, + superset_opa_config: &Option, logging: &Logging, vector_aggregator_address: Option<&str>, ) -> Result { let mut config_properties = BTreeMap::new(); - + let mut imports = PYTHON_IMPORTS.to_vec(); // TODO: this is true per default for versions 3.0.0 and up. // We deactivate it here to keep existing functionality. // However this is a security issue and should be configured properly @@ -556,6 +574,15 @@ fn build_rolegroup_config_map( config::add_superset_config(&mut config_properties, authentication_config) .context(AddSupersetConfigSnafu)?; + // Adding opa configuration properties to config_properties. + // This will be injected as key/value pair in superset_config.py + if let Some(opa_config) = superset_opa_config { + // If opa role mapping is configured, insert CustomOpaSecurityManager import + imports.extend(OPA_IMPORTS); + + config_properties.extend(opa_config.as_config()); + } + // The order here should be kept in order to preserve overrides. // No properties should be added after this extend. config_properties.extend( @@ -579,7 +606,7 @@ fn build_rolegroup_config_map( flask_app_config_writer::write::( &mut config_file, config_properties.iter(), - PYTHON_IMPORTS, + &imports, ) .with_context(|_| BuildRoleGroupConfigFileSnafu { rolegroup: rolegroup.clone(), diff --git a/tests/release.yaml b/tests/release.yaml index 76b389a8..f757f9fe 100644 --- a/tests/release.yaml +++ b/tests/release.yaml @@ -16,3 +16,5 @@ releases: operatorVersion: 0.0.0-dev superset: operatorVersion: 0.0.0-dev + opa: + operatorVersion: 0.0.0-dev diff --git a/tests/templates/kuttl/ldap/login.py b/tests/templates/kuttl/ldap/login.py index d058d1c9..1099c41e 100644 --- a/tests/templates/kuttl/ldap/login.py +++ b/tests/templates/kuttl/ldap/login.py @@ -2,18 +2,25 @@ import sys import logging -if __name__ == '__main__': +if __name__ == "__main__": result = 0 - log_level = 'DEBUG' # if args.debug else 'INFO' - logging.basicConfig(level=log_level, format='%(asctime)s %(levelname)s: %(message)s', stream=sys.stdout) + log_level = "DEBUG" # if args.debug else 'INFO' + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)s: %(message)s", + stream=sys.stdout, + ) - http_code = requests.post('http://superset-with-ldap-node-default:8088/api/v1/security/login', json={ - 'username': 'integrationtest', - 'password': 'integrationtest', - 'provider': 'ldap', - 'refresh': 'true', - }).status_code + http_code = requests.post( + "http://superset-with-ldap-node-default:8088/api/v1/security/login", + json={ + "username": "integrationtest", + "password": "integrationtest", + "provider": "ldap", + "refresh": "true", + }, + ).status_code if http_code != 200: result = 1 diff --git a/tests/templates/kuttl/logging/test_log_aggregation.py b/tests/templates/kuttl/logging/test_log_aggregation.py index 3fcc974e..7e8c18b3 100755 --- a/tests/templates/kuttl/logging/test_log_aggregation.py +++ b/tests/templates/kuttl/logging/test_log_aggregation.py @@ -4,9 +4,9 @@ def check_sent_events(): response = requests.post( - 'http://superset-vector-aggregator:8686/graphql', + "http://superset-vector-aggregator:8686/graphql", json={ - 'query': """ + "query": """ { transforms(first:100) { nodes { @@ -20,29 +20,31 @@ def check_sent_events(): } } """ - } + }, ) - assert response.status_code == 200, \ - 'Cannot access the API of the vector aggregator.' + assert ( + response.status_code == 200 + ), "Cannot access the API of the vector aggregator." result = response.json() - transforms = result['data']['transforms']['nodes'] + transforms = result["data"]["transforms"]["nodes"] for transform in transforms: - sentEvents = transform['metrics']['sentEventsTotal'] - componentId = transform['componentId'] + sentEvents = transform["metrics"]["sentEventsTotal"] + componentId = transform["componentId"] + + if componentId == "filteredInvalidEvents": + assert ( + sentEvents is None or sentEvents["sentEventsTotal"] == 0 + ), "Invalid log events were sent." - if componentId == 'filteredInvalidEvents': - assert sentEvents is None or \ - sentEvents['sentEventsTotal'] == 0, \ - 'Invalid log events were sent.' else: - assert sentEvents is not None and \ - sentEvents['sentEventsTotal'] > 0, \ - f'No events were sent in "{componentId}".' + assert ( + sentEvents is not None and sentEvents["sentEventsTotal"] > 0 + ), f'No events were sent in "{componentId}".' -if __name__ == '__main__': +if __name__ == "__main__": check_sent_events() - print('Test successful!') + print("Test successful!") diff --git a/tests/templates/kuttl/oidc/10-install-postgresql.yaml b/tests/templates/kuttl/oidc/10-install-postgresql.yaml index 50c5ad67..a9fc0d36 100644 --- a/tests/templates/kuttl/oidc/10-install-postgresql.yaml +++ b/tests/templates/kuttl/oidc/10-install-postgresql.yaml @@ -6,7 +6,7 @@ commands: helm install superset-postgresql --namespace $NAMESPACE --version 12.5.6 - -f helm-bitnami-postgresql-values.yaml + -f 10_helm-bitnami-postgresql-values.yaml --repo https://charts.bitnami.com/bitnami postgresql --wait timeout: 600 diff --git a/tests/templates/kuttl/oidc/helm-bitnami-postgresql-values.yaml.j2 b/tests/templates/kuttl/oidc/10_helm-bitnami-postgresql-values.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/helm-bitnami-postgresql-values.yaml.j2 rename to tests/templates/kuttl/oidc/10_helm-bitnami-postgresql-values.yaml.j2 diff --git a/tests/templates/kuttl/oidc/30-install-keycloak.yaml b/tests/templates/kuttl/oidc/30-keycloak.yaml similarity index 79% rename from tests/templates/kuttl/oidc/30-install-keycloak.yaml rename to tests/templates/kuttl/oidc/30-keycloak.yaml index 33104195..13edf988 100644 --- a/tests/templates/kuttl/oidc/30-install-keycloak.yaml +++ b/tests/templates/kuttl/oidc/30-keycloak.yaml @@ -12,7 +12,7 @@ commands: PASSWORD=T8mn72D9 \ CLIENT_ID=superset1 \ CLIENT_SECRET=R1bxHUD569vHeQdw \ - envsubst < install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - + envsubst < 30_install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - INSTANCE_NAME=keycloak2 \ REALM=test2 \ @@ -23,4 +23,4 @@ commands: PASSWORD=NvfpU518 \ CLIENT_ID=superset2 \ CLIENT_SECRET=scWzh0D4v0GN8NrN \ - envsubst < install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - + envsubst < 30_install-keycloak.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/install-keycloak.yaml.j2 b/tests/templates/kuttl/oidc/30_install-keycloak.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/install-keycloak.yaml.j2 rename to tests/templates/kuttl/oidc/30_install-keycloak.yaml.j2 diff --git a/tests/templates/kuttl/oidc/40-install-superset.yaml b/tests/templates/kuttl/oidc/40-install-superset.yaml index 0cba3c7b..c97f66e8 100644 --- a/tests/templates/kuttl/oidc/40-install-superset.yaml +++ b/tests/templates/kuttl/oidc/40-install-superset.yaml @@ -4,5 +4,5 @@ kind: TestStep timeout: 300 commands: - script: > - envsubst '$NAMESPACE' < install-superset.yaml | + envsubst '$NAMESPACE' < 40_install-superset.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/install-superset.yaml.j2 b/tests/templates/kuttl/oidc/40_install-superset.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/install-superset.yaml.j2 rename to tests/templates/kuttl/oidc/40_install-superset.yaml.j2 diff --git a/tests/templates/kuttl/oidc/60-assert.yaml b/tests/templates/kuttl/oidc/60-assert.yaml index fee66ceb..df17232b 100644 --- a/tests/templates/kuttl/oidc/60-assert.yaml +++ b/tests/templates/kuttl/oidc/60-assert.yaml @@ -5,4 +5,4 @@ metadata: name: login timeout: 300 commands: - - script: kubectl exec -n $NAMESPACE python-0 -- python /stackable/login.py + - script: kubectl exec -n $NAMESPACE python-0 -- python /stackable/60_login.py diff --git a/tests/templates/kuttl/oidc/60-login.yaml b/tests/templates/kuttl/oidc/60-login.yaml index 0745bc4b..e5ca3052 100644 --- a/tests/templates/kuttl/oidc/60-login.yaml +++ b/tests/templates/kuttl/oidc/60-login.yaml @@ -6,4 +6,4 @@ metadata: commands: - script: > envsubst '$NAMESPACE' < login.py | - kubectl exec -n $NAMESPACE -i python-0 -- tee /stackable/login.py > /dev/null + kubectl exec -n $NAMESPACE -i python-0 -- tee /stackable/60_login.py > /dev/null diff --git a/tests/templates/kuttl/oidc/login.py b/tests/templates/kuttl/oidc/60_login.py similarity index 69% rename from tests/templates/kuttl/oidc/login.py rename to tests/templates/kuttl/oidc/60_login.py index 91e28eb8..8ad0c966 100644 --- a/tests/templates/kuttl/oidc/login.py +++ b/tests/templates/kuttl/oidc/60_login.py @@ -2,14 +2,13 @@ import json import logging -import requests import sys +import requests from bs4 import BeautifulSoup logging.basicConfig( - level='DEBUG', - format="%(asctime)s %(levelname)s: %(message)s", - stream=sys.stdout) + level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout +) session = requests.Session() @@ -17,40 +16,45 @@ login_page = session.get("http://superset-external:8088/login/keycloak?next=") assert login_page.ok, "Redirection from Superset 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=superset1"), \ - "Redirection to the Keycloak login page expected" +assert login_page.url.startswith( + "https://keycloak1.$NAMESPACE.svc.cluster.local:8443/realms/test1/protocol/openid-connect/auth?response_type=code&client_id=superset1" +), "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" -}) +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://superset-external:8088/superset/welcome/", \ - "Redirection to the Superset welcome page expected" +assert ( + welcome_page.url == "http://superset-external:8088/superset/welcome/" +), "Redirection to the Superset welcome page expected" # Open the user information page in Superset userinfo_page = session.get("http://superset-external:8088/users/userinfo/") assert userinfo_page.ok, "Retrieving user information failed" -assert userinfo_page.url == "http://superset-external:8088/superset/welcome/", \ - "Redirection to the Superset welcome page expected" +assert ( + userinfo_page.url == "http://superset-external:8088/superset/welcome/" +), "Redirection to the Superset welcome page expected" # Expect the user data provided by Keycloak in Superset -userinfo_page_html = BeautifulSoup(userinfo_page.text, 'html.parser') -raw_data = userinfo_page_html.find(id='app')['data-bootstrap'] +userinfo_page_html = BeautifulSoup(userinfo_page.text, "html.parser") +raw_data = userinfo_page_html.find(id="app")["data-bootstrap"] data = json.loads(raw_data) -user_data = data['user'] +user_data = data["user"] -assert user_data['firstName'] == "Jane", \ - "The first name of the user in Superset should match the one provided by Keycloak" -assert user_data['lastName'] == "Doe", \ - "The last name of the user in Superset should match the one provided by Keycloak" -assert user_data['email'] == "jane.doe@stackable.tech", \ - "The email of the user in Superset should match the one provided by Keycloak" +assert ( + user_data["firstName"] == "Jane" +), "The first name of the user in Superset should match the one provided by Keycloak" +assert ( + user_data["lastName"] == "Doe" +), "The last name of the user in Superset should match the one provided by Keycloak" +assert ( + user_data["email"] == "jane.doe@stackable.tech" +), "The email of the user in Superset should match the one provided by Keycloak" # TODO Use different OIDC providers (currently only Keycloak is # supported) diff --git a/tests/templates/kuttl/opa/00-patch-ns.yaml.j2 b/tests/templates/kuttl/opa/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/opa/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/opa/05-assert.yaml.j2 b/tests/templates/kuttl/opa/05-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/opa/05-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/opa/05-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/opa/05-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/opa/05-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/opa/10-assert.yaml b/tests/templates/kuttl/opa/10-assert.yaml new file mode 100644 index 00000000..e9c60b15 --- /dev/null +++ b/tests/templates/kuttl/opa/10-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-superset-postgresql +timeout: 480 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: superset-postgresql +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/opa/10-install-postgresql.yaml b/tests/templates/kuttl/opa/10-install-postgresql.yaml new file mode 100644 index 00000000..a9fc0d36 --- /dev/null +++ b/tests/templates/kuttl/opa/10-install-postgresql.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: >- + helm install superset-postgresql + --namespace $NAMESPACE + --version 12.5.6 + -f 10_helm-bitnami-postgresql-values.yaml + --repo https://charts.bitnami.com/bitnami postgresql + --wait + timeout: 600 diff --git a/tests/templates/kuttl/opa/10_helm-bitnami-postgresql-values.yaml.j2 b/tests/templates/kuttl/opa/10_helm-bitnami-postgresql-values.yaml.j2 new file mode 100644 index 00000000..7991d27e --- /dev/null +++ b/tests/templates/kuttl/opa/10_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: superset + password: superset + database: superset diff --git a/tests/templates/kuttl/opa/20-assert.yaml b/tests/templates/kuttl/opa/20-assert.yaml new file mode 100644 index 00000000..943a1340 --- /dev/null +++ b/tests/templates/kuttl/opa/20-assert.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/opa/20-install-keycloak.yaml.j2 b/tests/templates/kuttl/opa/20-install-keycloak.yaml.j2 new file mode 100644 index 00000000..d8f3c96e --- /dev/null +++ b/tests/templates/kuttl/opa/20-install-keycloak.yaml.j2 @@ -0,0 +1,163 @@ +--- +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 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl apply -n $NAMESPACE -f - << EOF + --- + apiVersion: secrets.stackable.tech/v1alpha1 + kind: SecretClass + metadata: + name: keycloak-tls-$NAMESPACE + spec: + backend: + autoTls: + ca: + autoGenerate: true + secret: + name: tls + namespace: $NAMESPACE + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: keycloak + labels: + app: keycloak + spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + serviceAccountName: keycloak + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:23.0.4 + args: + - start + - --hostname-strict=false + - --https-key-store-file=/tls/keystore.p12 + - --https-key-store-password=changeit + - --import-realm + env: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-admin-credentials + key: admin + - name: USER_INFO_FETCHER_CLIENT_ID + valueFrom: + secretKeyRef: + name: user-info-fetcher-client-credentials + key: clientId + - name: USER_INFO_FETCHER_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: user-info-fetcher-client-credentials + key: clientSecret + ports: + - name: https + containerPort: 8443 + readinessProbe: + httpGet: + scheme: HTTPS + path: /realms/master + port: https + resources: + limits: + cpu: 1 + memory: 1024Mi + requests: + cpu: 500m + memory: 1024Mi + volumeMounts: + - name: data + mountPath: /opt/keycloak/data/ + - name: tls + mountPath: /tls/ + - name: realm-volume + mountPath: /opt/keycloak/data/import + securityContext: + fsGroup: 1000 + runAsGroup: 1000 + runAsUser: 1000 + volumes: + - name: data + emptyDir: {} + - name: tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: keycloak-tls-$NAMESPACE + secrets.stackable.tech/format: tls-pkcs12 + secrets.stackable.tech/format.compatibility.tls-pkcs12.password: changeit + secrets.stackable.tech/scope: service=keycloak,node + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" + - name: realm-volume + configMap: + name: keycloak-my-dataspace-realm + --- + apiVersion: v1 + kind: Secret + metadata: + name: keycloak-admin-credentials + stringData: + admin: "adminadmin" + --- + apiVersion: v1 + kind: Service + metadata: + name: keycloak + labels: + app: keycloak + spec: + ports: + - name: https + port: 8443 + targetPort: 8443 + selector: + app: keycloak + EOF diff --git a/tests/templates/kuttl/opa/20-keycloak-realm-cm.yaml b/tests/templates/kuttl/opa/20-keycloak-realm-cm.yaml new file mode 100644 index 00000000..14cc8faf --- /dev/null +++ b/tests/templates/kuttl/opa/20-keycloak-realm-cm.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: user-info-fetcher-client-credentials +stringData: + clientId: user-info-fetcher + clientSecret: user-info-fetcher-client-secret +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycloak-my-dataspace-realm +data: + realm.json: | + { + "realm" : "my-dataspace", + "enabled" : true, + "groups" : [ + { + "name" : "Admin", + "path" : "/Admin" + }, + { + "name": "Test", + "path": "/Test" + }], + "users" : [ + { + "username" : "service-account-user-info-fetcher", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "serviceAccountClientId" : "user-info-fetcher", + "credentials" : [ ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-my-dataspace" ], + "clientRoles" : { + "realm-management" : [ + "view-users" + ] + }, + "notBefore" : 0, + "groups" : [ ] + }, + { + "username" : "admin", + "enabled" : true, + "emailVerified" : true, + "firstName" : "admin", + "lastName" : "admin", + "email" : "admin@example.com", + "credentials" : [ { + "type" : "password", + "userLabel" : "My password", + "secretData" : "{\"value\":\"JxIyEshkBUrhZX1BEN9JO8EM3ue5/SnGHDfuyTqOH6A=\",\"salt\":\"f6iCn2rWqZQaRnCCsKAoQQ==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "realmRoles" : [ ], + "groups" : [ "/Admin", "/Test" ] + } + ], + "clients" : [ { + "clientId" : "${USER_INFO_FETCHER_CLIENT_ID}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "${USER_INFO_FETCHER_CLIENT_SECRET}", + "redirectUris" : [ "/*" ], + "webOrigins" : [ "/*" ], + "notBefore" : 0, + "bearerOnly" : false, + "serviceAccountsEnabled" : true, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "true", + "oauth2.device.authorization.grant.enabled" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true + } ] + } diff --git a/tests/templates/kuttl/opa/30-assert.yaml b/tests/templates/kuttl/opa/30-assert.yaml new file mode 100644 index 00000000..e868cdaf --- /dev/null +++ b/tests/templates/kuttl/opa/30-assert.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +commands: + - script: kubectl -n $NAMESPACE rollout status daemonset opa-server-default --timeout 300s diff --git a/tests/templates/kuttl/opa/30-install-opa.yaml.j2 b/tests/templates/kuttl/opa/30-install-opa.yaml.j2 new file mode 100644 index 00000000..46b4599d --- /dev/null +++ b/tests/templates/kuttl/opa/30-install-opa.yaml.j2 @@ -0,0 +1,44 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl apply -n $NAMESPACE -f - < 0 }} + containers: + opa: + loggers: + decision: + level: INFO + roleGroups: + default: {} + EOF diff --git a/tests/templates/kuttl/opa/31-opa-rego.yaml b/tests/templates/kuttl/opa/31-opa-rego.yaml new file mode 100644 index 00000000..18f1d3dd --- /dev/null +++ b/tests/templates/kuttl/opa/31-opa-rego.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-superset-uif-rego + labels: + opa.stackable.tech/bundle: "true" +data: + roles.rego: | + package superset + + user_roles := roles if { + group_paths := data.stackable.opa.userinfo.v1.userInfoByUsername(input.username).groups + roles := [ trim(group,"/") | group := group_paths[_] ] + } diff --git a/tests/templates/kuttl/opa/40-assert.yaml b/tests/templates/kuttl/opa/40-assert.yaml new file mode 100644 index 00000000..3eb18400 --- /dev/null +++ b/tests/templates/kuttl/opa/40-assert.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: install-superset +timeout: 300 +commands: + - script: kubectl -n $NAMESPACE wait --for=condition=available=true supersetclusters.superset.stackable.tech/superset --timeout 301s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: superset-node-default +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/opa/40-install-superset.yaml b/tests/templates/kuttl/opa/40-install-superset.yaml new file mode 100644 index 00000000..b525a1b8 --- /dev/null +++ b/tests/templates/kuttl/opa/40-install-superset.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 300 +commands: + - script: > + envsubst '$NAMESPACE' < 40_superset.yaml | + kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/opa/40_superset.yaml.j2 b/tests/templates/kuttl/opa/40_superset.yaml.j2 new file mode 100644 index 00000000..3d6a5d15 --- /dev/null +++ b/tests/templates/kuttl/opa/40_superset.yaml.j2 @@ -0,0 +1,64 @@ +# $NAMESPACE will be replaced with the namespace of the test case. +--- +apiVersion: v1 +kind: Secret +metadata: + name: superset-credentials +type: Opaque +stringData: + adminUser.username: admin + adminUser.firstname: Superset + adminUser.lastname: Admin + adminUser.email: admin@superset.com + adminUser.password: admin + connections.secretKey: aQC11KVUJ3yTVcy2 + connections.sqlalchemyDatabaseUri: postgresql://superset:superset@superset-postgresql/superset +--- +apiVersion: superset.stackable.tech/v1alpha1 +kind: SupersetCluster +metadata: + name: superset +spec: + image: +{% if test_scenario['values']['superset'].find(",") > 0 %} + custom: "{{ test_scenario['values']['superset'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['superset'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['superset'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + authorization: + roleMappingFromOpa: + configMapName: opa + package: superset + credentialsSecret: superset-credentials +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + nodes: + configOverrides: + superset_config.py: + # Enable the security API to be able to create roles from the test + FAB_ADD_SECURITY_API: "True" + # Enable FAB logging + SILENCE_FAB: "False" + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + containers: + superset: + console: + level: DEBUG + file: + level: DEBUG + loggers: + ROOT: + level: DEBUG + flask_appbuilder.security: + level: DEBUG + opa_authorizer: + level: DEBUG + roleGroups: + default: + replicas: 1 diff --git a/tests/templates/kuttl/opa/50-get-user-roles-cm.yaml b/tests/templates/kuttl/opa/50-get-user-roles-cm.yaml new file mode 100644 index 00000000..9d00b1c7 --- /dev/null +++ b/tests/templates/kuttl/opa/50-get-user-roles-cm.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 300 +commands: + - script: > + kubectl create cm get-user-roles-script -n $NAMESPACE --from-file 50_get_user_roles.py diff --git a/tests/templates/kuttl/opa/50_get_user_roles.py b/tests/templates/kuttl/opa/50_get_user_roles.py new file mode 100644 index 00000000..7765d526 --- /dev/null +++ b/tests/templates/kuttl/opa/50_get_user_roles.py @@ -0,0 +1,133 @@ +# +# Use the FAB security API to create a new role named "Test". +# Use the UI to login and fetch the user roles (that are resolved from OPA). +# Check that that the user has both the "Admin" role as well as the newly created "Test" role. +# +import logging +import sys + +import requests +from bs4 import BeautifulSoup + +base_ui_url = "" +base_api_url = "" +bearer_token = "" +csrf_token = "" + + +def get_bearer_token() -> str: + payload = {"password": "admin", "provider": "db", "username": "admin"} + headers = {"Content-Type": "application/json"} + response = requests.request( + "POST", f"{base_api_url}/security/login", json=payload, headers=headers + ) + json: dict[str, object] = response.json() + logging.info(f"get_bearer_token response {json}") + return str(json["access_token"]) + + +def get_csrf_token() -> str: + headers = {"Authorization": f"Bearer {bearer_token}"} + response = requests.request( + "GET", f"{base_api_url}/security/csrf_token/", data="", headers=headers + ) + json: dict[str, object] = response.json() + logging.info(f"get_csrf_token response {json}") + return str(json["result"]) + + +def add_role(name: str): + headers = { + "X-CSRFToken": csrf_token, + "Authorization": f"Bearer {bearer_token}", + } + response = requests.request( + "POST", f"{base_api_url}/security/roles", json={"name": name}, headers=headers + ) + json: dict[str, object] = response.json() + logging.info(f"add_role response {json}") + + +def add_permissions_to_role(role_id: int, permissions: list[int]): + headers = { + "X-CSRFToken": csrf_token, + "Authorization": f"Bearer {bearer_token}", + } + response = requests.request( + "POST", + f"{base_api_url}/security/roles/{role_id}/permissions", + json={"permission_view_menu_ids": permissions}, + headers=headers, + ) + json: dict[str, object] = response.json() + logging.info(f"add_permissions_to_role response {json}") + + +def get_ui_roles() -> list[str]: + session = requests.Session() + + # Click on "Login" in Superset + login_page = session.get(f"{base_ui_url}/login/") + assert login_page.status_code == 200 + + login_page_html = BeautifulSoup(login_page.text, "html.parser") + csrf_token = login_page_html.find("input", id="csrf_token") + if csrf_token is None: + raise Exception("CSRF token not found in on the login page") + else: + csrf_token = csrf_token["value"] + + # Login with CSRF token + welcome_page = session.post( + f"{base_ui_url}/login/", + data={"username": "admin", "password": "admin", "csrf_token": csrf_token}, + ) + assert welcome_page.status_code == 200 + logging.debug(welcome_page.url) + + # Force roles to be loaded by the OPA security manager + # Assign to _ to shut up type checker + _ = session.get(f"{base_api_url}/dashboard/") + + response = session.get(f"{base_api_url}/me/roles/") + json: dict[str, object] = response.json() + + logging.info(f"get_ui_roles response {json}") + + return list(json["result"]["roles"].keys()) + + +def main(): + logging.basicConfig( + level="DEBUG", + format="%(asctime)s %(levelname)s: %(message)s", + stream=sys.stdout, + ) + + namespace = sys.argv[1] + + global base_ui_url + global base_api_url + global bearer_token + global csrf_token + + base_ui_url = f"http://superset-external.{namespace}.svc.cluster.local:8088" + base_api_url = f"http://superset-external.{namespace}.svc.cluster.local:8088/api/v1" + bearer_token = get_bearer_token() + csrf_token = get_csrf_token() + + # Create a new role and assign some permissions to it + add_role("Test") + # "6" is the new role id (Superset has 5 builtin roles) + add_permissions_to_role(6, list(range(3))) + + ui_user_roles = get_ui_roles() + + expected_roles = ["Admin", "Test"] + logging.debug(f"Expected roles: {expected_roles}") + logging.debug(f"Got UI user roles: {ui_user_roles}") + assert expected_roles == ui_user_roles + + +if __name__ == "__main__": + main() diff --git a/tests/templates/kuttl/opa/51-assert.yaml b/tests/templates/kuttl/opa/51-assert.yaml new file mode 100644 index 00000000..58987778 --- /dev/null +++ b/tests/templates/kuttl/opa/51-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/opa/51-install-test-container.yaml.j2 b/tests/templates/kuttl/opa/51-install-test-container.yaml.j2 new file mode 100644 index 00000000..b6de3aef --- /dev/null +++ b/tests/templates/kuttl/opa/51-install-test-container.yaml.j2 @@ -0,0 +1,87 @@ +--- +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 + - name: get-user-roles-script + mountPath: /tmp/scripts + env: + - name: REQUESTS_CA_BUNDLE + value: /stackable/tls/ca.crt + - name: SUPERSET_BASE_URL + value: superset-external.$NAMESPACE.svc.cluster.local:8088 + volumes: + - name: tls + csi: + driver: secrets.stackable.tech + volumeAttributes: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: pod + - name: get-user-roles-script + configMap: + name: get-user-roles-script diff --git a/tests/templates/kuttl/opa/52-test-user-roles.yaml.j2 b/tests/templates/kuttl/opa/52-test-user-roles.yaml.j2 new file mode 100644 index 00000000..57f54032 --- /dev/null +++ b/tests/templates/kuttl/opa/52-test-user-roles.yaml.j2 @@ -0,0 +1,7 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + # Need to wait until the superset API is ready before running the script + - script: | + sleep 30 + kubectl exec -n $NAMESPACE python-0 -i -- python /tmp/scripts/50_get_user_roles.py $NAMESPACE diff --git a/tests/templates/kuttl/smoke/login.py b/tests/templates/kuttl/smoke/login.py index 9607fb2b..eca166b3 100644 --- a/tests/templates/kuttl/smoke/login.py +++ b/tests/templates/kuttl/smoke/login.py @@ -2,13 +2,18 @@ import sys import logging -logging.basicConfig(level='DEBUG', format='%(asctime)s %(levelname)s: %(message)s', stream=sys.stdout) +logging.basicConfig( + level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout +) -http_code = requests.post("http://superset-node-default:8088/api/v1/security/login", json={ - "password": "admin", - "provider": "db", - "refresh": "true", - "username": "admin", -}).status_code +http_code = requests.post( + "http://superset-node-default:8088/api/v1/security/login", + json={ + "password": "admin", + "provider": "db", + "refresh": "true", + "username": "admin", + }, +).status_code assert http_code == 200, "Login failed." diff --git a/tests/templates/kuttl/smoke/metrics.py b/tests/templates/kuttl/smoke/metrics.py index 682207fb..cffaa151 100644 --- a/tests/templates/kuttl/smoke/metrics.py +++ b/tests/templates/kuttl/smoke/metrics.py @@ -11,5 +11,6 @@ metrics_response = requests.get("http://superset-node-default:9102/metrics") assert metrics_response.status_code == 200, "Metrics could not be retrieved." -assert "superset_welcome" in metrics_response.text, \ - "The metrics do not contain the superset_welcome counter." +assert ( + "superset_welcome" in metrics_response.text +), "The metrics do not contain the superset_welcome counter." diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index c9bd027f..3ce36205 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -13,11 +13,15 @@ dimensions: - name: superset-latest values: - 4.1.1 + # - 4.1.1,oci.stackable.tech/razvan/superset:4.1.1-stackable0.0.0-dev - name: ldap-authentication values: - no-tls - insecure-tls - server-verification-tls + - name: opa-latest + values: + - 1.0.1 - name: openshift values: - "false" @@ -43,6 +47,11 @@ tests: dimensions: - superset - openshift + - name: opa + dimensions: + - superset + - opa-latest + - openshift - name: resources dimensions: - superset-latest