This is an automated email from the ASF dual-hosted git repository.
bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 9ea151d5f0 Issue #8302 : Add Kubernetes authentication to the
HashiCorp Vault variable resolver (#8305)
9ea151d5f0 is described below
commit 9ea151d5f042f6dd34abc0ce51aa80082de3acc3
Author: Matt Casters <[email protected]>
AuthorDate: Fri Sep 11 13:21:10 2026 +0200
Issue #8302 : Add Kubernetes authentication to the HashiCorp Vault variable
resolver (#8305)
* Issue #8302 : Add Kubernetes authentication to the HashiCorp Vault
variable resolver
* Issue #8302 : Address review feedback on Vault Kubernetes authentication
Apply the 30s refresh margin for non-renewable tokens, retry Kubernetes
auth once on 401/403, clear unused credentials on save, and parse auth
types with Locale.ROOT.
---
.../integration-tests/integration-tests-vault.yaml | 28 +
.../resource/vault/k8s-tokenreview.py | 186 ++++++
...lt-variable-resolver-authentication-tab-k8s.png | Bin 0 -> 85557 bytes
...-variable-resolver-authentication-tab-token.png | Bin 0 -> 59170 bytes
.../vault-variable-resolver-connection-tab.png | Bin 0 -> 99205 bytes
.../vault-variable-resolver-secrets-tab.png | Bin 0 -> 50486 bytes
.../hashicorp-vault-variable-resolver.adoc | 64 +-
.../openbao-variable-resolver.adoc | 15 +-
.../vault/0004-vault-resolve-secrets-k8s.hpl | 134 ++++
.../vault/main-0004-kubernetes-auth.hwf | 132 ++++
.../0004-vault-resolve-secrets-k8s UNIT.json | 53 ++
.../metadata/variable-resolver/vault-k8s.json | 20 +
plugins/tech/vault/pom.xml | 33 +
.../resolver/vault/BaseVaultVariableResolver.java | 694 ++++++++++++++++++---
.../variables/resolver/vault/VaultAuthType.java | 30 +
.../vault/messages/messages_en_US.properties | 12 +-
.../vault/KubernetesServiceAccountJwt.java | 81 +++
.../resolver/vault/KubernetesTokenReviewMock.java | 147 +++++
.../resolver/vault/VaultKubernetesAuthIT.java | 301 +++++++++
.../resolver/vault/VaultVariableResolverTest.java | 359 +++++++++++
.../resolver/vault/VaultWidgetVisibilityTest.java | 211 +++++++
.../variables/resolver/VariableResolverEditor.java | 3 +
22 files changed, 2403 insertions(+), 100 deletions(-)
diff --git a/docker/integration-tests/integration-tests-vault.yaml
b/docker/integration-tests/integration-tests-vault.yaml
index 6af5ae6e11..acd8f5821f 100644
--- a/docker/integration-tests/integration-tests-vault.yaml
+++ b/docker/integration-tests/integration-tests-vault.yaml
@@ -15,6 +15,9 @@
# specific language governing permissions and limitations
# under the License.
+volumes:
+ k8s-jwt:
+
services:
integration_test_vault:
extends:
@@ -23,8 +26,12 @@ services:
depends_on:
vault:
condition: service_healthy
+ k8s-mock:
+ condition: service_healthy
links:
- vault
+ volumes:
+ - k8s-jwt:/tmp/k8s-sa:ro
vault:
image: hashicorp/vault
ports:
@@ -40,3 +47,24 @@ services:
timeout: 3s
retries: 10
start_period: 5s
+ k8s-mock:
+ image: python:3.12-alpine
+ depends_on:
+ vault:
+ condition: service_healthy
+ environment:
+ JWT_PATH: /jwt/token
+ PORT: "8080"
+ VAULT_ADDR: http://vault:8200
+ VAULT_TOKEN: myroot
+ KUBERNETES_HOST: http://k8s-mock:8080
+ volumes:
+ - ./resource/vault/k8s-tokenreview.py:/k8s-tokenreview.py:ro
+ - k8s-jwt:/jwt
+ command: [ "python", "/k8s-tokenreview.py" ]
+ healthcheck:
+ test: [ "CMD", "python", "-c", "import urllib.request;
urllib.request.urlopen('http://127.0.0.1:8080/ready')" ]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ start_period: 2s
diff --git a/docker/integration-tests/resource/vault/k8s-tokenreview.py
b/docker/integration-tests/resource/vault/k8s-tokenreview.py
new file mode 100644
index 0000000000..5fccbb7898
--- /dev/null
+++ b/docker/integration-tests/resource/vault/k8s-tokenreview.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Minimal Kubernetes TokenReview API for Vault Kubernetes auth integration
tests.
+
+Also enables Vault Kubernetes auth against this mock. The Hop docker suite uses
+``--abort-on-container-exit``, so this process must keep running after setup.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import threading
+import time
+import urllib.error
+import urllib.request
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+
+NAMESPACE = "default"
+SA_NAME = "hop"
+JWT_PATH = os.environ.get("JWT_PATH", "/jwt/token")
+PORT = int(os.environ.get("PORT", "8080"))
+VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://vault:8200").rstrip("/")
+VAULT_TOKEN = os.environ.get("VAULT_TOKEN", "myroot")
+KUBERNETES_HOST = os.environ.get("KUBERNETES_HOST", "http://k8s-mock:8080")
+# RS256 JWT for SA default/hop. Vault does not verify the signature when no
+# public keys are configured, but it does require an RSA/ECDSA algorithm.
+HOP_SA_JWT = (
+ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9."
+
"eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZWNyZXQubmFtZSI6ImhvcC10b2tlbiIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUiOiJob3AiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiIxIiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6aG9wIn0."
+
"diHYA5MSwzDlpHZo6wuPsndgG-TrsGOHr87IDzfqq9-pZG21iM1bCsHt0i1liioEHe7F4XQLlXziDBHLGWKcj_PGjiP4K9FdkOCBjmyJF6hTNN0OrgHzLCKK3RBDVTXVD5xg-kCAhCeIoebg1eCuYTf6r8uiiodKSS8k1ImSdnOjM5E3TMBQpCYdnOIEmUw0uZq9Z1ome2XVrV9h173wp5BHI_fTjJpj7jVwDKhoP8RC1OWMtKsvYKMlkH4sXJqeBm60BuOT1GjLOJ9k0IkOJacLxYlyM1pz73j3J8LaD91_cAeTpyzg6MImCjdPI35pmuhzML4cTubrd097KMWZDg"
+)
+
+ready = False
+
+
+def jwt_subject(token: str) -> str:
+ try:
+ payload_b64 = token.split(".")[1]
+ payload_b64 += "=" * (-len(payload_b64) % 4)
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
+ return str(payload.get("sub", ""))
+ except Exception:
+ return ""
+
+
+def write_jwt() -> None:
+ directory = os.path.dirname(JWT_PATH)
+ if directory:
+ os.makedirs(directory, exist_ok=True)
+ with open(JWT_PATH, "w", encoding="utf-8") as handle:
+ handle.write(HOP_SA_JWT)
+
+
+def vault_write(path: str, payload: dict) -> None:
+ data = json.dumps(payload).encode("utf-8")
+ request = urllib.request.Request(
+ VAULT_ADDR + path,
+ data=data,
+ headers={
+ "X-Vault-Token": VAULT_TOKEN,
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+ try:
+ urllib.request.urlopen(request, timeout=10)
+ except urllib.error.HTTPError as error:
+ body = error.read().decode("utf-8", errors="replace")
+ if error.code == 400 and "already in use" in body:
+ return
+ raise RuntimeError(f"Vault {path} returned {error.code}: {body}") from
error
+
+
+def wait_for_vault() -> None:
+ last_error = None
+ for _ in range(30):
+ try:
+ urllib.request.urlopen(VAULT_ADDR + "/v1/sys/health", timeout=3)
+ return
+ except Exception as error: # noqa: BLE001
+ last_error = error
+ time.sleep(1)
+ raise RuntimeError(f"Vault at {VAULT_ADDR} did not become ready:
{last_error}")
+
+
+def configure_vault() -> None:
+ wait_for_vault()
+ vault_write("/v1/sys/auth/kubernetes", {"type": "kubernetes"})
+ vault_write(
+ "/v1/auth/kubernetes/config",
+ {
+ "kubernetes_host": KUBERNETES_HOST,
+ "disable_iss_validation": True,
+ "disable_local_ca_jwt": True,
+ "token_reviewer_jwt": "dummy",
+ },
+ )
+ vault_write(
+ "/v1/sys/policies/acl/hop-read",
+ {"policy": 'path "secret/data/*" { capabilities = ["read"] }'},
+ )
+ vault_write(
+ "/v1/auth/kubernetes/role/hop",
+ {
+ "bound_service_account_names": SA_NAME,
+ "bound_service_account_namespaces": NAMESPACE,
+ "policies": ["hop-read"],
+ "ttl": "1h",
+ },
+ )
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None: # noqa: N802
+ if self.path.startswith("/ready"):
+ if ready:
+ self._send(200, b"ok", "text/plain")
+ else:
+ self._send(503, b"not ready", "text/plain")
+ return
+ if self.path.startswith("/healthz"):
+ self._send(200, b"ok", "text/plain")
+ return
+ self._send(200, b'{"kind":"APIVersions","versions":["v1"]}',
"application/json")
+
+ def do_POST(self) -> None: # noqa: N802
+ length = int(self.headers.get("Content-Length", "0"))
+ body = self.rfile.read(length) if length else b"{}"
+ token = ""
+ try:
+ token = json.loads(body.decode("utf-8")).get("spec",
{}).get("token", "")
+ except Exception:
+ token = ""
+ sub = jwt_subject(token)
+ authenticated = sub.startswith("system:serviceaccount:")
+ review = {
+ "apiVersion": "authentication.k8s.io/v1",
+ "kind": "TokenReview",
+ "status": {
+ "authenticated": authenticated,
+ "user": {
+ "username": sub,
+ "uid": "1",
+ "groups": ["system:serviceaccounts"],
+ },
+ },
+ }
+ self._send(200, json.dumps(review).encode("utf-8"), "application/json")
+
+ def log_message(self, format: str, *args) -> None: # noqa: A003
+ return
+
+ def _send(self, status: int, body: bytes, content_type: str) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+if __name__ == "__main__":
+ write_jwt()
+ server = HTTPServer(("0.0.0.0", PORT), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ configure_vault()
+ ready = True
+ thread.join()
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
new file mode 100644
index 0000000000..5f1767a506
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
new file mode 100644
index 0000000000..6904a9287d
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
new file mode 100644
index 0000000000..02bee8ab1a
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
new file mode 100644
index 0000000000..ae9875e02b
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
index 4e8948b83e..f85e86cf87 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
@@ -23,11 +23,43 @@ under the License.
== Functionality
This variable resolver can retrieve secrets from a
https://www.vaultproject.io/[Hashicorp Vault].
-Here are the options to use:
+The editor groups the options into **Connection**, **Authentication** and
**Secrets**. The Authentication tab only shows the fields that belong to the
authentication type you picked.
+
+=== Connection
+
+image::metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png[Connection
tab of the HashiCorp Vault variable resolver]
* Vault address: The base address and port of the Vault server (for example:
https://vault-server:8200)
-* Vault token: The token to use to authenticate
* Namespace: The Vault namespace to use (optional, primarily used in
enterprise/multi-tenant setups)
+* Validate HTTPS connections?: It's recommended to enable connection
validation in production. This secures the connection with the X.509
certificate specified in one of either next option.
+* PEM file path: The name of the file (VFS) containing the X.509 certificate
string
+* PEM string: The X.509 string itself in case you're not using a file
+* Open connection timeout: The connection timeout when getting a http(s)
connection in milliseconds.
+* Read timeout: The timeout in milliseconds when reading.
+
+=== Authentication
+
+How Hop proves its identity to Vault. `TOKEN` is the default and keeps
existing resolvers working. `KUBERNETES` is intended for Hop Server (or Hop
Gui) running in a Kubernetes pod.
+
+When the authentication type is `TOKEN`, only the Vault token field is shown:
+
+image::metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png[Authentication
tab with type TOKEN]
+
+* Vault token: The token to use to authenticate.
+
+When the authentication type is `KUBERNETES`, the token field is replaced by
the Kubernetes options:
+
+image::metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png[Authentication
tab with type KUBERNETES]
+
+* Kubernetes role: The Vault Kubernetes auth role bound to this workload's
ServiceAccount.
+* Kubernetes JWT file path: File that holds the ServiceAccount JWT. Defaults
to `/var/run/secrets/kubernetes.io/serviceaccount/token` when left empty.
+* Kubernetes JWT: Optional JWT string used instead of the file when set (a
variable such as `'${KUBERNETES_JWT}'` is typical).
+* Kubernetes auth mount path: Vault auth mount without the leading `auth/`.
Defaults to `kubernetes`.
+
+=== Secrets
+
+image::metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png[Secrets
tab of the HashiCorp Vault variable resolver]
+
* Path prefix: an optional path prefix which gets added before the key paths
in the resolver expressions. For example, if you put `kv-other/data` in here,
expression
[source]
@@ -42,13 +74,6 @@ will resolve internally to:
#{vault:kv-other/data/db:password}
----
-
-* Validate HTTPS connections?: It's recommended to enable connection
validation in production. This secures the connection with the X.509
certificate specified in one of either next option.
-* PEM file path: The name of the file (VFS) containing the X.509 certificate
string
-* PEM string: The X.509 string itself in case you're not using a file
-* Open connection timeout: The connection timeout when getting a http(s)
connection in milliseconds.
-* Read connection timeout: The timeout in milliseconds when reading.
-
The variable expression you can resolve with this plugin type is (as always)
in the following format:
`{openvar}name:path-key:value-key{closevar}`
@@ -63,7 +88,7 @@ In case we don't specify a `value-key`, you will give back
the complete JSON str
Suppose we have a secret defined in the Vault, in a KV secrets engine:
-image:metadata-types/variable-resolver/vault-variable-resolver-server.png
+image::metadata-types/variable-resolver/vault-variable-resolver-server.png[Secret
stored in a HashiCorp Vault KV engine]
We can define a connection called `vault` and retrieve values with expressions:
@@ -71,3 +96,22 @@ We can define a connection called `vault` and retrieve
values with expressions:
* `{openvar}vault:hop/data/some-db:username{closevar}` : john
* `{openvar}vault:hop/data/some-db{closevar}` :
`{"db":"test","hostname":"localhost","password":"some-password","port":"3306","username":"john"}`
+== Kubernetes authentication
+
+When Hop runs inside a Kubernetes pod, prefer `KUBERNETES` over a long-lived
Vault token. Hop reads the pod ServiceAccount JWT, logs in to Vault's
Kubernetes auth backend, and uses the short-lived client token Vault returns.
That token is kept in memory, renewed when Vault says it is renewable, and
replaced by a fresh login when it expires. It is never stored in the resolver
metadata.
+
+On the Vault side, enable Kubernetes auth and bind a role to the
ServiceAccount Hop runs as:
+
+[source,bash]
+----
+vault auth enable kubernetes
+vault write auth/kubernetes/config
kubernetes_host=https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT
+vault write auth/kubernetes/role/hop \
+ bound_service_account_names=hop \
+ bound_service_account_namespaces=hop \
+ policies=hop \
+ ttl=1h
+----
+
+In the variable resolver metadata, set Authentication type to `KUBERNETES` on
the Authentication tab, Kubernetes role to `hop`, and Vault address on the
Connection tab to the in-cluster Vault URL. Leave the JWT file path empty to
use the token Kubernetes mounts at
`/var/run/secrets/kubernetes.io/serviceaccount/token`.
+
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
index 4ece79afce..d304df0237 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
@@ -21,10 +21,17 @@ limitations under the License.
== Functionality
This variable resolver can retrieve secrets from an
https://openbao.org/[OpenBAO] instance.
+The editor is the same as the
xref:metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc[HashiCorp
Vault variable resolver]: options are grouped into **Connection**,
**Authentication** and **Secrets**, and the Authentication tab only shows the
fields that belong to the authentication type you picked.
+
Here are the options to use:
* OpenBAO address: The base address and port of the OpenBAO server (for
example: https://openbao-server:8200)
-* OpenBAO token: The token to use to authenticate
+* Authentication type: How Hop proves its identity to OpenBAO. `TOKEN` is the
default and keeps existing resolvers working. `KUBERNETES` is intended for Hop
Server (or Hop Gui) running in a Kubernetes pod.
+* OpenBAO token: The token to use to authenticate. Shown only when the
authentication type is `TOKEN`.
+* Kubernetes role: The OpenBAO Kubernetes auth role bound to this workload's
ServiceAccount. Shown only when the authentication type is `KUBERNETES`.
+* Kubernetes JWT file path: File that holds the ServiceAccount JWT. Defaults
to `/var/run/secrets/kubernetes.io/serviceaccount/token` when left empty. Shown
only for `KUBERNETES`.
+* Kubernetes JWT: Optional JWT string used instead of the file when set (a
variable such as `'${KUBERNETES_JWT}'` is typical). Shown only for `KUBERNETES`.
+* Kubernetes auth mount path: OpenBAO auth mount without the leading `auth/`.
Defaults to `kubernetes`. Shown only for `KUBERNETES`.
* Namespace: The namespace to use (optional, primarily used in
enterprise/multi-tenant setups)
* Path prefix: an optional path prefix which gets added before the key paths
in the resolver expressions. For example, if you put `kv-other/data` in here,
expression
@@ -64,3 +71,9 @@ We can define a connection called `openbao` and retrieve
values with expressions
* `{openvar}openbao:hop/data/some-db:hostname{closevar}` : localhost
* `{openvar}openbao:hop/data/some-db:username{closevar}` : john
* `{openvar}openbao:hop/data/some-db{closevar}` :
`{"db":"test","hostname":"localhost","password":"some-password","port":"3306","username":"john"}`
+
+== Kubernetes authentication
+
+When Hop runs inside a Kubernetes pod, prefer `KUBERNETES` over a long-lived
OpenBAO token. Hop reads the pod ServiceAccount JWT, logs in to OpenBAO's
Kubernetes auth backend, and uses the short-lived client token OpenBAO returns.
That token is kept in memory, renewed when OpenBAO says it is renewable, and
replaced by a fresh login when it expires. It is never stored in the resolver
metadata.
+
+On the OpenBAO side, enable Kubernetes auth and bind a role to the
ServiceAccount Hop runs as, then set Authentication type to `KUBERNETES` and
Kubernetes role to that role name. Leave the JWT file path empty to use the
token Kubernetes mounts at
`/var/run/secrets/kubernetes.io/serviceaccount/token`.
diff --git a/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl
b/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl
new file mode 100644
index 0000000000..74746cf19b
--- /dev/null
+++ b/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0004-vault-resolve-secrets-k8s</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2025/01/14 15:54:11.134</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2025/01/14 15:54:11.134</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>Get variables</from>
+ <to>Output</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Get variables</name>
+ <type>GetVariable</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <length>-1</length>
+ <name>json-data</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop}</variable>
+ </field>
+ <field>
+ <length>-1</length>
+ <name>hostname</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop:hostname}</variable>
+ </field>
+ <field>
+ <length>-1</length>
+ <name>port</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop:port}</variable>
+ </field>
+ <field>
+ <length>-1</length>
+ <name>db</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop:db}</variable>
+ </field>
+ <field>
+ <length>-1</length>
+ <name>username</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop:username}</variable>
+ </field>
+ <field>
+ <length>-1</length>
+ <name>password</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{vault-k8s:secret/data/hop:password}</variable>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>64</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Output</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/vault/main-0004-kubernetes-auth.hwf
b/integration-tests/vault/main-0004-kubernetes-auth.hwf
new file mode 100644
index 0000000000..cb249a2625
--- /dev/null
+++ b/integration-tests/vault/main-0004-kubernetes-auth.hwf
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0004-kubernetes-auth</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2025/01/14 15:46:57.125</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2025/01/14 15:46:57.125</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0001-vault-add-secrets.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0001-vault-add-secrets.hpl</filename>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Run Pipeline Unit Tests</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <attributes/>
+ <test_names>
+ <test_name>
+ <name>0004-vault-resolve-secrets-k8s UNIT</name>
+ </test_name>
+ </test_names>
+ <parallel>N</parallel>
+ <xloc>416</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0001-vault-add-secrets.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>0001-vault-add-secrets.hpl</from>
+ <to>Run Pipeline Unit Tests</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ <notepad>
+ <backgroundcolorblue>251</backgroundcolorblue>
+ <backgroundcolorgreen>232</backgroundcolorgreen>
+ <backgroundcolorred>201</backgroundcolorred>
+ <bordercolorblue>90</bordercolorblue>
+ <bordercolorgreen>58</bordercolorgreen>
+ <bordercolorred>14</bordercolorred>
+ <fontbold>N</fontbold>
+ <fontcolorblue>90</fontcolorblue>
+ <fontcolorgreen>58</fontcolorgreen>
+ <fontcolorred>14</fontcolorred>
+ <fontitalic>N</fontitalic>
+ <fontsize>-1</fontsize>
+ <height>32</height>
+ <xloc>119</xloc>
+ <yloc>196</yloc>
+ <note>VAULT_ADDR='http://0.0.0.0:8200'</note>
+ <width>32</width>
+ </notepad>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s
UNIT.json
b/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s
UNIT.json
new file mode 100644
index 0000000000..8dea0b3eef
--- /dev/null
+++ b/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s
UNIT.json
@@ -0,0 +1,53 @@
+{
+ "database_replacements": [],
+ "autoOpening": true,
+ "description": "",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "json-data",
+ "data_set_field": "json-data"
+ },
+ {
+ "transform_field": "hostname",
+ "data_set_field": "hostname"
+ },
+ {
+ "transform_field": "port",
+ "data_set_field": "port"
+ },
+ {
+ "transform_field": "db",
+ "data_set_field": "db"
+ },
+ {
+ "transform_field": "username",
+ "data_set_field": "username"
+ },
+ {
+ "transform_field": "password",
+ "data_set_field": "password"
+ }
+ ],
+ "field_order": [
+ "json-data",
+ "hostname",
+ "port",
+ "db",
+ "username",
+ "password"
+ ],
+ "data_set_name": "0001-golden-resolved-variables",
+ "transform_name": "Output"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0004-vault-resolve-secrets-k8s UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0004-vault-resolve-secrets-k8s.hpl"
+}
\ No newline at end of file
diff --git a/integration-tests/vault/metadata/variable-resolver/vault-k8s.json
b/integration-tests/vault/metadata/variable-resolver/vault-k8s.json
new file mode 100644
index 0000000000..6f78305be9
--- /dev/null
+++ b/integration-tests/vault/metadata/variable-resolver/vault-k8s.json
@@ -0,0 +1,20 @@
+{
+ "virtualPath": "",
+ "name": "vault-k8s",
+ "description": "Hashicorp Vault Kubernetes auth",
+ "variable-resolver": {
+ "Vault-Variable-Resolver": {
+ "vaultAddress": "http://vault:8200",
+ "pemString": "",
+ "openTimeout": "",
+ "verifyingSsl": false,
+ "pemFilePath": "",
+ "readTimeout": "",
+ "authenticationType": "KUBERNETES",
+ "kubernetesRole": "hop",
+ "kubernetesJwtPath": "/tmp/k8s-sa/token",
+ "kubernetesJwt": "",
+ "kubernetesAuthPath": "kubernetes"
+ }
+ }
+}
diff --git a/plugins/tech/vault/pom.xml b/plugins/tech/vault/pom.xml
index 0e9f02ffd9..fce6b048ac 100755
--- a/plugins/tech/vault/pom.xml
+++ b/plugins/tech/vault/pom.xml
@@ -29,6 +29,7 @@
<name>Hop Plugins Technology Hashicorp Vault</name>
<properties>
+ <testcontainers.version>1.21.4</testcontainers.version>
<vault.driver.version>6.2.2</vault.driver.version>
</properties>
@@ -38,5 +39,37 @@
<artifactId>vault-java-driver</artifactId>
<version>${vault.driver.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
+
+ <build>
+ <plugins>
+ <!-- The *IT tests start a Vault server in a container. They are
skipped automatically
+ when no Docker daemon is available, so a build without Docker
is unaffected.
+ Use -DskipITs to skip them explicitly. -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-failsafe-plugin</artifactId>
+ <executions>
+ <execution>
+ <goals>
+ <goal>integration-test</goal>
+ <goal>verify</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
</project>
diff --git
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
index 02d28c1794..fe9ac7e52a 100644
---
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
+++
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
@@ -21,12 +21,19 @@ package org.apache.hop.core.variables.resolver.vault;
import io.github.jopenlibs.vault.SslConfig;
import io.github.jopenlibs.vault.Vault;
import io.github.jopenlibs.vault.VaultConfig;
+import io.github.jopenlibs.vault.VaultException;
+import io.github.jopenlibs.vault.response.AuthResponse;
import io.github.jopenlibs.vault.response.LogicalResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
@@ -34,125 +41,453 @@ import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.gui.plugin.GuiElementType;
import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.resolver.IVariableResolver;
import org.apache.hop.core.variables.resolver.VariableResolver;
import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.core.widget.ComboVar;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Text;
@Getter
@Setter
-public abstract class BaseVaultVariableResolver implements IVariableResolver {
+public abstract class BaseVaultVariableResolver
+ implements IVariableResolver, IGuiPluginCompositeWidgetsListener {
+
+ static final String ID_VAULT_ADDRESS = "vaultAddress";
+ static final String ID_AUTHENTICATION_TYPE = "authenticationType";
+ static final String ID_VAULT_TOKEN = "vaultToken";
+ static final String ID_KUBERNETES_ROLE = "kubernetesRole";
+ static final String ID_KUBERNETES_JWT_PATH = "kubernetesJwtPath";
+ static final String ID_KUBERNETES_JWT = "kubernetesJwt";
+ static final String ID_KUBERNETES_AUTH_PATH = "kubernetesAuthPath";
+ static final String ID_PATH_PREFIX = "pathPrefix";
+ static final String ID_NAMESPACE = "namespace";
+ static final String ID_VERIFYING_SSL = "verifyingSsl";
+ static final String ID_PEM_FILE_PATH = "pemFilePath";
+ static final String ID_PEM_STRING = "pemString";
+ static final String ID_OPEN_TIMEOUT = "openTimeout";
+ static final String ID_READ_TIMEOUT = "readTimeout";
+
+ static final String DEFAULT_KUBERNETES_JWT_PATH =
+ "/var/run/secrets/kubernetes.io/serviceaccount/token";
+ static final String DEFAULT_KUBERNETES_AUTH_MOUNT = "kubernetes";
+ static final String I18N_PREFIX =
+
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.";
+
+ private static final String GROUP_CONNECTION = "Connection";
+ private static final String GROUP_AUTHENTICATION = "Authentication";
+ private static final String GROUP_SECRETS = "Secrets";
+ private static final long REFRESH_MARGIN_MILLIS = 30_000L;
+
+ private final Object clientLock = new Object();
+
+ private transient Vault vaultClient;
+ private transient String clientSignature;
+ private transient long tokenExpiryMillis;
+ private transient boolean tokenRenewable;
@GuiWidgetElement(
- id = "vaultAddress",
- order = "10",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.vaultAddress",
+ id = ID_VAULT_ADDRESS,
+ order = "010",
+ label = I18N_PREFIX + "label.vaultAddress",
type = GuiElementType.TEXT,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
@HopMetadataProperty
protected String vaultAddress;
@GuiWidgetElement(
- id = "vaultToken",
- order = "20",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.vaultToken",
+ id = ID_NAMESPACE,
+ order = "020",
+ label = I18N_PREFIX + "label.namespace",
+ type = GuiElementType.TEXT,
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
+ @HopMetadataProperty
+ protected String namespace;
+
+ @GuiWidgetElement(
+ id = ID_VERIFYING_SSL,
+ order = "030",
+ label = I18N_PREFIX + "label.verifyingSsl",
+ toolTip = I18N_PREFIX + "tooltip.verifyingSsl",
+ type = GuiElementType.CHECKBOX,
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
+ @HopMetadataProperty
+ protected boolean verifyingSsl;
+
+ @GuiWidgetElement(
+ id = ID_PEM_FILE_PATH,
+ order = "040",
+ label = I18N_PREFIX + "label.pemFilePath",
+ type = GuiElementType.FILENAME,
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
+ @HopMetadataProperty
+ protected String pemFilePath;
+
+ @GuiWidgetElement(
+ id = ID_PEM_STRING,
+ order = "050",
+ label = I18N_PREFIX + "label.pemString",
type = GuiElementType.TEXT,
password = true,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
@HopMetadataProperty
- protected String vaultToken;
+ protected String pemString;
@GuiWidgetElement(
- id = "pathPrefix",
- order = "30",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pathPrefix",
+ id = ID_OPEN_TIMEOUT,
+ order = "060",
+ label = I18N_PREFIX + "label.openTimeout",
type = GuiElementType.TEXT,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
@HopMetadataProperty
- protected String pathPrefix;
+ protected String openTimeout;
@GuiWidgetElement(
- id = "namespace",
- order = "35",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.namespace",
+ id = ID_READ_TIMEOUT,
+ order = "070",
+ label = I18N_PREFIX + "label.readTimeout",
type = GuiElementType.TEXT,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_CONNECTION,
+ groupOrder = "010")
@HopMetadataProperty
- protected String namespace;
+ protected String readTimeout;
@GuiWidgetElement(
- id = "verifyingSsl",
- order = "40",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.verifyingSsl",
- toolTip =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.tooltip.verifyingSsl",
- type = GuiElementType.CHECKBOX,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ id = ID_AUTHENTICATION_TYPE,
+ order = "010",
+ label = I18N_PREFIX + "label.authenticationType",
+ toolTip = I18N_PREFIX + "tooltip.authenticationType",
+ type = GuiElementType.COMBO,
+ comboValuesMethod = "getAuthenticationTypes",
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
@HopMetadataProperty
- protected boolean verifyingSsl;
+ protected String authenticationType;
+
+ @GuiWidgetElement(
+ id = ID_VAULT_TOKEN,
+ order = "020",
+ label = I18N_PREFIX + "label.vaultToken",
+ type = GuiElementType.TEXT,
+ password = true,
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
+ @HopMetadataProperty
+ protected String vaultToken;
@GuiWidgetElement(
- id = "pemFilePath",
- order = "50",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pemFilePath",
+ id = ID_KUBERNETES_ROLE,
+ order = "030",
+ label = I18N_PREFIX + "label.kubernetesRole",
+ toolTip = I18N_PREFIX + "tooltip.kubernetesRole",
+ type = GuiElementType.TEXT,
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
+ @HopMetadataProperty
+ protected String kubernetesRole;
+
+ @GuiWidgetElement(
+ id = ID_KUBERNETES_JWT_PATH,
+ order = "040",
+ label = I18N_PREFIX + "label.kubernetesJwtPath",
+ toolTip = I18N_PREFIX + "tooltip.kubernetesJwtPath",
type = GuiElementType.FILENAME,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
@HopMetadataProperty
- protected String pemFilePath;
+ protected String kubernetesJwtPath;
@GuiWidgetElement(
- id = "pemString",
- order = "60",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pemString",
+ id = ID_KUBERNETES_JWT,
+ order = "050",
+ label = I18N_PREFIX + "label.kubernetesJwt",
+ toolTip = I18N_PREFIX + "tooltip.kubernetesJwt",
type = GuiElementType.TEXT,
password = true,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
@HopMetadataProperty
- protected String pemString;
+ protected String kubernetesJwt;
@GuiWidgetElement(
- id = "openTimeout",
- order = "70",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.openTimeout",
+ id = ID_KUBERNETES_AUTH_PATH,
+ order = "060",
+ label = I18N_PREFIX + "label.kubernetesAuthPath",
+ toolTip = I18N_PREFIX + "tooltip.kubernetesAuthPath",
type = GuiElementType.TEXT,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_AUTHENTICATION,
+ groupOrder = "020")
@HopMetadataProperty
- protected String openTimeout;
+ protected String kubernetesAuthPath;
@GuiWidgetElement(
- id = "readTimeout",
- order = "80",
- label =
-
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.readTimeout",
+ id = ID_PATH_PREFIX,
+ order = "010",
+ label = I18N_PREFIX + "label.pathPrefix",
type = GuiElementType.TEXT,
- parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_SECRETS,
+ groupOrder = "030")
@HopMetadataProperty
- protected String readTimeout;
+ protected String pathPrefix;
+
+ protected BaseVaultVariableResolver() {
+ authenticationType = VaultAuthType.TOKEN.name();
+ }
@Override
public String resolve(String secretPath, IVariables variables) throws
HopException {
+ if (StringUtils.isEmpty(secretPath)) {
+ return null;
+ }
try {
- // If we don't have any argument, give up immediately.
- //
- if (StringUtils.isEmpty(secretPath)) {
- return null;
+ return lookupSecret(secretPath, variables);
+ } catch (Exception first) {
+ if (shouldRetryAuth(first, variables)) {
+ invalidateCachedClient();
+ try {
+ return lookupSecret(secretPath, variables);
+ } catch (Exception retry) {
+ LogChannel.GENERAL.logError(
+ "Error looking up secret '" + secretPath + "' in the Variable
resolver", retry);
+ return null;
+ }
+ }
+ LogChannel.GENERAL.logError(
+ "Error looking up secret '" + secretPath + "' in the Variable
resolver", first);
+ return null;
+ }
+ }
+
+ private String lookupSecret(String secretPath, IVariables variables) throws
Exception {
+ Vault vault = getVault(variables);
+
+ String path;
+ if (StringUtils.isNotEmpty(pathPrefix)) {
+ path = variables.resolve(pathPrefix) + secretPath;
+ } else {
+ path = secretPath;
+ }
+
+ LogicalResponse logicalResponse = vault.logical().read(path);
+ if (logicalResponse == null) {
+ LogChannel.GENERAL.logDetailed(
+ "The secret with path '" + secretPath + "' was not found in the
vault");
+ return null;
+ }
+ // If we don't have a value to retrieve, simply return the "data" value.
+ //
+ return logicalResponse.getData().get("data");
+ }
+
+ /**
+ * Returns a Vault client for the current configuration, logging in or
renewing when needed. The
+ * client is reused until the resolved configuration changes or a Kubernetes
token is close to
+ * expiry.
+ */
+ protected Vault getVault(IVariables variables) throws HopException {
+ String signature = clientSignature(variables);
+
+ synchronized (clientLock) {
+ if (vaultClient != null && signature.equals(clientSignature) &&
!tokenNeedsRefresh()) {
+ return vaultClient;
}
+ if (vaultClient != null
+ && signature.equals(clientSignature)
+ && tokenNeedsRefresh()
+ && tokenRenewable
+ && tryRenew()) {
+ return vaultClient;
+ }
+
+ vaultClient = buildVault(variables);
+ clientSignature = signature;
+ return vaultClient;
+ }
+ }
+
+ protected Vault buildVault(IVariables variables) throws HopException {
+ VaultAuthType authType =
parseAuthType(variables.resolve(authenticationType));
+ VaultConfig vaultConfig = buildBaseConfig(variables);
+
+ if (authType == VaultAuthType.KUBERNETES) {
+ return buildKubernetesVault(variables, vaultConfig);
+ }
+ return buildTokenVault(variables, vaultConfig);
+ }
+
+ private Vault buildTokenVault(IVariables variables, VaultConfig vaultConfig)
throws HopException {
+ String actualVaultToken = variables.resolve(vaultToken);
+ if (StringUtils.isEmpty(actualVaultToken)) {
+ throw new HopException(
+ "A Vault token is required when the authentication type is "
+ + VaultAuthType.TOKEN.name());
+ }
+ vaultConfig.token(actualVaultToken);
+ finalizeConfig(vaultConfig);
+ tokenExpiryMillis = 0L;
+ tokenRenewable = false;
+ return createVault(vaultConfig);
+ }
+
+ private Vault buildKubernetesVault(IVariables variables, VaultConfig
vaultConfig)
+ throws HopException {
+ String role = variables.resolve(kubernetesRole);
+ if (StringUtils.isEmpty(role)) {
+ throw new HopException(
+ "A Kubernetes role is required when the authentication type is "
+ + VaultAuthType.KUBERNETES.name());
+ }
+ String jwt = loadServiceAccountJwt(variables);
+ String loginPath =
kubernetesLoginPath(variables.resolve(kubernetesAuthPath));
+
+ finalizeConfig(vaultConfig);
+ Vault loginVault = createVault(vaultConfig);
+ AuthResponse response = loginByKubernetes(loginVault, role, jwt,
loginPath);
+ String clientToken = response == null ? null :
response.getAuthClientToken();
+ if (StringUtils.isEmpty(clientToken)) {
+ throw new HopException(
+ "Vault Kubernetes authentication did not return a client token for
role '" + role + "'");
+ }
+
+ vaultConfig.token(clientToken);
+ finalizeConfig(vaultConfig);
+ updateLease(response);
+ return createVault(vaultConfig);
+ }
+
+ private static void finalizeConfig(VaultConfig vaultConfig) throws
HopException {
+ try {
+ vaultConfig.build();
+ } catch (Exception e) {
+ throw new HopException("Error building the Vault client configuration",
e);
+ }
+ }
+
+ protected Vault createVault(VaultConfig vaultConfig) {
+ return Vault.create(vaultConfig);
+ }
+
+ protected AuthResponse loginByKubernetes(Vault vault, String role, String
jwt, String loginPath)
+ throws HopException {
+ try {
+ if (DEFAULT_KUBERNETES_LOGIN_PATH.equals(loginPath)) {
+ return vault.auth().loginByKubernetes(role, jwt);
+ }
+ return vault.auth().loginByKubernetes(role, jwt, loginPath);
+ } catch (Exception e) {
+ throw new HopException("Unable to log in to Vault with Kubernetes
authentication", e);
+ }
+ }
+
+ private static final String DEFAULT_KUBERNETES_LOGIN_PATH =
+ "auth/" + DEFAULT_KUBERNETES_AUTH_MOUNT;
- String actualVaultToken = variables.resolve(vaultToken);
+ protected String loadServiceAccountJwt(IVariables variables) throws
HopException {
+ String jwt = variables.resolve(kubernetesJwt);
+ if (StringUtils.isNotEmpty(jwt)) {
+ return jwt.trim();
+ }
+ String path = variables.resolve(kubernetesJwtPath);
+ if (StringUtils.isEmpty(path)) {
+ path = DEFAULT_KUBERNETES_JWT_PATH;
+ }
+ try (InputStream is = HopVfs.getInputStream(path, variables)) {
+ return readUtf8StringFromInputStream(is).trim();
+ } catch (Exception e) {
+ throw new HopException(
+ "Could not read the Kubernetes service account token from '" + path
+ "'", e);
+ }
+ }
+
+ static String kubernetesLoginPath(String mount) {
+ if (StringUtils.isEmpty(mount)) {
+ return DEFAULT_KUBERNETES_LOGIN_PATH;
+ }
+ String trimmed = mount.trim();
+ if (trimmed.startsWith("auth/")) {
+ return trimmed;
+ }
+ return "auth/" + trimmed;
+ }
+
+ VaultAuthType parseAuthType(String actualAuthType) throws HopException {
+ if (StringUtils.isEmpty(actualAuthType)) {
+ return VaultAuthType.TOKEN;
+ }
+ try {
+ return
VaultAuthType.valueOf(actualAuthType.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new HopException(
+ "Unknown Vault authentication type '"
+ + actualAuthType
+ + "'. Valid values are: "
+ + String.join(", ", authTypeNames()),
+ e);
+ }
+ }
+
+ public List<String> getAuthenticationTypes(
+ ILogChannel logChannel, IHopMetadataProvider metadataProvider) {
+ return authTypeNames();
+ }
+
+ private static List<String> authTypeNames() {
+ List<String> names = new ArrayList<>();
+ for (VaultAuthType type : VaultAuthType.values()) {
+ names.add(type.name());
+ }
+ return names;
+ }
+
+ private VaultConfig buildBaseConfig(IVariables variables) throws
HopException {
+ try {
String actualVaultAddress = variables.resolve(vaultAddress);
final VaultConfig vaultConfig = new VaultConfig();
vaultConfig.address(actualVaultAddress);
- vaultConfig.token(actualVaultToken);
vaultConfig.engineVersion(1);
if (StringUtils.isNotEmpty(namespace)) {
@@ -162,10 +497,8 @@ public abstract class BaseVaultVariableResolver implements
IVariableResolver {
final SslConfig sslConfig = new SslConfig();
sslConfig.verify(isVerifyingSsl());
String pemUtf8 = null;
- // Is our PEM String located in a file?
- //
if (StringUtils.isNotEmpty(pemFilePath)) {
- try (InputStream is =
HopVfs.getInputStream(variables.resolve(pemFilePath))) {
+ try (InputStream is =
HopVfs.getInputStream(variables.resolve(pemFilePath), variables)) {
pemUtf8 = readUtf8StringFromInputStream(is);
}
} else if (StringUtils.isNotEmpty(pemString)) {
@@ -189,35 +522,127 @@ public abstract class BaseVaultVariableResolver
implements IVariableResolver {
vaultConfig.readTimeout(timeOut);
}
}
+ return vaultConfig;
+ } catch (HopException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new HopException("Error building the Vault client configuration",
e);
+ }
+ }
- vaultConfig.build();
+ private String clientSignature(IVariables variables) {
+ return String.join(
+ "\t",
+ Const.NVL(variables.resolve(vaultAddress), ""),
+ Const.NVL(variables.resolve(authenticationType), ""),
+ Const.NVL(variables.resolve(vaultToken), ""),
+ Const.NVL(variables.resolve(kubernetesRole), ""),
+ Const.NVL(variables.resolve(kubernetesJwtPath), ""),
+ Const.NVL(variables.resolve(kubernetesJwt), ""),
+ Const.NVL(variables.resolve(kubernetesAuthPath), ""),
+ Const.NVL(variables.resolve(namespace), ""),
+ Boolean.toString(verifyingSsl),
+ Const.NVL(variables.resolve(pemFilePath), ""),
+ Const.NVL(variables.resolve(pemString), ""),
+ Const.NVL(variables.resolve(openTimeout), ""),
+ Const.NVL(variables.resolve(readTimeout), ""));
+ }
- final Vault vault = Vault.create(vaultConfig);
+ private boolean tokenNeedsRefresh() {
+ if (tokenExpiryMillis <= 0L) {
+ return false;
+ }
+ return System.currentTimeMillis() >= tokenExpiryMillis -
REFRESH_MARGIN_MILLIS;
+ }
- String path;
- if (StringUtils.isNotEmpty(pathPrefix)) {
- path = variables.resolve(pathPrefix) + secretPath;
- } else {
- path = secretPath;
- }
+ private boolean tryRenew() {
+ try {
+ AuthResponse renewed = vaultClient.auth().renewSelf();
+ updateLease(renewed);
+ return true;
+ } catch (Exception e) {
+ LogChannel.GENERAL.logDetailed(
+ "Could not renew the Vault token, will re-authenticate instead", e);
+ return false;
+ }
+ }
+
+ private void updateLease(AuthResponse response) {
+ if (response == null) {
+ tokenExpiryMillis = 0L;
+ tokenRenewable = false;
+ return;
+ }
+ long leaseSeconds = response.getAuthLeaseDuration();
+ tokenRenewable = response.isAuthRenewable();
+ // A missing lease would otherwise cache the token forever. Use the
refresh margin so a
+ // Kubernetes login without TTL is retried instead of being kept until it
403s.
+ tokenExpiryMillis =
+ System.currentTimeMillis()
+ + (leaseSeconds <= 0L ? REFRESH_MARGIN_MILLIS : leaseSeconds *
1000L);
+ }
+
+ void expireCachedToken() {
+ tokenExpiryMillis = System.currentTimeMillis();
+ tokenRenewable = false;
+ }
+
+ void setCachedLeaseForTest(long expiryMillis, boolean renewable) {
+ tokenExpiryMillis = expiryMillis;
+ tokenRenewable = renewable;
+ }
+
+ void invalidateCachedClient() {
+ synchronized (clientLock) {
+ vaultClient = null;
+ clientSignature = null;
+ tokenExpiryMillis = 0L;
+ tokenRenewable = false;
+ }
+ }
+
+ boolean shouldRetryAuth(Exception error, IVariables variables) {
+ if (!isAuthFailure(error)) {
+ return false;
+ }
+ try {
+ return parseAuthType(variables.resolve(authenticationType)) ==
VaultAuthType.KUBERNETES;
+ } catch (HopException e) {
+ return false;
+ }
+ }
- LogicalResponse logicalResponse = vault.logical().read(path);
- if (logicalResponse == null) {
- LogChannel.GENERAL.logDetailed(
- "The secret with path '" + secretPath + "' was not found in the
vault");
- return null;
+ static boolean isAuthFailure(Throwable error) {
+ for (Throwable current = error; current != null; current =
current.getCause()) {
+ if (current instanceof VaultException vaultException) {
+ int status = vaultException.getHttpStatusCode();
+ if (status == 401 || status == 403) {
+ return true;
+ }
}
- // If we don't have a value to retrieve, simple return the "data" value.
- //
- return logicalResponse.getData().get("data");
- } catch (Exception e) {
- LogChannel.GENERAL.logError(
- "Error looking up secret '" + secretPath + "' in the Variable
resolver", e);
- return null;
+ }
+ return false;
+ }
+
+ void clearUnusedCredentials() {
+ if (authenticationType != null && authenticationType.contains("${")) {
+ return;
+ }
+ VaultAuthType authType;
+ try {
+ authType = parseAuthType(authenticationType);
+ } catch (HopException e) {
+ return;
+ }
+ if (authType != VaultAuthType.TOKEN) {
+ vaultToken = "";
+ }
+ if (authType != VaultAuthType.KUBERNETES) {
+ kubernetesJwt = "";
}
}
- // Read the PEM file content in UTF8 from an input stream.
+ // Read the PEM or JWT file content in UTF8 from an input stream.
//
private String readUtf8StringFromInputStream(final InputStream input) throws
IOException {
final StringBuilder utf8 = new StringBuilder();
@@ -234,7 +659,8 @@ public abstract class BaseVaultVariableResolver implements
IVariableResolver {
@Override
public void init() {
- // Not used today
+ // The client is built lazily on the first resolve() call: only then do we
have the variables
+ // needed to resolve the configuration fields.
}
@Override
@@ -242,4 +668,96 @@ public abstract class BaseVaultVariableResolver implements
IVariableResolver {
@Override
public abstract String getPluginName();
+
+ @Override
+ public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+ hideFieldsThatDoNotApply(compositeWidgets);
+ }
+
+ @Override
+ public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+ hideFieldsThatDoNotApply(compositeWidgets);
+ }
+
+ @Override
+ public void widgetModified(
+ GuiCompositeWidgets compositeWidgets, Control changedWidget, String
widgetId) {
+ if (ID_AUTHENTICATION_TYPE.equals(widgetId)) {
+ hideFieldsThatDoNotApply(compositeWidgets);
+ }
+ }
+
+ @Override
+ public void persistContents(GuiCompositeWidgets compositeWidgets) {
+ clearUnusedCredentials();
+ }
+
+ private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
+ VaultAuthType authType = readAuthType(compositeWidgets);
+ if (authType == null) {
+ // A variable or unknown type: keep every credential visible so we do
not wipe values
+ // that might still be needed once the expression is resolved.
+ compositeWidgets.setWidgetsHidden(this, Set.of());
+ return;
+ }
+ Set<String> hidden = new HashSet<>();
+ if (authType != VaultAuthType.TOKEN) {
+ hidden.add(ID_VAULT_TOKEN);
+ clearTextWidget(compositeWidgets, ID_VAULT_TOKEN);
+ }
+ if (authType != VaultAuthType.KUBERNETES) {
+ hidden.add(ID_KUBERNETES_ROLE);
+ hidden.add(ID_KUBERNETES_JWT_PATH);
+ hidden.add(ID_KUBERNETES_JWT);
+ hidden.add(ID_KUBERNETES_AUTH_PATH);
+ clearTextWidget(compositeWidgets, ID_KUBERNETES_JWT);
+ }
+ compositeWidgets.setWidgetsHidden(this, hidden);
+ }
+
+ private static void clearTextWidget(GuiCompositeWidgets compositeWidgets,
String id) {
+ Control control = compositeWidgets.getWidgetsMap().get(id);
+ if (control instanceof TextVar textVar) {
+ if (StringUtils.isNotEmpty(textVar.getText())) {
+ textVar.setText("");
+ }
+ } else if (control instanceof Text text) {
+ if (StringUtils.isNotEmpty(text.getText())) {
+ text.setText("");
+ }
+ }
+ }
+
+ /**
+ * @return the selected auth type, or {@code null} when the value is a
variable / unknown so the
+ * editor must not hide or clear credentials
+ */
+ private VaultAuthType readAuthType(GuiCompositeWidgets compositeWidgets) {
+ Control control =
compositeWidgets.getWidgetsMap().get(ID_AUTHENTICATION_TYPE);
+ String text = comboText(control);
+ if (StringUtils.isEmpty(text)) {
+ text = authenticationType;
+ }
+ if (StringUtils.isEmpty(text)) {
+ return VaultAuthType.TOKEN;
+ }
+ if (text.contains("${")) {
+ return null;
+ }
+ try {
+ return VaultAuthType.valueOf(text.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return VaultAuthType.TOKEN;
+ }
+ }
+
+ private static String comboText(Control control) {
+ if (control instanceof Combo combo) {
+ return combo.getText();
+ }
+ if (control instanceof ComboVar comboVar) {
+ return comboVar.getText();
+ }
+ return null;
+ }
}
diff --git
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
new file mode 100644
index 0000000000..f3b520e3a7
--- /dev/null
+++
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+/** Authentication method used by the HashiCorp Vault and OpenBAO variable
resolvers. */
+public enum VaultAuthType {
+ /** A Vault/OpenBAO client token, typically a long-lived token stored in the
resolver metadata. */
+ TOKEN,
+ /**
+ * Kubernetes auth: Hop reads a ServiceAccount JWT and exchanges it with
Vault for a short-lived
+ * client token.
+ */
+ KUBERNETES
+}
diff --git
a/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
b/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
index ca6271ddc7..3b255675e2 100644
---
a/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
+++
b/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
@@ -25,4 +25,14 @@ VaultVariableResolver.tooltip.verifyingSsl = Enable this in
production! It vali
VaultVariableResolver.label.pemFilePath = PEM (X.509 certificate) file path
VaultVariableResolver.label.pemString = PEM (X.509 certificate) String
VaultVariableResolver.label.openTimeout = Open connection timeout
-VaultVariableResolver.label.readTimeout = Read timeout
\ No newline at end of file
+VaultVariableResolver.label.readTimeout = Read timeout
+VaultVariableResolver.label.authenticationType = Authentication type
+VaultVariableResolver.tooltip.authenticationType = TOKEN uses a Vault client
token. KUBERNETES exchanges a Kubernetes ServiceAccount JWT for a short-lived
Vault token.
+VaultVariableResolver.label.kubernetesRole = Kubernetes role
+VaultVariableResolver.tooltip.kubernetesRole = The Vault Kubernetes auth role
bound to this workload's ServiceAccount.
+VaultVariableResolver.label.kubernetesJwtPath = Kubernetes JWT file path
+VaultVariableResolver.tooltip.kubernetesJwtPath = Path of the ServiceAccount
token file. Defaults to /var/run/secrets/kubernetes.io/serviceaccount/token.
You can also use a variable such as '${KUBERNETES_JWT_PATH}'.
+VaultVariableResolver.label.kubernetesJwt = Kubernetes JWT
+VaultVariableResolver.tooltip.kubernetesJwt = Optional JWT string, used
instead of the file when set. Typical in tests or when the token is injected as
'${KUBERNETES_JWT}'.
+VaultVariableResolver.label.kubernetesAuthPath = Kubernetes auth mount path
+VaultVariableResolver.tooltip.kubernetesAuthPath = Vault auth mount, without
the leading auth/. Defaults to kubernetes.
\ No newline at end of file
diff --git
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
new file mode 100644
index 0000000000..790586ddd6
--- /dev/null
+++
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Signature;
+import java.util.Base64;
+
+/**
+ * Compact RS256 JWT with Kubernetes ServiceAccount claims, for tests only.
Vault's Kubernetes auth
+ * backend only accepts RSA/ECDSA JWT algorithms, even when it does not verify
the signature.
+ */
+final class KubernetesServiceAccountJwt {
+
+ private KubernetesServiceAccountJwt() {}
+
+ static String create(String namespace, String name) {
+ String header = b64("{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
+ String payload =
+ b64(
+ "{"
+ + "\"iss\":\"kubernetes/serviceaccount\","
+ + "\"kubernetes.io/serviceaccount/namespace\":\""
+ + namespace
+ + "\","
+ + "\"kubernetes.io/serviceaccount/secret.name\":\""
+ + name
+ + "-token\","
+ + "\"kubernetes.io/serviceaccount/service-account.name\":\""
+ + name
+ + "\","
+ + "\"kubernetes.io/serviceaccount/service-account.uid\":\"1\","
+ + "\"sub\":\"system:serviceaccount:"
+ + namespace
+ + ":"
+ + name
+ + "\""
+ + "}");
+ String signingInput = header + "." + payload;
+ return signingInput + "." + b64(rsaSign(signingInput));
+ }
+
+ private static byte[] rsaSign(String signingInput) {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ KeyPair keyPair = generator.generateKeyPair();
+ Signature signature = Signature.getInstance("SHA256withRSA");
+ signature.initSign(keyPair.getPrivate());
+ signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
+ return signature.sign();
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not sign the test JWT", e);
+ }
+ }
+
+ private static String b64(String value) {
+ return b64(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String b64(byte[] value) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
+ }
+}
diff --git
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
new file mode 100644
index 0000000000..3c9975628a
--- /dev/null
+++
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.Executors;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Minimal Kubernetes TokenReview API used by Vault's Kubernetes auth backend
in tests.
+ * Authenticates JWTs whose {@code sub} claim is {@code
system:serviceaccount:<namespace>:<name>}
+ * for the bound service account.
+ */
+final class KubernetesTokenReviewMock implements AutoCloseable {
+
+ private static final Pattern SUB =
Pattern.compile("\"sub\"\\s*:\\s*\"([^\"]+)\"");
+
+ private final HttpServer server;
+
+ KubernetesTokenReviewMock() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("0.0.0.0", 0), 0);
+ server.createContext("/", this::handle);
+ server.setExecutor(Executors.newCachedThreadPool());
+ server.start();
+ }
+
+ int getPort() {
+ return server.getAddress().getPort();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private void handle(com.sun.net.httpserver.HttpExchange exchange) throws
IOException {
+ String path = exchange.getRequestURI().getPath();
+ String method = exchange.getRequestMethod();
+ byte[] body = readBody(exchange);
+
+ if ("GET".equalsIgnoreCase(method) && path.contains("healthz")) {
+ send(exchange, 200, "text/plain", "ok");
+ return;
+ }
+
+ if ("POST".equalsIgnoreCase(method) && path.contains("tokenreviews")) {
+ send(
+ exchange, 200, "application/json", tokenReview(new String(body,
StandardCharsets.UTF_8)));
+ return;
+ }
+
+ send(exchange, 200, "application/json",
"{\"kind\":\"APIVersions\",\"versions\":[\"v1\"]}");
+ }
+
+ private static byte[] readBody(com.sun.net.httpserver.HttpExchange exchange)
throws IOException {
+ try (InputStream in = exchange.getRequestBody()) {
+ return in.readAllBytes();
+ }
+ }
+
+ private static void send(
+ com.sun.net.httpserver.HttpExchange exchange, int status, String
contentType, String body)
+ throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", contentType);
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(bytes);
+ }
+ }
+
+ private static String tokenReview(String requestBody) {
+ String jwt = jsonString(requestBody, "token");
+ String sub = jwtSubject(jwt);
+ boolean authenticated = sub.startsWith("system:serviceaccount:");
+ String username = authenticated ? sub : "";
+ return """
+ {
+ "apiVersion": "authentication.k8s.io/v1",
+ "kind": "TokenReview",
+ "status": {
+ "authenticated": %s,
+ "user": {
+ "username": "%s",
+ "uid": "1",
+ "groups": ["system:serviceaccounts"]
+ }
+ }
+ }
+ """
+ .formatted(authenticated, username);
+ }
+
+ private static String jsonString(String json, String field) {
+ Matcher matcher =
+ Pattern.compile("\"" + Pattern.quote(field) +
"\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
+ return matcher.find() ? matcher.group(1) : "";
+ }
+
+ static String jwtSubject(String jwt) {
+ if (jwt == null || jwt.isBlank()) {
+ return "";
+ }
+ String[] parts = jwt.split("\\.");
+ if (parts.length < 2) {
+ return "";
+ }
+ try {
+ String payload =
+ new String(Base64.getUrlDecoder().decode(padBase64(parts[1])),
StandardCharsets.UTF_8);
+ Matcher matcher = SUB.matcher(payload);
+ return matcher.find() ? matcher.group(1) : "";
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+ }
+
+ private static String padBase64(String value) {
+ int remainder = value.length() % 4;
+ if (remainder == 0) {
+ return value;
+ }
+ return value + "=".repeat(4 - remainder);
+ }
+}
diff --git
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
new file mode 100644
index 0000000000..55cdbaecb7
--- /dev/null
+++
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.jopenlibs.vault.Vault;
+import io.github.jopenlibs.vault.response.AuthResponse;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers(disabledWithoutDocker = true)
+class VaultKubernetesAuthIT {
+
+ private static final String ROOT_TOKEN = "myroot";
+ private static final String SECRET_PATH = "secret/data/hop";
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+ static final KubernetesTokenReviewMock TOKEN_REVIEW = startTokenReview();
+
+ @Container
+ static GenericContainer<?> vault =
+ new GenericContainer<>(DockerImageName.parse("hashicorp/vault:1.19.0"))
+ .withExposedPorts(8200)
+ .withEnv("VAULT_DEV_ROOT_TOKEN_ID", ROOT_TOKEN)
+ .withEnv("VAULT_DEV_LISTEN_ADDRESS", "0.0.0.0:8200")
+ .withEnv("SKIP_SETCAP", "true")
+ .withAccessToHost(true)
+ .waitingFor(
+ Wait.forHttp("/v1/sys/health")
+ .forPort(8200)
+ .forStatusCodeMatching(code -> code == 200 || code == 429)
+ .withStartupTimeout(Duration.ofMinutes(2)));
+
+ @TempDir static Path tempDir;
+
+ @BeforeAll
+ static void configureVault() throws Exception {
+ HopLogStore.init();
+ String kubernetesHost = "http://host.testcontainers.internal:" +
TOKEN_REVIEW.getPort();
+
+ vaultWrite("/v1/sys/auth/kubernetes", "{\"type\":\"kubernetes\"}");
+ vaultWrite(
+ "/v1/auth/kubernetes/config",
+ """
+ {
+ "kubernetes_host": "%s",
+ "disable_iss_validation": true,
+ "disable_local_ca_jwt": true,
+ "token_reviewer_jwt": "dummy"
+ }
+ """
+ .formatted(kubernetesHost));
+ vaultWrite(
+ "/v1/sys/policies/acl/hop-read",
+ """
+ {
+ "policy": "path \\"secret/data/*\\" { capabilities = [\\"read\\"] }"
+ }
+ """);
+ vaultWrite(
+ "/v1/auth/kubernetes/role/hop",
+ """
+ {
+ "bound_service_account_names": "hop",
+ "bound_service_account_namespaces": "default",
+ "policies": ["hop-read"],
+ "ttl": "1h"
+ }
+ """);
+ vaultWrite("/v1/sys/auth/k8s", "{\"type\":\"kubernetes\"}");
+ vaultWrite(
+ "/v1/auth/k8s/config",
+ """
+ {
+ "kubernetes_host": "%s",
+ "disable_iss_validation": true,
+ "disable_local_ca_jwt": true,
+ "token_reviewer_jwt": "dummy"
+ }
+ """
+ .formatted(kubernetesHost));
+ vaultWrite(
+ "/v1/auth/k8s/role/hop",
+ """
+ {
+ "bound_service_account_names": "hop",
+ "bound_service_account_namespaces": "default",
+ "policies": ["hop-read"],
+ "ttl": "1h"
+ }
+ """);
+ vaultWrite(
+ "/v1/" + SECRET_PATH,
+ """
+ {
+ "data": {
+ "hostname": "localhost",
+ "password": "some-password"
+ }
+ }
+ """);
+
+ var probe = vault.execInContainer("wget", "-qO-", "-T", "5",
kubernetesHost + "/healthz");
+ if (probe.getExitCode() != 0) {
+ throw new IllegalStateException(
+ "Vault cannot reach the TokenReview mock at "
+ + kubernetesHost
+ + ": exit="
+ + probe.getExitCode()
+ + " stdout="
+ + probe.getStdout()
+ + " stderr="
+ + probe.getStderr()
+ + " vaultLogs="
+ + vault.getLogs());
+ }
+ }
+
+ @AfterAll
+ static void stopMock() {
+ TOKEN_REVIEW.close();
+ }
+
+ private static KubernetesTokenReviewMock startTokenReview() {
+ try {
+ KubernetesTokenReviewMock mock = new KubernetesTokenReviewMock();
+ org.testcontainers.Testcontainers.exposeHostPorts(mock.getPort());
+ return mock;
+ } catch (IOException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ @Test
+ void tokenAuthStillReadsTheSecret() throws Exception {
+ VaultVariableResolver resolver = tokenResolver();
+ String value = resolver.resolve(SECRET_PATH, variables());
+ assertNotNull(value);
+ assertTrue(value.contains("some-password"), value);
+ }
+
+ @Test
+ void kubernetesAuthWithInlineJwtReadsTheSecret() throws Exception {
+ VaultVariableResolver resolver = kubernetesResolver();
+ resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default",
"hop"));
+ String value = resolveOrThrow(resolver);
+ assertTrue(value.contains("some-password"), value);
+ }
+
+ @Test
+ void kubernetesAuthWithJwtFileReadsTheSecret() throws Exception {
+ Path jwtFile = tempDir.resolve("token");
+ Files.writeString(jwtFile, KubernetesServiceAccountJwt.create("default",
"hop"));
+
+ VaultVariableResolver resolver = kubernetesResolver();
+ resolver.setKubernetesJwt("");
+ resolver.setKubernetesJwtPath(jwtFile.toString());
+ String value = resolveOrThrow(resolver);
+ assertTrue(value.contains("some-password"), value);
+ }
+
+ @Test
+ void kubernetesAuthWithCustomMountPathReadsTheSecret() throws Exception {
+ VaultVariableResolver resolver = kubernetesResolver();
+ resolver.setKubernetesAuthPath("k8s");
+ resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default",
"hop"));
+ String value = resolveOrThrow(resolver);
+ assertTrue(value.contains("some-password"), value);
+ }
+
+ @Test
+ void kubernetesAuthWithWrongRoleReturnsNull() throws Exception {
+ VaultVariableResolver resolver = kubernetesResolver();
+ resolver.setKubernetesRole("missing-role");
+ resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default",
"hop"));
+ assertNull(resolver.resolve(SECRET_PATH, variables()));
+ }
+
+ @Test
+ void kubernetesAuthWithUnauthenticatedJwtReturnsNull() throws Exception {
+ VaultVariableResolver resolver = kubernetesResolver();
+ resolver.setKubernetesJwt("not-a-jwt");
+ assertNull(resolver.resolve(SECRET_PATH, variables()));
+ }
+
+ @Test
+ void kubernetesClientIsCachedAcrossResolves() throws Exception {
+ CountingLoginResolver resolver = new CountingLoginResolver();
+ resolver.setVaultAddress(vaultAddress());
+ resolver.setVerifyingSsl(false);
+ resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ resolver.setKubernetesRole("hop");
+ resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default",
"hop"));
+
+ IVariables variables = variables();
+ assertNotNull(resolveOrThrow(resolver, variables));
+ assertNotNull(resolveOrThrow(resolver, variables));
+ assertTrue(resolver.logins >= 1);
+ assertTrue(
+ resolver.logins < 3, "expected the Vault client to be reused, logins="
+ resolver.logins);
+ }
+
+ private static VaultVariableResolver tokenResolver() {
+ VaultVariableResolver resolver = new VaultVariableResolver();
+ resolver.setVaultAddress(vaultAddress());
+ resolver.setVaultToken(ROOT_TOKEN);
+ resolver.setVerifyingSsl(false);
+ resolver.setAuthenticationType(VaultAuthType.TOKEN.name());
+ return resolver;
+ }
+
+ private static VaultVariableResolver kubernetesResolver() {
+ VaultVariableResolver resolver = new VaultVariableResolver();
+ resolver.setVaultAddress(vaultAddress());
+ resolver.setVerifyingSsl(false);
+ resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ resolver.setKubernetesRole("hop");
+ return resolver;
+ }
+
+ private static IVariables variables() {
+ return new Variables();
+ }
+
+ private static String vaultAddress() {
+ return "http://" + vault.getHost() + ":" + vault.getMappedPort(8200);
+ }
+
+ private static String resolveOrThrow(VaultVariableResolver resolver) throws
Exception {
+ return resolveOrThrow(resolver, variables());
+ }
+
+ private static String resolveOrThrow(VaultVariableResolver resolver,
IVariables variables)
+ throws Exception {
+ resolver.getVault(variables);
+ String value = resolver.resolve(SECRET_PATH, variables);
+ assertNotNull(value, "secret lookup returned null after a successful
login");
+ return value;
+ }
+
+ private static void vaultWrite(String path, String json) throws Exception {
+ HttpRequest request =
+ HttpRequest.newBuilder(URI.create(vaultAddress() + path))
+ .timeout(Duration.ofSeconds(15))
+ .header("X-Vault-Token", ROOT_TOKEN)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(json))
+ .build();
+ HttpResponse<String> response = HTTP.send(request,
HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() >= 300) {
+ throw new IllegalStateException(
+ "Vault " + path + " returned " + response.statusCode() + ": " +
response.body());
+ }
+ }
+
+ private static final class CountingLoginResolver extends
VaultVariableResolver {
+ private int logins;
+
+ @Override
+ protected AuthResponse loginByKubernetes(Vault vault, String role, String
jwt, String loginPath)
+ throws org.apache.hop.core.exception.HopException {
+ logins++;
+ return super.loginByKubernetes(vault, role, jwt, loginPath);
+ }
+ }
+}
diff --git
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
new file mode 100644
index 0000000000..8348e83d83
--- /dev/null
+++
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.github.jopenlibs.vault.Vault;
+import io.github.jopenlibs.vault.VaultConfig;
+import io.github.jopenlibs.vault.VaultException;
+import io.github.jopenlibs.vault.api.Logical;
+import io.github.jopenlibs.vault.response.AuthResponse;
+import io.github.jopenlibs.vault.response.LogicalResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class VaultVariableResolverTest {
+
+ private VaultVariableResolver resolver;
+ private IVariables variables;
+ private Vault vault;
+ private Logical logical;
+
+ @TempDir Path tempDir;
+
+ @BeforeAll
+ static void initHopEnvironment() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ vault = mock(Vault.class);
+ logical = mock(Logical.class);
+ LogicalResponse response = mock(LogicalResponse.class);
+ when(vault.logical()).thenReturn(logical);
+ when(logical.read(anyString())).thenReturn(response);
+ when(response.getData()).thenReturn(Map.of("data",
"{\"password\":\"secret\"}"));
+
+ resolver =
+ new VaultVariableResolver() {
+ @Override
+ protected Vault createVault(VaultConfig vaultConfig) {
+ return vault;
+ }
+ };
+ resolver.setVaultAddress("http://vault:8200");
+ resolver.setVaultToken("s.token");
+ variables = new Variables();
+ }
+
+ @Test
+ void testPluginMetadata() {
+ assertEquals("Vault-Variable-Resolver", resolver.getPluginId());
+ assertEquals("Hashicorp Vault Variable Resolver",
resolver.getPluginName());
+ }
+
+ @Test
+ void testDefaultAuthenticationTypeIsToken() {
+ assertEquals(VaultAuthType.TOKEN.name(), new
VaultVariableResolver().getAuthenticationType());
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void testEmptyAuthTypeFallsBackToToken(String authType) throws Exception {
+ assertEquals(VaultAuthType.TOKEN, resolver.parseAuthType(authType));
+ }
+
+ @Test
+ void testAuthTypeIsCaseInsensitive() throws Exception {
+ assertEquals(VaultAuthType.KUBERNETES,
resolver.parseAuthType("kubernetes"));
+ assertEquals(VaultAuthType.TOKEN, resolver.parseAuthType("Token"));
+ }
+
+ @Test
+ void testAuthTypeParseUsesRootLocale() throws Exception {
+ Locale previous = Locale.getDefault();
+ try {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ assertEquals(VaultAuthType.KUBERNETES,
resolver.parseAuthType("kubernetes"));
+ } finally {
+ Locale.setDefault(previous);
+ }
+ }
+
+ @Test
+ void testUnknownAuthTypeIsRejected() {
+ HopException e = assertThrows(HopException.class, () ->
resolver.parseAuthType("APPROLE"));
+ assertTrue(e.getMessage().contains("TOKEN"));
+ assertTrue(e.getMessage().contains("KUBERNETES"));
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void testResolveWithoutSecretPath(String secretPath) throws Exception {
+ assertNull(resolver.resolve(secretPath, variables));
+ }
+
+ @Test
+ void testTokenAuthReadsSecret() throws Exception {
+ assertEquals("{\"password\":\"secret\"}",
resolver.resolve("secret/data/hop", variables));
+ verify(logical).read("secret/data/hop");
+ }
+
+ @Test
+ void testPathPrefixIsPrepended() throws Exception {
+ resolver.setPathPrefix("secret/data/");
+ resolver.resolve("hop", variables);
+ verify(logical).read("secret/data/hop");
+ }
+
+ @Test
+ void testTokenAuthWithoutTokenFails() throws Exception {
+ resolver.setVaultToken("");
+ assertNull(resolver.resolve("secret/data/hop", variables));
+ }
+
+ @Test
+ void testKubernetesAuthWithoutRoleFails() throws Exception {
+ resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ resolver.setKubernetesRole("");
+ resolver.setKubernetesJwt("header.payload.sig");
+ assertNull(resolver.resolve("secret/data/hop", variables));
+ }
+
+ @Test
+ void testLoadJwtFromInlineString() throws Exception {
+ resolver.setKubernetesJwt(" inline-jwt ");
+ assertEquals("inline-jwt", resolver.loadServiceAccountJwt(variables));
+ }
+
+ @Test
+ void testLoadJwtFromFile() throws Exception {
+ Path jwtFile = tempDir.resolve("token");
+ Files.writeString(jwtFile, "file-jwt\n");
+ resolver.setKubernetesJwt("");
+ resolver.setKubernetesJwtPath(jwtFile.toString());
+ assertEquals("file-jwt", resolver.loadServiceAccountJwt(variables));
+ }
+
+ @Test
+ void testLoadJwtPrefersInlineOverFile() throws Exception {
+ Path jwtFile = tempDir.resolve("token");
+ Files.writeString(jwtFile, "file-jwt");
+ resolver.setKubernetesJwt("inline-jwt");
+ resolver.setKubernetesJwtPath(jwtFile.toString());
+ assertEquals("inline-jwt", resolver.loadServiceAccountJwt(variables));
+ }
+
+ @Test
+ void testLoadJwtUsesDefaultPathWhenEmpty() {
+ resolver.setKubernetesJwt("");
+ resolver.setKubernetesJwtPath("");
+ HopException e =
+ assertThrows(HopException.class, () ->
resolver.loadServiceAccountJwt(variables));
+
assertTrue(e.getMessage().contains(BaseVaultVariableResolver.DEFAULT_KUBERNETES_JWT_PATH));
+ }
+
+ @Test
+ void testJwtAndRoleVariablesAreResolved() throws Exception {
+ variables.setVariable("ROLE", "hop");
+ variables.setVariable("JWT", "resolved-jwt");
+ resolver.setKubernetesRole("${ROLE}");
+ resolver.setKubernetesJwt("${JWT}");
+ assertEquals("resolved-jwt", resolver.loadServiceAccountJwt(variables));
+ assertEquals("hop", variables.resolve(resolver.getKubernetesRole()));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", "kubernetes"})
+ void testDefaultKubernetesLoginPath(String mount) {
+ assertEquals("auth/kubernetes",
BaseVaultVariableResolver.kubernetesLoginPath(mount));
+ }
+
+ @Test
+ void testCustomKubernetesLoginPath() {
+ assertEquals("auth/k8s",
BaseVaultVariableResolver.kubernetesLoginPath("k8s"));
+ assertEquals("auth/k8s",
BaseVaultVariableResolver.kubernetesLoginPath("auth/k8s"));
+ }
+
+ @Test
+ void testClientIsCachedAcrossResolves() throws Exception {
+ CountingResolver counting = new CountingResolver(vault);
+ counting.setVaultAddress("http://vault:8200");
+ counting.setVaultToken("s.token");
+
+ counting.resolve("secret/data/hop", variables);
+ counting.resolve("secret/data/hop", variables);
+
+ assertEquals(1, counting.builds);
+ verify(logical, times(2)).read("secret/data/hop");
+ }
+
+ @Test
+ void testExpiredTokenRebuildsTheClient() throws Exception {
+ CountingResolver counting = new CountingResolver(vault);
+ counting.setVaultAddress("http://vault:8200");
+ counting.setVaultToken("s.token");
+
+ counting.resolve("secret/data/hop", variables);
+ counting.expireCachedToken();
+ counting.resolve("secret/data/hop", variables);
+
+ assertEquals(2, counting.builds);
+ }
+
+ @Test
+ void testNonRenewableTokenRefreshesBeforeExpiry() throws Exception {
+ CountingResolver counting = new CountingResolver(vault);
+ counting.setVaultAddress("http://vault:8200");
+ counting.setVaultToken("s.token");
+
+ counting.resolve("secret/data/hop", variables);
+ counting.setCachedLeaseForTest(System.currentTimeMillis() + 10_000L,
false);
+ counting.resolve("secret/data/hop", variables);
+
+ assertEquals(2, counting.builds);
+ }
+
+ @Test
+ void testKubernetesAuthFailureRetriesOnce() throws Exception {
+ when(logical.read(anyString()))
+ .thenThrow(new VaultException("permission denied", 403))
+ .thenReturn(response("{\"password\":\"secret\"}"));
+
+ CountingResolver kubernetes = new CountingResolver(vault, true);
+ kubernetes.setVaultAddress("http://vault:8200");
+ kubernetes.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ kubernetes.setKubernetesRole("hop");
+ kubernetes.setKubernetesJwt("header.payload.sig");
+
+ assertEquals("{\"password\":\"secret\"}",
kubernetes.resolve("secret/data/hop", variables));
+ assertEquals(2, kubernetes.builds);
+ verify(logical, times(2)).read("secret/data/hop");
+ }
+
+ @Test
+ void testTokenAuthFailureDoesNotRetry() throws Exception {
+ when(logical.read(anyString())).thenThrow(new VaultException("permission
denied", 403));
+
+ CountingResolver counting = new CountingResolver(vault);
+ counting.setVaultAddress("http://vault:8200");
+ counting.setVaultToken("s.token");
+
+ assertNull(counting.resolve("secret/data/hop", variables));
+ assertEquals(1, counting.builds);
+ verify(logical, times(1)).read("secret/data/hop");
+ }
+
+ @Test
+ void testClearUnusedCredentialsDropsVaultTokenForKubernetes() {
+ resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ resolver.setVaultToken("s.leftover");
+ resolver.setKubernetesJwt("inline-jwt");
+ resolver.clearUnusedCredentials();
+ assertEquals("", resolver.getVaultToken());
+ assertEquals("inline-jwt", resolver.getKubernetesJwt());
+ }
+
+ @Test
+ void testClearUnusedCredentialsDropsJwtForToken() {
+ resolver.setAuthenticationType(VaultAuthType.TOKEN.name());
+ resolver.setVaultToken("s.token");
+ resolver.setKubernetesJwt("inline-jwt");
+ resolver.clearUnusedCredentials();
+ assertEquals("s.token", resolver.getVaultToken());
+ assertEquals("", resolver.getKubernetesJwt());
+ }
+
+ @Test
+ void testClearUnusedCredentialsKeepsBothWhenAuthTypeIsAVariable() {
+ resolver.setAuthenticationType("${VAULT_AUTH_TYPE}");
+ resolver.setVaultToken("s.token");
+ resolver.setKubernetesJwt("inline-jwt");
+ resolver.clearUnusedCredentials();
+ assertEquals("s.token", resolver.getVaultToken());
+ assertEquals("inline-jwt", resolver.getKubernetesJwt());
+ }
+
+ private static LogicalResponse response(String data) {
+ LogicalResponse logicalResponse = mock(LogicalResponse.class);
+ when(logicalResponse.getData()).thenReturn(Map.of("data", data));
+ return logicalResponse;
+ }
+
+ private static final class CountingResolver extends VaultVariableResolver {
+ private final Vault vault;
+ private final boolean kubernetesLogin;
+ private int builds;
+
+ private CountingResolver(Vault vault) {
+ this(vault, false);
+ }
+
+ private CountingResolver(Vault vault, boolean kubernetesLogin) {
+ this.vault = vault;
+ this.kubernetesLogin = kubernetesLogin;
+ }
+
+ @Override
+ protected Vault buildVault(IVariables variables) throws HopException {
+ builds++;
+ return super.buildVault(variables);
+ }
+
+ @Override
+ protected Vault createVault(VaultConfig vaultConfig) {
+ return vault;
+ }
+
+ @Override
+ protected AuthResponse loginByKubernetes(Vault vault, String role, String
jwt, String loginPath)
+ throws HopException {
+ if (!kubernetesLogin) {
+ return super.loginByKubernetes(vault, role, jwt, loginPath);
+ }
+ AuthResponse response = mock(AuthResponse.class);
+ when(response.getAuthClientToken()).thenReturn("s.k8s");
+ when(response.getAuthLeaseDuration()).thenReturn(3600L);
+ when(response.isAuthRenewable()).thenReturn(false);
+ return response;
+ }
+ }
+}
diff --git
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
new file mode 100644
index 0000000000..8608d4be9e
--- /dev/null
+++
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.variables.resolver.VariableResolver;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * TOKEN and KUBERNETES have no credentials in common, so the editor only
shows the ones the chosen
+ * type can actually use.
+ */
+@Tag("uitest")
+class VaultWidgetVisibilityTest extends SwtBotTestBase {
+
+ @BeforeAll
+ static void registerGuiPluginElements() throws Exception {
+ if (GuiRegistry.getInstance()
+ .findGuiElements(
+ VaultVariableResolver.class.getName(),
+ VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+ == null) {
+ HopGuiEnvironment.init();
+ }
+ }
+
+ @Test
+ @DisplayName("TOKEN shows the Vault token and hides Kubernetes fields")
+ void tokenShowsOnlyTheToken() {
+ withWidgets(
+ resolver -> resolver.setAuthenticationType(VaultAuthType.TOKEN.name()),
+ (resolver, widgets) -> {
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+
+ assertHidden(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+ assertHidden(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+ assertHidden(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+ assertHidden(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+ });
+ }
+
+ @Test
+ @DisplayName("KUBERNETES shows the Kubernetes fields and hides the Vault
token")
+ void kubernetesShowsOnlyKubernetesFields() {
+ withWidgets(
+ resolver ->
resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name()),
+ (resolver, widgets) -> {
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+
+ assertHidden(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+ });
+ }
+
+ @Test
+ @DisplayName("switching to KUBERNETES clears a leftover Vault token from the
hidden widget")
+ void kubernetesClearsLeftoverVaultToken() {
+ withWidgets(
+ resolver -> {
+ resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+ resolver.setVaultToken("s.leftover");
+ },
+ (resolver, widgets) -> {
+ resolver.persistContents(widgets);
+ assertEquals("", resolver.getVaultToken());
+ assertEquals("", textOf(widgets,
BaseVaultVariableResolver.ID_VAULT_TOKEN));
+ });
+ }
+
+ @Test
+ @DisplayName("a variable auth type keeps every credential field visible and
intact")
+ void variableAuthTypeShowsAllCredentialFields() {
+ withWidgets(
+ resolver -> {
+ resolver.setAuthenticationType("${VAULT_AUTH_TYPE}");
+ resolver.setVaultToken("s.token");
+ resolver.setKubernetesJwt("inline-jwt");
+ resolver.setKubernetesRole("hop");
+ },
+ (resolver, widgets) -> {
+ assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+
+ resolver.persistContents(widgets);
+ assertEquals("s.token", resolver.getVaultToken());
+ assertEquals("inline-jwt", resolver.getKubernetesJwt());
+ assertEquals("hop", resolver.getKubernetesRole());
+ });
+ }
+
+ @Test
+ @DisplayName("connection and secret options stay visible for every auth
type")
+ void generalOptionsStayVisibleForEveryAuthType() {
+ for (VaultAuthType authType : VaultAuthType.values()) {
+ withWidgets(
+ resolver -> resolver.setAuthenticationType(authType.name()),
+ (resolver, widgets) -> {
+ assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_ADDRESS);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_NAMESPACE);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_VERIFYING_SSL);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_PEM_FILE_PATH);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_PEM_STRING);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_OPEN_TIMEOUT);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_READ_TIMEOUT);
+ assertVisible(widgets, BaseVaultVariableResolver.ID_PATH_PREFIX);
+ assertVisible(widgets,
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+ });
+ }
+ }
+
+ private void withWidgets(
+ Consumer<VaultVariableResolver> configure,
+ BiConsumer<VaultVariableResolver, GuiCompositeWidgets> assertions) {
+ ensureDisplay();
+
+ Shell shell = new Shell(display);
+ shell.setLayout(new FormLayout());
+ try {
+ VaultVariableResolver resolver = new VaultVariableResolver();
+ configure.accept(resolver);
+
+ GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+ widgets.createCompositeWidgets(
+ resolver, null, shell,
VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
+ widgets.setWidgetsContents(resolver, shell,
VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID);
+ widgets.setWidgetsListener(resolver);
+ resolver.widgetsPopulated(widgets);
+
+ shell.layout(true, true);
+ shell.pack();
+
+ assertions.accept(resolver, widgets);
+ } finally {
+ if (!shell.isDisposed()) {
+ shell.dispose();
+ }
+ }
+ }
+
+ private void assertVisible(GuiCompositeWidgets widgets, String id) {
+ Control control = widgets.getWidgetsMap().get(id);
+ assertNotNull(control, "no widget registered for " + id);
+ assertTrue(control.getVisible(), id + " should be visible");
+ Control label = widgets.getLabelsMap().get(id);
+ if (label != null) {
+ assertTrue(label.getVisible(), "the label of " + id + " should be
visible");
+ }
+ }
+
+ private void assertHidden(GuiCompositeWidgets widgets, String id) {
+ Control control = widgets.getWidgetsMap().get(id);
+ assertNotNull(control, "no widget registered for " + id);
+ assertFalse(control.getVisible(), id + " should be hidden");
+ Control label = widgets.getLabelsMap().get(id);
+ if (label != null) {
+ assertFalse(label.getVisible(), "the label of " + id + " should be
hidden");
+ }
+ }
+
+ private static String textOf(GuiCompositeWidgets widgets, String id) {
+ Control control = widgets.getWidgetsMap().get(id);
+ if (control instanceof TextVar textVar) {
+ return textVar.getText();
+ }
+ if (control instanceof Text text) {
+ return text.getText();
+ }
+ return null;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
b/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
index c6e54800da..493595d472 100644
---
a/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
+++
b/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
@@ -337,6 +337,9 @@ public class VariableResolverEditor extends
MetadataEditor<VariableResolver> {
//
guiCompositeWidgets.getWidgetsContents(
meta.getIResolver(), VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID);
+ if (guiCompositeWidgets.getWidgetsListener() != null) {
+
guiCompositeWidgets.getWidgetsListener().persistContents(guiCompositeWidgets);
+ }
}
private String[] getResolverTypes() {