diff --git a/deploy/helm/nifi-operator/crds/crds.yaml b/deploy/helm/nifi-operator/crds/crds.yaml index 7511f55a..fec50157 100644 --- a/deploy/helm/nifi-operator/crds/crds.yaml +++ b/deploy/helm/nifi-operator/crds/crds.yaml @@ -29,7 +29,7 @@ spec: description: Settings that affect all roles and role groups. The settings in the `clusterConfig` are cluster wide settings that do not need to be configurable at role or role group level. properties: authentication: - description: Authentication options for NiFi (required). Read more about authentication in the [security documentation](https://docs.stackable.tech/home/nightly/nifi/usage_guide/security). + description: Authentication options for NiFi (required). Read more about authentication in the [security documentation](https://docs.stackable.tech/home/nightly/nifi/usage_guide/security#authentication). items: properties: authenticationClass: @@ -55,6 +55,42 @@ spec: - authenticationClass type: object type: array + authorization: + description: Authorization options. Learn more in the [NiFi authorization usage guide](https://docs.stackable.tech/home/nightly/nifi/usage-guide/security#authorization). + nullable: true + properties: + opa: + 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. + nullable: true + properties: + cache: + default: + entryTimeToLive: 30s + maxEntries: 10000 + description: Least Recently Used (LRU) cache with per-entry time-to-live (TTL) value. + 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 + type: object createReportingTaskJob: default: enabled: true diff --git a/docs/modules/nifi/pages/usage_guide/security.adoc b/docs/modules/nifi/pages/usage_guide/security.adoc index f45e6e56..883ccebe 100644 --- a/docs/modules/nifi/pages/usage_guide/security.adoc +++ b/docs/modules/nifi/pages/usage_guide/security.adoc @@ -1,9 +1,14 @@ = Security :description: Secure Apache NiFi on Kubernetes with TLS, authentication, and authorization using the Stackable operator. Configure LDAP, OIDC, and sensitive data encryption. :nifi-docs-authorization: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#multi-tenant-authorization +:nifi-docs-access-policies: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#access-policies +:nifi-docs-component-level-access-policies: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#component-level-access-policies +:nifi-docs-access-policy-inheritance: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#access-policy-inheritance :nifi-docs-fileusergroupprovider: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#fileusergroupprovider :nifi-docs-fileaccesspolicyprovider: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#fileaccesspolicyprovider :nifi-docs-sensitive-properties-key: https://nifi.apache.org/docs/nifi-docs/html/administration-guide.html#nifi_sensitive_props_key +:nifi-opa-plugin: https://github.com/DavidGitter/nifi-opa-plugin/ +:opa-rego-docs: https://www.openpolicyagent.org/docs/latest/#rego == TLS @@ -166,9 +171,7 @@ stringData: [#authorization] == Authorization -NiFi supports {nifi-docs-authorization}[multiple authorization methods], the available authorization methods depend on the chosen authentication method. - -Authorization is not fully implemented by the Stackable Operator for Apache NiFi. +The Stackable Operator for Apache NiFi supports {nifi-docs-authorization}[multiple authorization methods], the available authorization methods depend on the chosen authentication method. Using Open Policy Agent for authorization is independent of the authentication method. [#authorization-single-user] === Single user @@ -190,6 +193,171 @@ With this authorization method, all authenticated users have administrator capab An admin user with an auto-generated password is created that can access the NiFi API. The password for this user is stored in a Kubernetes Secret called `-oidc-admin-password`. +[#authorization-opa] +=== Open Policy Agent (OPA) + +NiFi can be configured to delegate authorization decisions to an Open Policy Agent (OPA) instance. More information on the setup and configuration of OPA can be found in the xref:opa:index.adoc[OPA Operator documentation]. + +A NiFi cluster can be configured with OPA authorization by adding this section to the configuration: + +[source,yaml] +---- +spec: + clusterConfig: + authorization: + opa: + configMapName: simple-opa # <1> + package: my-nifi-rules # <2> + cache: + entryTimeToLive: 5s # <3> + maxEntries: 10 # <4> +---- +<1> The name of your OPA Stacklet (`simple-opa` in this case) +<2> The rego rule package to use for policy decisions. +The package needs to contain an `allow` rule. +This is optional and defaults to the name of the NiFi Stacklet. +<3> TTL for items in the cache in NiFi. Optional, defaults to 30 seconds. +<4> Maximum number of concurrent entries in the cache in NiFi. Optional, defaults to 10000 entries. + +[#defining-rego-rules] +=== Defining rego rules + +For a general explanation of how rules are written, please refer to the {opa-rego-docs}[OPA documentation]. Authorization with OPA is done using a {nifi-opa-plugin}[custom authorizer provided by a plugin for NiFi]. + +[#opa-inputs] +==== OPA Inputs +The payload sent by NiFi with each request to OPA, that is accessible within the rego rules, has the following structure: + +[cols="1,2,1"] +|=== +| Payload Field| Description| Possible Values +| action.name +| The action taken against the resource. +|`read`, `write` +| resource.id +| The unique identifier of the resource that is being authorized. This might be a parent component in the case of `resourceNotFound` is set to `true`. +| +| resource.name +| The name of the resource that is being authorized. This might be a parent component in the case of `resourceNotFound` is set to `true`. +| +| resource.safeDescription +| The description of the resource that is being authorized. +| +| requestedResource.id +| The unique identifier of the original resource that was requested (see <>). +| +| requestedResource.name +| The name of the original resource that is being authorized on (see <>). +| +| requestedResource.safeDescription +| The description of the original resource that is being authorized on (see <>). +| +| identity.name +| The name of the identity/user accessing the resource. +| +| identity.groups +| Comma-separated list of groups that the identity/user accessing the resource belongs to. +| +| properties.isAccessAttempt +| Whether this is a direct access attempt of the resource or if it's being checked as part of another response. +| `true`, `false` (String) +| isAnonymous +| Whether the entity accessing the resource is anonymous. +| `true`, `false` (String) +| resourceContext +| Object containing the event attributes to make additional access decisions for provenance events. +| ```{"": ""}``` if empty +| userContext +| Additional context for the user to make additional access decisions. +| ```{"": ""}``` if empty +|=== + +[#opa-result] +==== OPA Result + +The OPA authorizer plugin expects rego rules to be named `allow` and to return a result following this schema: +[source] +---- +{ + "allowed": , # <1> + "resourceNotFound": , # <2> + "dumpCache": , # <3> + "message": , # <4> +} +---- +<1> Whether the action against the resource is allowed. Optional, defaults to false. +<2> Whether no rule was found for the authorization request. This should only be set to true in the default rule to e.g. forward policy decisions to parent components. If set to true the value of the "allowed" field will be ignored. Optional, defaults to false. +<3> Whether the whole local cache in the OPA authorizer plugin in NiFi should be invalidated. Optional, defaults to false. +<4> An optional error message that is shown to the user when access is denied. + +[#access-policies] +==== Access Policies +NiFi uses {nifi-docs-access-policies}[access policies] to manage access to system-wide resources like the user interface. + +[#component-level-access-policies] +==== Component Level Access Policies and Access Policy Inheritance + +{nifi-docs-component-level-access-policies}[Component Level Access Policies] allow managing granular access to components like process-groups and processors. Components can {nifi-docs-access-policy-inheritance}[inherite access policies] defined for parent components, e.g. a process group is the parent component for a contained processor component. + +The payload field `requestedResource` contains the id, name and description of the original resource that was requested. In cases with inherited policies, this will be an ancestor resource of the current resource. For the initial request, and cases without inheritance, the requested resource will be the same as the current resource. + +When an authorizer returns "resourceNotFound" as result instead of an authorization decision, NiFi will send an authorization request for the parent component. Access policy inheritance can be recursive up to the root component. If "resourceNotFound" is returned for an authorization request and the component doesn't have a parent component, NiFi will deny access to the component. + +To manage access for all process groups in the NiFi instance a rule has to be defined for the root process group which is identified by the resource name "NiFi Flow" and a resource id generated at random ("/process-groups/"). + +[source,rego] +---- +default allow := { + "resourceNotFound": true +} # <1> + +allow := { + "allowed": true +} if { + input.resource.name == "NiFi Flow" + startswith(input.resource.id, "/process-groups") +} # <2> + +allow := { + "allowed": false +} if { + input.resource.id == "/process-groups/a10c311e-0196-1000-2856-dc0606d3c5d7" + input.identity.name == "alice" +} # <3> +---- +<1> The default rule should return `"resourceNotFound": true`. If this is not set, NiFi's access policy inheritance will not work. Any values for the `allowed` field in the response will be ignored. +<2> A rule that grants all users access to the root process group and thus to all components in the NiFi instance. +<3> A rule that denies access to a specific process group for the user "alice". For this process group the default rego rule will not be applied and NiFi's component inhertiance will not be used. All child components of this process group will also be authorized based on this rule unless a more granular rule overrides it. + +[#communication-between-nifi-nodes] +==== Communication between NiFi nodes +To allow communication between NiFi nodes an additional rego rule is required: +[source,rego] +---- +allow := { + "allowed": true +} if { + input.identity.name == "CN=generated certificate for pod" # <1> + input.resource.id == "/proxy" # <2> +} +---- +<1> The identity of NiFi nodes authenticated with TLS certificates provided by the secrets operator. +<2> Only access to the `/proxy` API is required. + +[#caching] +==== Caching + +The OPA authorizer has a mechanism to cache results from OPA which can be configured in the NifiCluster spec (see above). To delete the whole cache add `"dumpCache": true` to the result. +[source,rego] +---- +allow := { + "allowed": false + "dumpCache": true +} if { + ... +} +---- + [#encrypting-sensitive-properties] == Encrypting sensitive properties on disk diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index cca71af6..5e27bbf8 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -99,6 +99,7 @@ use crate::{ AUTHORIZERS_XML_FILE_NAME, LOGIN_IDENTITY_PROVIDERS_XML_FILE_NAME, NifiAuthenticationConfig, STACKABLE_SERVER_TLS_DIR, STACKABLE_TLS_STORE_PASSWORD, }, + authorization::NifiAuthorizationConfig, build_tls_volume, check_or_generate_oidc_admin_password, check_or_generate_sensitive_key, tls::{KEYSTORE_NIFI_CONTAINER_MOUNT, KEYSTORE_VOLUME_NAME, TRUSTSTORE_VOLUME_NAME}, }, @@ -296,6 +297,11 @@ pub enum Error { source: crate::security::authentication::Error, }, + #[snafu(display("Invalid NiFi Authorization Configuration"))] + InvalidNifiAuthorizationConfig { + source: crate::security::authorization::Error, + }, + #[snafu(display("Failed to resolve NiFi Authentication Configuration"))] FailedResolveNifiAuthenticationConfig { source: crate::crd::authentication::Error, @@ -455,19 +461,22 @@ pub async fn reconcile_nifi( obj_ref: ObjectRef::new(&nifi.name_any()).within(namespace), })?; - let nifi_authentication_config = NifiAuthenticationConfig::try_from( + let authentication_config = NifiAuthenticationConfig::try_from( AuthenticationClassResolved::from(nifi, client) .await .context(FailedResolveNifiAuthenticationConfigSnafu)?, ) .context(InvalidNifiAuthenticationConfigSnafu)?; - if let NifiAuthenticationConfig::Oidc { .. } = nifi_authentication_config { + if let NifiAuthenticationConfig::Oidc { .. } = authentication_config { check_or_generate_oidc_admin_password(client, nifi) .await .context(SecuritySnafu)?; } + let authorization_config = + NifiAuthorizationConfig::from(&nifi.spec.cluster_config.authorization); + let (rbac_sa, rbac_rolebinding) = build_rbac_resources( nifi, APP_NAME, @@ -516,7 +525,8 @@ pub async fn reconcile_nifi( let rg_configmap = build_node_rolegroup_config_map( nifi, &resolved_product_image, - &nifi_authentication_config, + &authentication_config, + &authorization_config, role, &rolegroup, rolegroup_config, @@ -541,7 +551,8 @@ pub async fn reconcile_nifi( role, rolegroup_config, &merged_config, - &nifi_authentication_config, + &authentication_config, + &authorization_config, rolling_upgrade_supported, replicas, &rbac_sa.name_any(), @@ -591,7 +602,7 @@ pub async fn reconcile_nifi( nifi, &resolved_product_image, &client.kubernetes_cluster_info, - &nifi_authentication_config, + &authentication_config, &rbac_sa.name_any(), ) .context(ReportingTaskSnafu)? @@ -699,7 +710,8 @@ pub fn build_node_role_service( async fn build_node_rolegroup_config_map( nifi: &v1alpha1::NifiCluster, resolved_product_image: &ResolvedProductImage, - nifi_auth_config: &NifiAuthenticationConfig, + authentication_config: &NifiAuthenticationConfig, + authorization_config: &NifiAuthorizationConfig, role: &Role, rolegroup: &RoleGroupRef, rolegroup_config: &HashMap>, @@ -708,10 +720,14 @@ async fn build_node_rolegroup_config_map( ) -> Result { tracing::debug!("building rolegroup configmaps"); - let (login_identity_provider_xml, authorizers_xml) = nifi_auth_config - .get_auth_config() + let login_identity_provider_xml = authentication_config + .get_authentication_config() .context(InvalidNifiAuthenticationConfigSnafu)?; + let authorizers_xml = authorization_config + .get_authorizers_config(authentication_config) + .context(InvalidNifiAuthorizationConfigSnafu)?; + let jvm_sec_props: BTreeMap> = rolegroup_config .get(&PropertyNameKind::File( JVM_SECURITY_PROPERTIES_FILE.to_string(), @@ -761,7 +777,7 @@ async fn build_node_rolegroup_config_map( &nifi.spec, &merged_config.resources, proxy_hosts, - nifi_auth_config, + authentication_config, rolegroup_config .get(&PropertyNameKind::File(NIFI_PROPERTIES.to_string())) .with_context(|| ProductConfigKindNotSpecifiedSnafu { @@ -870,7 +886,8 @@ async fn build_node_rolegroup_statefulset( role: &Role, rolegroup_config: &HashMap>, merged_config: &NifiConfig, - nifi_auth_config: &NifiAuthenticationConfig, + authentication_config: &NifiAuthenticationConfig, + authorization_config: &NifiAuthorizationConfig, rolling_update_supported: bool, replicas: Option, sa_name: &str, @@ -922,12 +939,14 @@ async fn build_node_rolegroup_statefulset( &nifi.spec.cluster_config.zookeeper_config_map_name, )); - if let NifiAuthenticationConfig::Oidc { oidc, .. } = nifi_auth_config { + if let NifiAuthenticationConfig::Oidc { oidc, .. } = authentication_config { env_vars.extend(AuthenticationProvider::client_credentials_env_var_mounts( oidc.client_credentials_secret_ref.clone(), )); } + env_vars.extend(authorization_config.get_env_vars()); + let node_address = format!( "$POD_NAME.{}-node-{}.{}.svc.{}", rolegroup_ref.cluster.name, @@ -973,7 +992,11 @@ async fn build_node_rolegroup_statefulset( ]); // This commands needs to go first, as they might set env variables needed by the templating - prepare_args.extend_from_slice(nifi_auth_config.get_additional_container_args().as_slice()); + prepare_args.extend_from_slice( + authentication_config + .get_additional_container_args() + .as_slice(), + ); prepare_args.extend(vec![ "echo Templating config files".to_string(), @@ -1222,7 +1245,7 @@ async fn build_node_rolegroup_statefulset( } } - nifi_auth_config + authentication_config .add_volumes_and_mounts(&mut pod_builder, vec![ &mut container_prepare, container_nifi, diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index c3227e77..56ec1ced 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -11,7 +11,9 @@ use stackable_operator::{ commons::{ affinity::StackableAffinity, authentication::ClientAuthenticationDetails, + cache::UserInformationCache, cluster_operation::ClusterOperation, + opa::OpaConfig, product_image_selection::ProductImage, resources::{ CpuLimitsFragment, MemoryLimitsFragment, NoRuntimeLimits, NoRuntimeLimitsFragment, @@ -115,10 +117,15 @@ pub mod versioned { #[serde(rename_all = "camelCase")] pub struct NifiClusterConfig { /// Authentication options for NiFi (required). - /// Read more about authentication in the [security documentation](DOCS_BASE_URL_PLACEHOLDER/nifi/usage_guide/security). + /// Read more about authentication in the [security documentation](DOCS_BASE_URL_PLACEHOLDER/nifi/usage_guide/security#authentication). // We don't add `#[serde(default)]` here, as we require authentication pub authentication: Vec, + /// Authorization options. + /// Learn more in the [NiFi authorization usage guide](DOCS_BASE_URL_PLACEHOLDER/nifi/usage-guide/security#authorization). + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization: Option, + /// Configuration of allowed proxies e.g. load balancers or Kubernetes Ingress. Using a proxy that is not allowed by NiFi results /// in a failed host header check. #[serde(default)] @@ -272,6 +279,22 @@ impl v1alpha1::NifiCluster { } } +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NifiAuthorization { + #[serde(skip_serializing_if = "Option::is_none")] + pub opa: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NifiOpaConfig { + #[serde(flatten)] + pub opa: OpaConfig, + #[serde(default)] + pub cache: UserInformationCache, +} + #[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct HostHeaderCheckConfig { diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index e6810d8b..938ff62c 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -179,5 +179,22 @@ fn references_config_map( return false; }; - nifi.spec.cluster_config.zookeeper_config_map_name == config_map.name_any() + let references_zookeeper_config_map = + nifi.spec.cluster_config.zookeeper_config_map_name == config_map.name_any(); + let references_authorization_config_map = references_authorization_config_map(nifi, config_map); + + references_zookeeper_config_map || references_authorization_config_map +} + +fn references_authorization_config_map( + nifi: &v1alpha1::NifiCluster, + config_map: &DeserializeGuard, +) -> bool { + nifi.spec + .cluster_config + .authorization + .as_ref() + .and_then(|authz| authz.opa.as_ref()) + .map(|opa_config| opa_config.opa.config_map_name == config_map.name_any()) + .unwrap_or(false) } diff --git a/rust/operator-binary/src/reporting_task/mod.rs b/rust/operator-binary/src/reporting_task/mod.rs index 7449537c..cf6e1f73 100644 --- a/rust/operator-binary/src/reporting_task/mod.rs +++ b/rust/operator-binary/src/reporting_task/mod.rs @@ -130,7 +130,7 @@ pub fn build_maybe_reporting_task( nifi: &v1alpha1::NifiCluster, resolved_product_image: &ResolvedProductImage, cluster_info: &KubernetesClusterInfo, - nifi_auth_config: &NifiAuthenticationConfig, + authentication_config: &NifiAuthenticationConfig, sa_name: &str, ) -> Result> { if resolved_product_image.product_version.starts_with("1.") { @@ -139,7 +139,7 @@ pub fn build_maybe_reporting_task( nifi, resolved_product_image, cluster_info, - nifi_auth_config, + authentication_config, sa_name, )?, build_reporting_task_service(nifi, resolved_product_image)?, diff --git a/rust/operator-binary/src/security/authentication.rs b/rust/operator-binary/src/security/authentication.rs index 4c2f2e15..24455634 100644 --- a/rust/operator-binary/src/security/authentication.rs +++ b/rust/operator-binary/src/security/authentication.rs @@ -80,17 +80,12 @@ pub enum NifiAuthenticationConfig { } impl NifiAuthenticationConfig { - pub fn get_auth_config(&self) -> Result<(String, String), Error> { + pub fn get_authentication_config(&self) -> Result { let mut login_identity_provider_xml = indoc! {r#" "#} .to_string(); - let mut authorizers_xml = indoc! {r#" - - - "#} - .to_string(); match &self { Self::SingleUser { .. } | Self::Oidc { .. } => { @@ -103,28 +98,17 @@ impl NifiAuthenticationConfig { "#, }); - - authorizers_xml.push_str(indoc! {r#" - - authorizer - org.apache.nifi.authorization.single.user.SingleUserAuthorizer - - "#}); } Self::Ldap { provider } => { login_identity_provider_xml.push_str(&get_ldap_login_identity_provider(provider)?); - authorizers_xml.push_str(&get_ldap_authorizer(provider)?); } } login_identity_provider_xml.push_str(indoc! {r#" "#}); - authorizers_xml.push_str(indoc! {r#" - - "#}); - Ok((login_identity_provider_xml, authorizers_xml)) + Ok(login_identity_provider_xml) } pub fn get_user_and_password_file_paths(&self) -> (String, String) { @@ -343,44 +327,3 @@ fn get_ldap_login_identity_provider(ldap: &ldap::AuthenticationProvider) -> Resu keystore_path = STACKABLE_SERVER_TLS_DIR, }) } - -fn get_ldap_authorizer(ldap: &ldap::AuthenticationProvider) -> Result { - let (username_file, _) = ldap - .bind_credentials_mount_paths() - .context(LdapAuthenticationClassMissingBindCredentialsSnafu)?; - - Ok(formatdoc! {r#" - - file-user-group-provider - org.apache.nifi.authorization.FileUserGroupProvider - ./conf/users.xml - - - - ${{file:UTF-8:{username_file}}} - - - CN=generated certificate for pod - - - - file-access-policy-provider - org.apache.nifi.authorization.FileAccessPolicyProvider - file-user-group-provider - ./conf/authorizations.xml - - - - ${{file:UTF-8:{username_file}}} - - - CN=generated certificate for pod - - - - authorizer - org.apache.nifi.authorization.StandardManagedAuthorizer - file-access-policy-provider - - "#}) -} diff --git a/rust/operator-binary/src/security/authorization.rs b/rust/operator-binary/src/security/authorization.rs new file mode 100644 index 00000000..51fdc717 --- /dev/null +++ b/rust/operator-binary/src/security/authorization.rs @@ -0,0 +1,155 @@ +use indoc::{formatdoc, indoc}; +use snafu::{OptionExt, Snafu}; +use stackable_operator::{ + commons::authentication::ldap, + k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource}, +}; + +use super::authentication::NifiAuthenticationConfig; +use crate::crd::NifiAuthorization; + +#[derive(Snafu, Debug)] +pub enum Error { + #[snafu(display( + "The LDAP AuthenticationClass is missing the bind credentials. Currently the NiFi operator only supports connecting to LDAP servers using bind credentials" + ))] + LdapAuthenticationClassMissingBindCredentials {}, +} + +pub enum NifiAuthorizationConfig { + Opa { + configmap_name: String, + cache_entry_time_to_live_secs: u64, + cache_max_entries: u32, + }, + Default, +} + +impl NifiAuthorizationConfig { + pub fn from(nifi_authorization: &Option) -> Self { + match nifi_authorization { + Some(authorization_config) => match authorization_config.opa.clone() { + Some(opa_config) => NifiAuthorizationConfig::Opa { + configmap_name: opa_config.opa.config_map_name, + cache_entry_time_to_live_secs: opa_config.cache.entry_time_to_live.as_secs(), + cache_max_entries: opa_config.cache.max_entries, + }, + None => NifiAuthorizationConfig::Default, + }, + None => NifiAuthorizationConfig::Default, + } + } + + pub fn get_authorizers_config( + &self, + authentication_config: &NifiAuthenticationConfig, + ) -> Result { + let mut authorizers_xml = indoc! {r#" + + + "#} + .to_string(); + + match self { + NifiAuthorizationConfig::Opa { + cache_entry_time_to_live_secs, + cache_max_entries, + .. + } => { + authorizers_xml.push_str(&formatdoc! {r#" + + authorizer + org.nifiopa.nifiopa.OpaAuthorizer + {cache_entry_time_to_live_secs} + {cache_max_entries} + ${{env:OPA_BASE_URL}} + nifi/allow + + "#}); + } + NifiAuthorizationConfig::Default => match authentication_config { + NifiAuthenticationConfig::SingleUser { .. } + | NifiAuthenticationConfig::Oidc { .. } => { + authorizers_xml.push_str(indoc! {r#" + + authorizer + org.apache.nifi.authorization.single.user.SingleUserAuthorizer + + "#}); + } + NifiAuthenticationConfig::Ldap { provider } => { + authorizers_xml.push_str(&self.get_default_ldap_authorizer(provider)?); + } + }, + } + + authorizers_xml.push_str(indoc! {r#" + + "#}); + Ok(authorizers_xml) + } + + fn get_default_ldap_authorizer( + &self, + ldap: &ldap::AuthenticationProvider, + ) -> Result { + let (username_file, _) = ldap + .bind_credentials_mount_paths() + .context(LdapAuthenticationClassMissingBindCredentialsSnafu)?; + + Ok(formatdoc! {r#" + + file-user-group-provider + org.apache.nifi.authorization.FileUserGroupProvider + ./conf/users.xml + + + + ${{file:UTF-8:{username_file}}} + + + CN=generated certificate for pod + + + + file-access-policy-provider + org.apache.nifi.authorization.FileAccessPolicyProvider + file-user-group-provider + ./conf/authorizations.xml + + + + ${{file:UTF-8:{username_file}}} + + + CN=generated certificate for pod + + + + authorizer + org.apache.nifi.authorization.StandardManagedAuthorizer + file-access-policy-provider + + "#}) + } + + pub fn get_env_vars(&self) -> Vec { + match self { + NifiAuthorizationConfig::Opa { configmap_name, .. } => { + vec![EnvVar { + name: "OPA_BASE_URL".to_owned(), + value_from: Some(EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + key: "OPA".to_owned(), + name: configmap_name.to_owned(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }] + } + NifiAuthorizationConfig::Default => vec![], + } + } +} diff --git a/rust/operator-binary/src/security/mod.rs b/rust/operator-binary/src/security/mod.rs index 18f892da..eebc0a30 100644 --- a/rust/operator-binary/src/security/mod.rs +++ b/rust/operator-binary/src/security/mod.rs @@ -7,6 +7,7 @@ use stackable_operator::{ use crate::crd::v1alpha1; pub mod authentication; +pub mod authorization; pub mod oidc; pub mod sensitive_key; pub mod tls; diff --git a/tests/templates/kuttl/oidc/00-assert.yaml.j2 b/tests/templates/kuttl/oidc-opa/00-assert.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/00-assert.yaml.j2 rename to tests/templates/kuttl/oidc-opa/00-assert.yaml.j2 diff --git a/tests/templates/kuttl/oidc/00-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/oidc-opa/00-install-vector-aggregator-discovery-configmap.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/00-install-vector-aggregator-discovery-configmap.yaml.j2 rename to tests/templates/kuttl/oidc-opa/00-install-vector-aggregator-discovery-configmap.yaml.j2 diff --git a/tests/templates/kuttl/oidc/00-patch-ns.yaml.j2 b/tests/templates/kuttl/oidc-opa/00-patch-ns.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/00-patch-ns.yaml.j2 rename to tests/templates/kuttl/oidc-opa/00-patch-ns.yaml.j2 diff --git a/tests/templates/kuttl/oidc/10-assert.yaml b/tests/templates/kuttl/oidc-opa/10-assert.yaml similarity index 100% rename from tests/templates/kuttl/oidc/10-assert.yaml rename to tests/templates/kuttl/oidc-opa/10-assert.yaml diff --git a/tests/templates/kuttl/oidc/10-install-zk.yaml.j2 b/tests/templates/kuttl/oidc-opa/10-install-zk.yaml.j2 similarity index 95% rename from tests/templates/kuttl/oidc/10-install-zk.yaml.j2 rename to tests/templates/kuttl/oidc-opa/10-install-zk.yaml.j2 index 275907d4..3435e4d8 100644 --- a/tests/templates/kuttl/oidc/10-install-zk.yaml.j2 +++ b/tests/templates/kuttl/oidc-opa/10-install-zk.yaml.j2 @@ -23,7 +23,7 @@ spec: apiVersion: zookeeper.stackable.tech/v1alpha1 kind: ZookeeperZnode metadata: - name: nifi-with-oidc-znode + name: nifi-znode spec: clusterRef: name: test-zk diff --git a/tests/templates/kuttl/oidc-opa/15-install-authentication-class.yaml b/tests/templates/kuttl/oidc-opa/15-install-authentication-class.yaml new file mode 100644 index 00000000..73181d11 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/15-install-authentication-class.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: envsubst < 15_authentication-class.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/11_authentication-classes.yaml.j2 b/tests/templates/kuttl/oidc-opa/15_authentication-class.yaml.j2 similarity index 100% rename from tests/templates/kuttl/oidc/11_authentication-classes.yaml.j2 rename to tests/templates/kuttl/oidc-opa/15_authentication-class.yaml.j2 diff --git a/tests/templates/kuttl/oidc/01-assert.yaml b/tests/templates/kuttl/oidc-opa/19-assert.yaml similarity index 78% rename from tests/templates/kuttl/oidc/01-assert.yaml rename to tests/templates/kuttl/oidc-opa/19-assert.yaml index 5f3fae52..943a1340 100644 --- a/tests/templates/kuttl/oidc/01-assert.yaml +++ b/tests/templates/kuttl/oidc-opa/19-assert.yaml @@ -1,9 +1,7 @@ --- apiVersion: kuttl.dev/v1beta1 kind: TestAssert -metadata: - name: test-keycloak -timeout: 480 +timeout: 300 --- apiVersion: apps/v1 kind: Deployment diff --git a/tests/templates/kuttl/oidc-opa/19-install-keycloak.yaml b/tests/templates/kuttl/oidc-opa/19-install-keycloak.yaml new file mode 100644 index 00000000..6a4b4b33 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/19-install-keycloak.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: envsubst < 19_keycloak.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc-opa/19-keycloak-realm-cm.yaml.j2 b/tests/templates/kuttl/oidc-opa/19-keycloak-realm-cm.yaml.j2 new file mode 100644 index 00000000..914ce580 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/19-keycloak-realm-cm.yaml.j2 @@ -0,0 +1,192 @@ +--- +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: Secret +metadata: + name: nifi-client-credentials +stringData: + clientId: nifi + clientSecret: nifi-client-secret +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycloak-test-realm +data: + realm.json: | + { + "realm": "test", + "enabled": true, + "attributes": { + "frontendUrl": "keycloak.$NAMESPACE.svc.cluster.local" + }, + "groups": [ + { + "name": "nifi-admin", + "path": "/nifi-admin" + }, + { + "name": "nifi-user", + "path": "/nifi-user" + }, + { + "name": "nifi-process-group-a", + "path": "/nifi-process-group-a" + }, + { + "name": "nifi-process-group-b", + "path": "/nifi-process-group-b" + } + ], + "users": [ + { + "username": "nifi-admin", + "enabled": true, + "emailVerified": true, + "firstName": "nifi-admin", + "lastName": "nifi-admin", + "email": "nifi-admin@example.com", + "credentials": [ + { + "type": "password", + "value": "nifi-admin" + } + ], + "realmRoles": [ + "default-roles-my-dataspace" + ], + "groups": [ + "/nifi-admin" + ] + }, + { + "username": "alice", + "enabled": true, + "emailVerified": true, + "firstName": "alice", + "lastName": "alice", + "email": "alice@example.com", + "credentials": [ + { + "type": "password", + "value": "alice" + } + ], + "realmRoles": [ + "default-roles-my-dataspace" + ], + "groups": [ + "/nifi-user", + "/nifi-process-group-a" + ] + }, + { + "username": "bob", + "enabled": true, + "emailVerified": true, + "firstName": "bob", + "lastName": "bob", + "email": "bob@example.com", + "credentials": [ + { + "type": "password", + "value": "bob" + } + ], + "realmRoles": [], + "groups": [ + "/nifi-user", + "/nifi-process-group-b" + ] + }, + { + "username": "charlie", + "enabled": true, + "emailVerified": true, + "firstName": "charlie", + "lastName": "charlie", + "email": "charlie@example.com", + "credentials": [ + { + "type": "password", + "value": "charlie" + } + ], + "realmRoles": [ + "default-roles-my-dataspace" + ], + "groups": [ + "/nifi-user" + ] + }, + { + "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": [] + } + ], + "clients": [ + { + "clientId": "user-info-fetcher", + "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 + }, + { + "clientId": "nifi", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "nifi-client-secret", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "standardFlowEnabled": true, + "protocol": "openid-connect" + } + ] + } diff --git a/tests/templates/kuttl/oidc-opa/19_keycloak.yaml.j2 b/tests/templates/kuttl/oidc-opa/19_keycloak.yaml.j2 new file mode 100644 index 00000000..9f43dcbb --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/19_keycloak.yaml.j2 @@ -0,0 +1,151 @@ +--- +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: secrets.stackable.tech/v1alpha1 +kind: SecretClass +metadata: + name: keycloak-tls-$NAMESPACE +spec: + backend: + autoTls: + ca: + autoGenerate: true + secret: + name: keycloak-tls-ca-$NAMESPACE + 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:25.0.0 + args: + - start-dev + - --import-realm +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + - --https-certificate-file=/tls/tls.crt + - --https-certificate-key-file=/tls/tls.key +{% endif %} + env: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + - name: JAVA_OPTS_KC_HEAP + value: -Xmx500M +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + ports: + - name: https + containerPort: 8443 + readinessProbe: + httpGet: + scheme: HTTPS + path: /realms/master + port: https +{% else %} + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + scheme: HTTP + path: /realms/master + port: http +{% endif %} + 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 + volumes: + - name: data + emptyDir: {} + - name: tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: keycloak-tls-$NAMESPACE + secrets.stackable.tech/scope: service=keycloak + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" + - name: realm-volume + configMap: + name: keycloak-test-realm +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + labels: + app: keycloak +spec: + ports: +{% if test_scenario['values']['oidc-use-tls'] == 'true' %} + - name: https + port: 8443 + targetPort: 8443 +{% else %} + - name: http + port: 8080 + targetPort: 8080 +{% endif %} + selector: + app: keycloak diff --git a/tests/templates/kuttl/oidc-opa/20-assert.yaml b/tests/templates/kuttl/oidc-opa/20-assert.yaml new file mode 100644 index 00000000..e868cdaf --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/20-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/oidc-opa/20-install-opa.yaml.j2 b/tests/templates/kuttl/oidc-opa/20-install-opa.yaml.j2 new file mode 100644 index 00000000..8b8f9cf5 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/20-install-opa.yaml.j2 @@ -0,0 +1,47 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl apply -n $NAMESPACE -f - < 0 }} + containers: + opa: + loggers: + decision: + level: INFO + roleGroups: + default: {} diff --git a/tests/templates/kuttl/oidc-opa/25-opa-rego.yaml b/tests/templates/kuttl/oidc-opa/25-opa-rego.yaml new file mode 100644 index 00000000..43f58fd6 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/25-opa-rego.yaml @@ -0,0 +1,75 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-nifi-uif-rego + labels: + opa.stackable.tech/bundle: "true" +data: + nifi.rego: | + package nifi + + # Setting "resourceNotFound" to true results in the parent resource beingevaluated for authorization, + # e.g. the parent of a processor is the processor-group. + # If a resource is matched by a rego rule that is not the default the parent resource will be ignored. + default allow := { + "resourceNotFound": true + } + + # The nifi-admin has access to everything + allow := { + "allowed": true, + } if { + some group in user_groups + group == "nifi-admin" + } + + # Every user in the group "nifi-user" has access to the UI + allow := { + "allowed": true, + "dumpCache": true + } if { + input.resource.id in ["/flow"] + some group in user_groups + group == "nifi-user" + } + + # Allow access to process groups A & B only to users in the corresponding group + allow := { + "allowed": true, + } if { + input.resource.id == "/process-groups/c9186a05-0196-1000-ffff-ffffd8474359" + some group in user_groups + group in ["nifi-process-group-a"] + } + + allow := { + "allowed": true + } if { + input.resource.id == "/process-groups/7e08561b-447d-3acb-b510-744d886c3ca4" + some group in user_groups + group in ["nifi-process-group-b"] + } + + # Allow read-only access to group "nifi-user" to proccess group C + allow := { + "allowed": true + } if { + input.resource.id == "/process-groups/f3b78341-b2ff-3ae3-b7fc-4b518617c802" + input.action.name == "read" + some group in user_groups + group == "nifi-user" + } + + allow := { + "allowed": true, + "dumpCache": true + } if { + input.identity.name == "CN=generated certificate for pod" + input.resource.id == "/proxy" + } + + user_groups := user_groups if { + user_group_paths := data.stackable.opa.userinfo.v1.userInfoByUsername(input.identity.name).groups + user_groups := [ trim(user_group,"/") | user_group := user_group_paths[_] ] + } diff --git a/tests/templates/kuttl/oidc/12-assert.yaml b/tests/templates/kuttl/oidc-opa/30-assert.yaml similarity index 100% rename from tests/templates/kuttl/oidc/12-assert.yaml rename to tests/templates/kuttl/oidc-opa/30-assert.yaml diff --git a/tests/templates/kuttl/oidc/12-install-nifi.yaml b/tests/templates/kuttl/oidc-opa/30-install-nifi.yaml similarity index 50% rename from tests/templates/kuttl/oidc/12-install-nifi.yaml rename to tests/templates/kuttl/oidc-opa/30-install-nifi.yaml index edef731d..e7126be2 100644 --- a/tests/templates/kuttl/oidc/12-install-nifi.yaml +++ b/tests/templates/kuttl/oidc-opa/30-install-nifi.yaml @@ -2,4 +2,4 @@ apiVersion: kuttl.dev/v1beta1 kind: TestStep commands: - - script: envsubst < 12_nifi.yaml | kubectl apply -n $NAMESPACE -f - + - script: envsubst < 30_nifi.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc-opa/30-nifi-flow-cm.yaml b/tests/templates/kuttl/oidc-opa/30-nifi-flow-cm.yaml new file mode 100644 index 00000000..87824f63 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/30-nifi-flow-cm.yaml @@ -0,0 +1,388 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nifi-flow-json +data: + flow.json: | + { + "encodingVersion": { + "majorVersion": 2, + "minorVersion": 0 + }, + "maxTimerDrivenThreadCount": 10, + "registries": [], + "parameterContexts": [], + "parameterProviders": [], + "controllerServices": [], + "reportingTasks": [], + "flowAnalysisRules": [], + "rootGroup": { + "identifier": "96d9abc7-e736-324d-9da8-1c0a4e671508", + "instanceIdentifier": "c377901a-0196-1000-d1e8-b549997e4d94", + "name": "NiFi Flow", + "comments": "", + "position": { + "x": 0, + "y": 0 + }, + "processGroups": [ + { + "identifier": "0ad13e3a-a773-380d-0000-000031bd1ecb", + "instanceIdentifier": "7e08561b-447d-3acb-b510-744d886c3ca4", + "name": "Process Group B", + "comments": "", + "position": { + "x": 248, + "y": -112 + }, + "processGroups": [], + "remoteProcessGroups": [], + "processors": [ + { + "identifier": "347682fa-61d4-31ae-0000-000031bd1ecb", + "instanceIdentifier": "351336f0-903f-362c-b479-c06040537e68", + "name": "GenerateFlowFile C", + "comments": "", + "position": { + "x": -1032, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "0ad13e3a-a773-380d-0000-000031bd1ecb" + }, + { + "identifier": "f7b6da7b-daa3-3b68-0000-000031bd1ecb", + "instanceIdentifier": "1ed101fd-f976-38ad-8137-45dc0c7b6e6e", + "name": "GenerateFlowFile D", + "comments": "", + "position": { + "x": -648, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "0ad13e3a-a773-380d-0000-000031bd1ecb" + } + ], + "inputPorts": [], + "outputPorts": [], + "connections": [], + "labels": [], + "funnels": [], + "controllerServices": [], + "defaultFlowFileExpiration": "0 sec", + "defaultBackPressureObjectThreshold": 10000, + "defaultBackPressureDataSizeThreshold": "1 GB", + "scheduledState": "ENABLED", + "executionEngine": "INHERITED", + "maxConcurrentTasks": 1, + "statelessFlowTimeout": "1 min", + "flowFileConcurrency": "UNBOUNDED", + "flowFileOutboundPolicy": "STREAM_WHEN_AVAILABLE", + "componentType": "PROCESS_GROUP", + "groupIdentifier": "96d9abc7-e736-324d-9da8-1c0a4e671508" + }, + { + "identifier": "8f62d3e7-2a0b-3ba8-936c-8a0cb425284a", + "instanceIdentifier": "c9186a05-0196-1000-ffff-ffffd8474359", + "name": "Process Group A", + "comments": "", + "position": { + "x": -190, + "y": -112.91667938232422 + }, + "processGroups": [], + "remoteProcessGroups": [], + "processors": [ + { + "identifier": "e14260ce-23cd-361d-0000-00001b31a7fc", + "instanceIdentifier": "63ad5386-61ce-327a-bce3-5b597829aa54", + "name": "GenerateFlowFile B", + "comments": "", + "position": { + "x": -648, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "8f62d3e7-2a0b-3ba8-936c-8a0cb425284a" + }, + { + "identifier": "4623a22e-5c14-310b-afb8-c82965f74a7d", + "instanceIdentifier": "c9196a9e-0196-1000-0000-0000418515a4", + "name": "GenerateFlowFile A", + "comments": "", + "position": { + "x": -1032, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "8f62d3e7-2a0b-3ba8-936c-8a0cb425284a" + } + ], + "inputPorts": [], + "outputPorts": [], + "connections": [], + "labels": [], + "funnels": [], + "controllerServices": [], + "defaultFlowFileExpiration": "0 sec", + "defaultBackPressureObjectThreshold": 10000, + "defaultBackPressureDataSizeThreshold": "1 GB", + "scheduledState": "ENABLED", + "executionEngine": "INHERITED", + "maxConcurrentTasks": 1, + "statelessFlowTimeout": "1 min", + "flowFileConcurrency": "UNBOUNDED", + "flowFileOutboundPolicy": "STREAM_WHEN_AVAILABLE", + "componentType": "PROCESS_GROUP", + "groupIdentifier": "96d9abc7-e736-324d-9da8-1c0a4e671508" + }, + { + "identifier": "82533e93-7494-352d-ffff-ffffaad75c48", + "instanceIdentifier": "f3b78341-b2ff-3ae3-b7fc-4b518617c802", + "name": "Process Group C", + "comments": "", + "position": { + "x": 696, + "y": -112 + }, + "processGroups": [], + "remoteProcessGroups": [], + "processors": [ + { + "identifier": "fcd0595d-bf18-34be-ffff-ffffaad75c48", + "instanceIdentifier": "9d95cac3-2759-3fce-9c07-71215b0fb554", + "name": "GenerateFlowFile E", + "comments": "", + "position": { + "x": -1032, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "82533e93-7494-352d-ffff-ffffaad75c48" + }, + { + "identifier": "077fd08e-4c7c-39cc-ffff-ffffaad75c48", + "instanceIdentifier": "63d8f319-893c-38d4-9cd4-35cea3f43ea2", + "name": "GenerateFlowFile F", + "comments": "", + "position": { + "x": -648, + "y": -520 + }, + "type": "org.apache.nifi.processors.standard.GenerateFlowFile", + "bundle": { + "group": "org.apache.nifi", + "artifact": "nifi-standard-nar", + "version": "2.2.0" + }, + "properties": { + "character-set": "UTF-8", + "File Size": "0B", + "Batch Size": "1", + "Unique FlowFiles": "false", + "Data Format": "Text" + }, + "propertyDescriptors": {}, + "style": {}, + "schedulingPeriod": "1 min", + "schedulingStrategy": "TIMER_DRIVEN", + "executionNode": "ALL", + "penaltyDuration": "30 sec", + "yieldDuration": "1 sec", + "bulletinLevel": "WARN", + "runDurationMillis": 0, + "concurrentlySchedulableTaskCount": 1, + "autoTerminatedRelationships": [], + "scheduledState": "ENABLED", + "retryCount": 10, + "retriedRelationships": [], + "backoffMechanism": "PENALIZE_FLOWFILE", + "maxBackoffPeriod": "10 mins", + "componentType": "PROCESSOR", + "groupIdentifier": "82533e93-7494-352d-ffff-ffffaad75c48" + } + ], + "inputPorts": [], + "outputPorts": [], + "connections": [], + "labels": [], + "funnels": [], + "controllerServices": [], + "defaultFlowFileExpiration": "0 sec", + "defaultBackPressureObjectThreshold": 10000, + "defaultBackPressureDataSizeThreshold": "1 GB", + "scheduledState": "ENABLED", + "executionEngine": "INHERITED", + "maxConcurrentTasks": 1, + "statelessFlowTimeout": "1 min", + "flowFileConcurrency": "UNBOUNDED", + "flowFileOutboundPolicy": "STREAM_WHEN_AVAILABLE", + "componentType": "PROCESS_GROUP", + "groupIdentifier": "96d9abc7-e736-324d-9da8-1c0a4e671508" + } + ], + "remoteProcessGroups": [], + "processors": [], + "inputPorts": [], + "outputPorts": [], + "connections": [], + "labels": [], + "funnels": [], + "controllerServices": [], + "defaultFlowFileExpiration": "0 sec", + "defaultBackPressureObjectThreshold": 10000, + "defaultBackPressureDataSizeThreshold": "1 GB", + "scheduledState": "ENABLED", + "executionEngine": "INHERITED", + "maxConcurrentTasks": 1, + "statelessFlowTimeout": "1 min", + "flowFileConcurrency": "UNBOUNDED", + "flowFileOutboundPolicy": "STREAM_WHEN_AVAILABLE", + "componentType": "PROCESS_GROUP" + } + } diff --git a/tests/templates/kuttl/oidc/12_nifi.yaml.j2 b/tests/templates/kuttl/oidc-opa/30_nifi.yaml.j2 similarity index 52% rename from tests/templates/kuttl/oidc/12_nifi.yaml.j2 rename to tests/templates/kuttl/oidc-opa/30_nifi.yaml.j2 index 6edd2e42..bb446c0b 100644 --- a/tests/templates/kuttl/oidc/12_nifi.yaml.j2 +++ b/tests/templates/kuttl/oidc-opa/30_nifi.yaml.j2 @@ -6,14 +6,6 @@ metadata: stringData: nifiSensitivePropsKey: mYsUp3rS3cr3tk3y --- -apiVersion: v1 -kind: Secret -metadata: - name: nifi-oidc-client -stringData: - clientId: nifi - clientSecret: R1bxHUD569vHeQdw ---- apiVersion: nifi.stackable.tech/v1alpha1 kind: NifiCluster metadata: @@ -24,7 +16,6 @@ spec: custom: "{{ test_scenario['values']['nifi'].split(',')[1] }}" productVersion: "{{ test_scenario['values']['nifi'].split(',')[0] }}" {% else %} - custom: null productVersion: "{{ test_scenario['values']['nifi'] }}" {% endif %} pullPolicy: IfNotPresent @@ -32,19 +23,51 @@ spec: authentication: - authenticationClass: nifi-oidc-auth-class-$NAMESPACE oidc: - clientCredentialsSecret: nifi-oidc-client + clientCredentialsSecret: nifi-client-credentials + authorization: + opa: + configMapName: opa + package: nifi + cache: + entryTimeToLive: 5s + maxEntries: 10 sensitiveProperties: keySecret: nifi-sensitive-property-key {% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} - zookeeperConfigMapName: nifi-with-oidc-znode + zookeeperConfigMapName: nifi-znode listenerClass: external-unstable nodes: config: logging: enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} gracefulShutdownTimeout: 1s # let the tests run faster + configOverrides: + nifi.properties: + # speed up tests + nifi.cluster.flow.election.max.wait.time: 10 secs + podOverrides: + spec: + initContainers: + - name: copy-nifi-flow + image: oci.stackable.tech/sdp/testing-tools:0.2.0-stackable0.0.0-dev + command: + - /bin/bash + - -c + - -euo + - pipefail + args: + - cp /tmp/flow.json /stackable/data/database/flow.json && gzip /stackable/data/database/flow.json + volumeMounts: + - mountPath: /stackable/data/database + name: database-repository + - mountPath: /tmp + name: nifi-flow-json + volumes: + - configMap: + name: nifi-flow-json + name: nifi-flow-json roleGroups: default: config: {} diff --git a/tests/templates/kuttl/oidc-opa/40-create-configmap.yaml.j2 b/tests/templates/kuttl/oidc-opa/40-create-configmap.yaml.j2 new file mode 100644 index 00000000..51b0321c --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/40-create-configmap.yaml.j2 @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl create cm test-script -n $NAMESPACE --from-file=test.py diff --git a/tests/templates/kuttl/oidc-opa/41-assert.yaml b/tests/templates/kuttl/oidc-opa/41-assert.yaml new file mode 100644 index 00000000..58987778 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/41-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-opa/41-install-test-container.yaml.j2 b/tests/templates/kuttl/oidc-opa/41-install-test-container.yaml.j2 new file mode 100644 index 00000000..7a860e26 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/41-install-test-container.yaml.j2 @@ -0,0 +1,83 @@ +--- +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 + containers: + - name: oidc-login-test + image: oci.stackable.tech/sdp/testing-tools:0.2.0-stackable0.0.0-dev + stdin: true + tty: true + resources: + requests: + memory: "128Mi" + cpu: "512m" + limits: + memory: "128Mi" + cpu: "1" + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NIFI_VERSION + value: "{{ test_scenario['values']['nifi'] }}" + - name: OIDC_USE_TLS + value: "{{ test_scenario['values']['oidc-use-tls'] }}" + volumeMounts: + - name: test-script + mountPath: /tmp/test-script + terminationGracePeriodSeconds: 1 + volumes: + - name: test-script + configMap: + name: test-script + terminationGracePeriodSeconds: 1 diff --git a/tests/templates/kuttl/oidc-opa/45-assert.yaml b/tests/templates/kuttl/oidc-opa/45-assert.yaml new file mode 100644 index 00000000..bd633b9b --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/45-assert.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test +timeout: 300 +commands: + - script: kubectl exec -n $NAMESPACE python-0 -- python /tmp/test-script/test.py diff --git a/tests/templates/kuttl/oidc-opa/test.py b/tests/templates/kuttl/oidc-opa/test.py new file mode 100644 index 00000000..a7e96d55 --- /dev/null +++ b/tests/templates/kuttl/oidc-opa/test.py @@ -0,0 +1,220 @@ +import logging +import os +import requests +import sys +import time +import json +import urllib3 +from bs4 import BeautifulSoup + +# disable tls insecure warnings +urllib3.disable_warnings() + +logging.basicConfig( + level="INFO", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout +) + +namespace = os.environ["NAMESPACE"] +tls = os.environ["OIDC_USE_TLS"] +nifi_version = os.environ["NIFI_VERSION"] +nifi = f"test-nifi-node-default-0.test-nifi-node-default.{namespace}.svc.cluster.local" +keycloak_service = f"keycloak.{namespace}.svc.cluster.local" + +keycloak_base_url = ( + f"https://{keycloak_service}:8443" + if tls == "true" + else f"http://{keycloak_service}:8080" +) + + +def login(session: requests.Session, username: str, password: str): + # startswith instead of an exact check to + # a) hit all 2.x versions and + # b) to allow for custom images because `nifi_version` will contain the whole custom image string + # e.g. 2.0.0,localhost:5000/stackable/nifi:2.0.0-stackable0.0.0-dev + if not nifi_version.startswith("1."): + auth_config_page = session.get( + f"https://{nifi}:8443/nifi-api/authentication/configuration", + verify=False, + headers={"Content-type": "application/json"}, + ) + assert auth_config_page.ok, "Could not fetch auth config from NiFi" + auth_config = json.loads(auth_config_page.text) + login_url = auth_config["authenticationConfiguration"]["loginUri"] + else: + login_url = f"https://{nifi}:8443/nifi/login" + + # Open NiFi web UI which will redirect to OIDC login + login_page = session.get( + login_url, + verify=False, + headers={"Content-type": "application/json"}, + ) + + print("actual: ", login_page.url) + print( + "expected: ", + f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=", + ) + assert login_page.ok, "Redirection from NiFi to Keycloak failed" + assert login_page.url.startswith( + f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=" + ), "Redirection to Keycloak expected" + + # Login to keycloak with test user + login_page_html = BeautifulSoup(login_page.text, "html.parser") + authenticate_url = login_page_html.form["action"] + welcome_page = session.post( + authenticate_url, + data={"username": username, "password": password}, + verify=False, + ) + assert welcome_page.ok, "Login failed" + assert welcome_page.url == f"https://{nifi}:8443/nifi/", ( + "Redirection to the NiFi web UI expected" + ) + print(f"logged in as {username}") + + +def get_process_group_a(session: requests.Session) -> requests.Response: + return get_resource( + session, "/flow/process-groups/c9186a05-0196-1000-ffff-ffffd8474359" + ) + + +def get_process_group_b(session: requests.Session) -> requests.Response: + return get_resource( + session, "/flow/process-groups/7e08561b-447d-3acb-b510-744d886c3ca4" + ) + + +def get_processor_e(session: requests.Session) -> requests.Response: + return get_resource(session, "/processors/9d95cac3-2759-3fce-9c07-71215b0fb554") + + +def get_counters(session: requests.Session) -> requests.Response: + return get_resource(session, "/counters") + + +def get_resource(session: requests.Session, resource: str) -> requests.Response: + response = session.get( + f"https://{nifi}:8443/nifi-api{resource}?uiOnly=true", + verify=False, + ) + + # let success or unauthorized pass + if response.status_code == 200 or response.status_code == 403: + return response + else: + print(f"Could not retrieve resource [{resource}] ...") + print("Status Code:", response.status_code) + print("Response Text:", response.text) + time.sleep(5) + exit(1) + + +# alice +session = requests.Session() +login(session, "alice", "alice") + +process_group_a = get_process_group_a(session) +assert process_group_a.json()["permissions"]["canRead"], ( + "Alice should be able to access process group A" +) +process_group_b = get_process_group_b(session) +assert not process_group_b.json()["permissions"]["canRead"], ( + "Alice should not be able to access process group B" +) +processor_e = get_processor_e(session) +assert processor_e.json()["permissions"]["canRead"], ( + "Alice should be able to read processor E in process group C" +) +assert not processor_e.json()["permissions"]["canWrite"], ( + "Alice should not be able to write to processor E in process group C" +) + +counters = get_counters(session) +assert not counters.ok, ( + "Alice should not be able to access the global resource 'counters'" +) + + +# bob +session = requests.Session() +login(session, "bob", "bob") + +process_group_a = get_process_group_a(session) +assert not process_group_a.json()["permissions"]["canRead"], ( + "Bob should not be able to access process group A" +) + +process_group_b = get_process_group_b(session) +assert process_group_b.json()["permissions"]["canRead"], ( + "Bob should be able to access process group B" +) + +processor_e = get_processor_e(session) +assert processor_e.json()["permissions"]["canRead"], ( + "Bob should be able to read processor E in process group C" +) +assert not processor_e.json()["permissions"]["canWrite"], ( + "Bob should not be able to write to processor E in process group C" +) + +counters = get_counters(session) +assert not counters.ok, ( + "Bob should not be able to access the global resource 'counters'" +) + + +# charlie +session = requests.Session() +login(session, "charlie", "charlie") + +process_group_a = get_process_group_a(session) +assert not process_group_a.json()["permissions"]["canRead"], ( + "Charlie should not be able to access process group A" +) + +process_group_b = get_process_group_b(session) +assert not process_group_b.json()["permissions"]["canRead"], ( + "Charlie should not be able to access process group B" +) + +processor_e = get_processor_e(session) +assert processor_e.json()["permissions"]["canRead"], ( + "Charlie should be able to read processor E in process group C" +) +assert not processor_e.json()["permissions"]["canWrite"], ( + "Charlie should not be able to write to processor E in process group C" +) + +counters = get_counters(session) +assert not counters.ok, ( + "Charlie should not be able to access the global resource 'counters'" +) + +# nifi-admin +session = requests.Session() +login(session, "nifi-admin", "nifi-admin") + +process_group_a = get_process_group_a(session) +assert process_group_a.json()["permissions"]["canRead"], ( + "Nifi-admin should be able to access process group A" +) + +process_group_b = get_process_group_b(session) +assert process_group_b.json()["permissions"]["canRead"], ( + "Nifi-admin should be able to access process group B" +) + +processor_e = get_processor_e(session) +assert processor_e.json()["permissions"]["canRead"], ( + "Nifi-admin should be able to read processor E in process group C" +) +assert processor_e.json()["permissions"]["canWrite"], ( + "Nifi-admin should be able to write to processor E in process group C" +) + +counters = get_counters(session) +assert counters.ok, "Nifi-admin should be able to access the global resource 'counters'" diff --git a/tests/templates/kuttl/oidc/01-install-keycloak.yaml b/tests/templates/kuttl/oidc/01-install-keycloak.yaml deleted file mode 100644 index 4e07a328..00000000 --- a/tests/templates/kuttl/oidc/01-install-keycloak.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - INSTANCE_NAME=keycloak \ - REALM=test \ - USERNAME=jane.doe \ - FIRST_NAME=Jane \ - LAST_NAME=Doe \ - EMAIL=jane.doe@stackable.tech \ - PASSWORD=T8mn72D9 \ - CLIENT_ID=nifi \ - CLIENT_SECRET=R1bxHUD569vHeQdw \ - envsubst < 01_keycloak.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 b/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 deleted file mode 100644 index 1331f2ac..00000000 --- a/tests/templates/kuttl/oidc/01_keycloak.yaml.j2 +++ /dev/null @@ -1,159 +0,0 @@ -# The environment variables must be replaced. ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: $INSTANCE_NAME-realms -data: - test-realm.json: | - { - "realm": "$REALM", - "enabled": true, - "attributes": { - "frontendUrl": "keycloak.$NAMESPACE.svc.cluster.local" - }, - "users": [ - { - "enabled": true, - "username": "$USERNAME", - "firstName" : "$FIRST_NAME", - "lastName" : "$LAST_NAME", - "email" : "$EMAIL", - "credentials": [ - { - "type": "password", - "value": "$PASSWORD" - } - ], - "realmRoles": [ - "user" - ] - } - ], - "roles": { - "realm": [ - { - "name": "user", - "description": "User privileges" - } - ] - }, - "clients": [ - { - "clientId": "$CLIENT_ID", - "enabled": true, - "clientAuthenticatorType": "client-secret", - "secret": "$CLIENT_SECRET", - "redirectUris": [ - "*" - ], - "webOrigins": [ - "*" - ], - "standardFlowEnabled": true, - "protocol": "openid-connect" - } - ] - } ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: $INSTANCE_NAME - labels: - app: $INSTANCE_NAME -spec: - replicas: 1 - selector: - matchLabels: - app: $INSTANCE_NAME - template: - metadata: - labels: - app: $INSTANCE_NAME - spec: - containers: - - name: keycloak - image: quay.io/keycloak/keycloak:23.0.4 - args: - - start-dev - - --import-realm -{% if test_scenario['values']['oidc-use-tls'] == 'true' %} - - --https-certificate-file=/tls/tls.crt - - --https-certificate-key-file=/tls/tls.key -{% endif %} - env: - - name: KEYCLOAK_ADMIN - value: admin - - name: KEYCLOAK_ADMIN_PASSWORD - value: admin - ports: -{% if test_scenario['values']['oidc-use-tls'] == 'true' %} - - name: https - containerPort: 8443 -{% else %} - - name: http - containerPort: 8080 -{% endif %} - volumeMounts: - - name: realms - mountPath: /opt/keycloak/data/import - - name: tls - mountPath: /tls - readinessProbe: - httpGet: - path: /realms/$REALM -{% if test_scenario['values']['oidc-use-tls'] == 'true' %} - port: 8443 - scheme: HTTPS -{% else %} - port: 8080 - scheme: HTTP -{% endif %} - volumes: - - name: realms - configMap: - name: $INSTANCE_NAME-realms - - ephemeral: - volumeClaimTemplate: - metadata: - annotations: - secrets.stackable.tech/class: keycloak-tls-$NAMESPACE - secrets.stackable.tech/scope: service=$INSTANCE_NAME - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: "1" - storageClassName: secrets.stackable.tech - volumeMode: Filesystem - name: tls ---- -apiVersion: v1 -kind: Service -metadata: - name: $INSTANCE_NAME -spec: - selector: - app: $INSTANCE_NAME - ports: - - protocol: TCP -{% if test_scenario['values']['oidc-use-tls'] == 'true' %} - port: 8443 -{% else %} - port: 8080 -{% endif %} ---- -apiVersion: secrets.stackable.tech/v1alpha1 -kind: SecretClass -metadata: - name: keycloak-tls-$NAMESPACE -spec: - backend: - autoTls: - ca: - autoGenerate: true - secret: - name: keycloak-tls-ca - namespace: $NAMESPACE diff --git a/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 b/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 deleted file mode 100644 index 6efd6383..00000000 --- a/tests/templates/kuttl/oidc/11-create-authentication-classes.yaml.j2 +++ /dev/null @@ -1,6 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # We need to replace $NAMESPACE (by KUTTL) in the create-authentication-classes.yaml(.j2) - - script: envsubst < 11_authentication-classes.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/oidc/20-assert.yaml b/tests/templates/kuttl/oidc/20-assert.yaml deleted file mode 100644 index 47c18ad2..00000000 --- a/tests/templates/kuttl/oidc/20-assert.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -timeout: 600 ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: oidc-login-test -status: - succeeded: 1 # wait for the test job to start before streaming its logs in the next test step diff --git a/tests/templates/kuttl/oidc/20-login-test.yaml.j2 b/tests/templates/kuttl/oidc/20-login-test.yaml.j2 deleted file mode 100644 index 97e3443c..00000000 --- a/tests/templates/kuttl/oidc/20-login-test.yaml.j2 +++ /dev/null @@ -1,37 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -metadata: - name: oidc-login-test-script -commands: - - script: kubectl create configmap oidc-login-test-script --from-file login.py -n $NAMESPACE ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: oidc-login-test -spec: - template: - spec: - containers: - - name: oidc-login-test - image: oci.stackable.tech/sdp/testing-tools:0.2.0-stackable0.0.0-dev - command: ["python", "/tmp/test-script/login.py"] - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: NIFI_VERSION - value: "{{ test_scenario['values']['nifi'] }}" - - name: OIDC_USE_TLS - value: "{{ test_scenario['values']['oidc-use-tls'] }}" - volumeMounts: - - name: test-script - mountPath: /tmp/test-script - restartPolicy: OnFailure - terminationGracePeriodSeconds: 1 - volumes: - - name: test-script - configMap: - name: oidc-login-test-script diff --git a/tests/templates/kuttl/oidc/21-assert.yaml b/tests/templates/kuttl/oidc/21-assert.yaml deleted file mode 100644 index f55ee23d..00000000 --- a/tests/templates/kuttl/oidc/21-assert.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -timeout: 30 ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: oidc-login-test -status: - succeeded: 1 diff --git a/tests/templates/kuttl/oidc/21-login-test-logs.yaml b/tests/templates/kuttl/oidc/21-login-test-logs.yaml deleted file mode 100644 index 092debe4..00000000 --- a/tests/templates/kuttl/oidc/21-login-test-logs.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -metadata: - name: oidc-login-test-logs -commands: - - script: kubectl logs job/oidc-login-test -n $NAMESPACE -f diff --git a/tests/templates/kuttl/oidc/login.py b/tests/templates/kuttl/oidc/login.py deleted file mode 100644 index 4961c5fe..00000000 --- a/tests/templates/kuttl/oidc/login.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging -import os -import requests -import sys -import json -from bs4 import BeautifulSoup - -logging.basicConfig( - level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout -) - -namespace = os.environ["NAMESPACE"] -tls = os.environ["OIDC_USE_TLS"] -nifi_version = os.environ["NIFI_VERSION"] - -session = requests.Session() - -nifi = f"test-nifi-node-default-0.test-nifi-node-default.{namespace}.svc.cluster.local" -keycloak_service = f"keycloak.{namespace}.svc.cluster.local" - -keycloak_base_url = ( - f"https://{keycloak_service}:8443" - if tls == "true" - else f"http://{keycloak_service}:8080" -) - -# startswith instead of an exact check to -# a) hit all 2.x versions and -# b) to allow for custom images because `nifi_version` will contain the whole custom image string -# e.g. 2.0.0,localhost:5000/stackable/nifi:2.0.0-stackable0.0.0-dev -if not nifi_version.startswith("1."): - auth_config_page = session.get( - f"https://{nifi}:8443/nifi-api/authentication/configuration", - verify=False, - headers={"Content-type": "application/json"}, - ) - assert auth_config_page.ok, "Could not fetch auth config from NiFi" - auth_config = json.loads(auth_config_page.text) - login_url = auth_config["authenticationConfiguration"]["loginUri"] -else: - login_url = f"https://{nifi}:8443/nifi/login" - -# Open NiFi web UI which will redirect to OIDC login -login_page = session.get( - login_url, - verify=False, - headers={"Content-type": "application/json"}, -) - -print("actual: ", login_page.url) -print( - "expected: ", - f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=", -) -assert login_page.ok, "Redirection from NiFi to Keycloak failed" -assert login_page.url.startswith( - f"{keycloak_base_url}/realms/test/protocol/openid-connect/auth?response_type=code&client_id=nifi&scope=" -), "Redirection to Keycloak expected" - -# Login to keycloak with test user -login_page_html = BeautifulSoup(login_page.text, "html.parser") -authenticate_url = login_page_html.form["action"] -welcome_page = session.post( - authenticate_url, - data={"username": "jane.doe", "password": "T8mn72D9"}, - verify=False, -) -assert welcome_page.ok, "Login failed" -assert ( - welcome_page.url == f"https://{nifi}:8443/nifi/" -), "Redirection to the NiFi web UI expected" diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 55e51bc1..fafdd5b6 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -105,11 +105,12 @@ tests: - nifi-latest - zookeeper-latest - openshift - - name: oidc + - name: oidc-opa dimensions: - nifi - zookeeper-latest - oidc-use-tls + - opa-l - openshift - name: iceberg dimensions: