Fix #22511: k8s secret support for Secrets Manager (#22516)

* Fix #22511: k8s secret support for Secrets Manager

* Update generated TypeScript types

* address comments

* pylint fix

* fix java checkstyle

* improve inCluster description in schema

* fix failing tests

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: ulixius9 <mayursingal9@gmail.com>
Co-authored-by: Mayur Singal <39544459+ulixius9@users.noreply.github.com>
This commit is contained in:
Sriharsha Chintalapani 2025-07-24 03:40:51 -07:00 committed by GitHub
parent 2c18a7ed32
commit b0586f849f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1376 additions and 3 deletions

View File

@ -374,7 +374,7 @@ fernetConfiguration:
fernetKey: ${FERNET_KEY:-jJ/9sz0g0OHxsfxOoSfdFdmk3ysNmPRnH3TUAbz3IHA=}
secretsManagerConfiguration:
secretsManager: ${SECRET_MANAGER:-db} # Possible values are "db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp"
secretsManager: ${SECRET_MANAGER:-db} # Possible values are "db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp", "kubernetes"
prefix: ${SECRET_MANAGER_PREFIX:-""} # Define the secret key ID as /<prefix>/<clusterName>/<key>
tags: ${SECRET_MANAGER_TAGS:-[]} # Add tags to the created resource. Format is `[key1:value1,key2:value2,...]`
# it will use the default auth provider for the secrets' manager service if parameters are not set
@ -390,6 +390,10 @@ secretsManagerConfiguration:
vaultName: ${OM_SM_VAULT_NAME:-""}
## For GCP
projectId: ${OM_SM_PROJECT_ID:-""}
## For Kubernetes
namespace: ${OM_SM_NAMESPACE:-"default"}
kubeconfigPath: ${OM_SM_KUBECONFIG_PATH:-""}
inCluster: ${OM_SM_IN_CLUSTER:-"false"}
health:
delayedShutdownHandlerEnabled: true

View File

@ -140,6 +140,7 @@ base_requirements = {
"importlib-metadata>=4.13.0", # From airflow constraints
"Jinja2>=2.11.3",
"jsonpatch<2.0, >=1.24",
"kubernetes>=21.0.0", # Kubernetes client for secrets manager
"memory-profiler",
"mypy_extensions>=0.4.3",
VERSIONS["pydantic"],

View File

@ -0,0 +1,176 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# 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.
"""
Kubernetes Secrets Manager implementation
"""
import base64
import os
import traceback
from abc import ABC
from typing import Optional
from kubernetes import client, config
from kubernetes.client.exceptions import ApiException
from metadata.generated.schema.security.credentials.kubernetesCredentials import (
KubernetesCredentials,
)
from metadata.generated.schema.security.secrets.secretsManagerClientLoader import (
SecretsManagerClientLoader,
)
from metadata.generated.schema.security.secrets.secretsManagerProvider import (
SecretsManagerProvider,
)
from metadata.utils.dispatch import enum_register
from metadata.utils.logger import utils_logger
from metadata.utils.secrets.external_secrets_manager import (
SECRET_MANAGER_AIRFLOW_CONF,
ExternalSecretsManager,
SecretsManagerConfigException,
)
logger = utils_logger()
secrets_manager_client_loader = enum_register()
def _get_current_namespace() -> str:
"""
:return: The namespace where the application service account is running or default if it can't be retrieved
"""
try:
with open(
"/var/run/secrets/kubernetes.io/serviceaccount/namespace", encoding="utf-8"
) as f:
return f.read().strip()
except Exception as _:
logger.info(
"Can't read the current namespace from in-cluster kubernetes. Is the service account configured?"
)
return "default"
# pylint: disable=import-outside-toplevel
@secrets_manager_client_loader.add(SecretsManagerClientLoader.noop.value)
def _() -> None:
return None
@secrets_manager_client_loader.add(SecretsManagerClientLoader.airflow.value)
def _() -> Optional[KubernetesCredentials]:
from airflow.configuration import conf
namespace = conf.get(
SECRET_MANAGER_AIRFLOW_CONF,
"kubernetes_namespace",
fallback=_get_current_namespace(),
)
in_cluster = conf.getboolean(
SECRET_MANAGER_AIRFLOW_CONF, "kubernetes_in_cluster", fallback=False
)
kubeconfig_path = conf.get(
SECRET_MANAGER_AIRFLOW_CONF, "kubernetes_kubeconfig_path", fallback=None
)
return KubernetesCredentials(
namespace=namespace,
inCluster=in_cluster,
kubeconfigPath=kubeconfig_path,
)
@secrets_manager_client_loader.add(SecretsManagerClientLoader.env.value)
def _() -> Optional[KubernetesCredentials]:
namespace = os.getenv("KUBERNETES_NAMESPACE", _get_current_namespace())
in_cluster = os.getenv("KUBERNETES_IN_CLUSTER", "false").lower() == "true"
kubeconfig_path = os.getenv("KUBERNETES_KUBECONFIG_PATH")
return KubernetesCredentials(
namespace=namespace,
inCluster=in_cluster,
kubeconfigPath=kubeconfig_path,
)
class KubernetesSecretsManager(ExternalSecretsManager, ABC):
"""
Kubernetes Secrets Manager class
"""
def __init__(
self,
loader: SecretsManagerClientLoader,
):
super().__init__(provider=SecretsManagerProvider.kubernetes, loader=loader)
# Initialize Kubernetes client
if self.credentials.inCluster:
config.load_incluster_config()
logger.info("Using in-cluster Kubernetes configuration")
else:
kubeconfig_path = self.credentials.kubeconfigPath
if kubeconfig_path:
config.load_kube_config(config_file=kubeconfig_path)
logger.info(f"Using kubeconfig from path: {kubeconfig_path}")
else:
config.load_kube_config()
logger.info("Using default kubeconfig")
self.client = client.CoreV1Api()
self.namespace = self.credentials.namespace or _get_current_namespace()
logger.info(
f"Kubernetes SecretsManager initialized with namespace: {self.namespace}"
)
def get_string_value(self, secret_id: str) -> str:
"""
:param secret_id: The secret id to retrieve
:return: The value of the secret
"""
try:
# Get the secret from Kubernetes
secret = self.client.read_namespaced_secret(
name=secret_id, namespace=self.namespace
)
# Kubernetes stores secret data as base64 encoded
if secret.data and "value" in secret.data:
secret_value = base64.b64decode(secret.data["value"]).decode("utf-8")
logger.debug(f"Got value for secret {secret_id}")
return secret_value
logger.warning(f"Secret {secret_id} exists but has no 'value' key")
return None
except ApiException as exc:
if exc.status == 404:
logger.debug(f"Secret {secret_id} not found")
return None
logger.debug(traceback.format_exc())
logger.error(
f"Could not get the secret value of {secret_id} due to [{exc}]"
)
raise exc
except Exception as exc:
logger.debug(traceback.format_exc())
logger.error(
f"Could not get the secret value of {secret_id} due to [{exc}]"
)
raise exc
def load_credentials(self) -> Optional[dict]:
"""Load the provider credentials based on the loader type"""
try:
loader_fn = secrets_manager_client_loader.registry.get(self.loader.value)
return loader_fn()
except Exception as err:
raise SecretsManagerConfigException(f"Error loading credentials - [{err}]")

View File

@ -25,6 +25,7 @@ from metadata.utils.secrets.aws_ssm_secrets_manager import AWSSSMSecretsManager
from metadata.utils.secrets.azure_kv_secrets_manager import AzureKVSecretsManager
from metadata.utils.secrets.db_secrets_manager import DBSecretsManager
from metadata.utils.secrets.gcp_secrets_manager import GCPSecretsManager
from metadata.utils.secrets.kubernetes_secrets_manager import KubernetesSecretsManager
from metadata.utils.secrets.secrets_manager import SecretsManager
from metadata.utils.singleton import Singleton
@ -96,6 +97,8 @@ class SecretsManagerFactory(metaclass=Singleton):
return AzureKVSecretsManager(secrets_manager_loader)
if secrets_manager_provider in (SecretsManagerProvider.gcp,):
return GCPSecretsManager(secrets_manager_loader)
if secrets_manager_provider in (SecretsManagerProvider.kubernetes,):
return KubernetesSecretsManager(secrets_manager_loader)
raise NotImplementedError(f"[{secrets_manager_provider}] is not implemented.")
def get_secrets_manager(self):

View File

@ -0,0 +1,220 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# 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.
"""
Test Kubernetes Secrets Manager
"""
import base64
import os
from unittest import TestCase
from unittest.mock import MagicMock, patch
from kubernetes.client.exceptions import ApiException
from metadata.generated.schema.security.secrets.secretsManagerClientLoader import (
SecretsManagerClientLoader,
)
from metadata.utils.secrets.kubernetes_secrets_manager import KubernetesSecretsManager
from metadata.utils.singleton import Singleton
class TestKubernetesSecretsManager(TestCase):
"""Test Kubernetes Secrets Manager"""
def setUp(self) -> None:
"""Clear singleton instances before each test"""
Singleton.clear_all()
def tearDown(self) -> None:
"""Clear singleton instances after each test"""
Singleton.clear_all()
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_init_in_cluster(self, mock_client, mock_config):
"""Test initialization with in-cluster config"""
mock_core_v1_api = MagicMock()
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(
os.environ,
{
"KUBERNETES_NAMESPACE": "test-namespace",
"KUBERNETES_IN_CLUSTER": "true",
},
):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Verify in-cluster config was loaded
mock_config.load_incluster_config.assert_called_once()
mock_config.load_kube_config.assert_not_called()
# Verify namespace is set correctly
self.assertEqual(secrets_manager.namespace, "test-namespace")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_init_with_kubeconfig(self, mock_client, mock_config):
"""Test initialization with kubeconfig file"""
mock_core_v1_api = MagicMock()
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(
os.environ,
{
"KUBERNETES_NAMESPACE": "custom-namespace",
"KUBERNETES_IN_CLUSTER": "false",
"KUBERNETES_KUBECONFIG_PATH": "/path/to/kubeconfig",
},
):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Verify kubeconfig was loaded with correct path
mock_config.load_incluster_config.assert_not_called()
mock_config.load_kube_config.assert_called_once_with(
config_file="/path/to/kubeconfig"
)
# Verify namespace is set correctly
self.assertEqual(secrets_manager.namespace, "custom-namespace")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_get_string_value_success(self, mock_client, mock_config):
"""Test successful secret retrieval"""
# Setup mock secret
mock_secret = MagicMock()
mock_secret.data = {"value": base64.b64encode(b"test-secret-value").decode()}
# Setup mock client
mock_core_v1_api = MagicMock()
mock_core_v1_api.read_namespaced_secret.return_value = mock_secret
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Test retrieving secret
result = secrets_manager.get_string_value("test-secret")
# Verify API call
mock_core_v1_api.read_namespaced_secret.assert_called_once_with(
name="test-secret", namespace="default"
)
# Verify result
self.assertEqual(result, "test-secret-value")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_get_string_value_not_found(self, mock_client, mock_config):
"""Test secret not found returns None"""
# Setup mock client to raise 404 error
mock_core_v1_api = MagicMock()
mock_core_v1_api.read_namespaced_secret.side_effect = (
lambda **kwargs: self._raise_api_exception(404)
)
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Test retrieving non-existent secret
result = secrets_manager.get_string_value("non-existent-secret")
# Verify result is None
self.assertIsNone(result)
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_get_string_value_api_error(self, mock_client, mock_config):
"""Test API error is raised"""
# Setup mock client to raise non-404 error
mock_core_v1_api = MagicMock()
mock_core_v1_api.read_namespaced_secret.side_effect = (
lambda **kwargs: self._raise_api_exception(500)
)
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Test retrieving secret with API error
with self.assertRaises(ApiException):
secrets_manager.get_string_value("test-secret")
def _raise_api_exception(self, status):
"""Helper to raise ApiException with given status"""
raise ApiException(status=status)
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_get_string_value_no_value_key(self, mock_client, mock_config):
"""Test secret without 'value' key returns None"""
# Setup mock secret without 'value' key
mock_secret = MagicMock()
mock_secret.data = {"other-key": base64.b64encode(b"some-value").decode()}
# Setup mock client
mock_core_v1_api = MagicMock()
mock_core_v1_api.read_namespaced_secret.return_value = mock_secret
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Test retrieving secret
result = secrets_manager.get_string_value("test-secret")
# Verify result is None
self.assertIsNone(result)
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
def test_get_string_value_with_special_characters(self, mock_client, mock_config):
"""Test that secret names with special characters are passed through to the backend"""
# Setup mock secret
mock_secret = MagicMock()
mock_secret.data = {"value": base64.b64encode(b"test-value").decode()}
# Setup mock client
mock_core_v1_api = MagicMock()
mock_core_v1_api.read_namespaced_secret.return_value = mock_secret
mock_client.CoreV1Api.return_value = mock_core_v1_api
with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}):
secrets_manager = KubernetesSecretsManager(
loader=SecretsManagerClientLoader.env
)
# Test with name that contains special characters
# The sanitization is now handled in the backend, so we pass the name as-is
result = secrets_manager.get_string_value("test-secret-name")
# Verify API was called with the original name (sanitization handled in backend)
mock_core_v1_api.read_namespaced_secret.assert_called_once_with(
name="test-secret-name", namespace="default"
)
# Verify result
self.assertEqual(result, "test-value")

View File

@ -66,9 +66,15 @@ class TestSecretsManagerFactory(TestCase):
)
@patch.dict(os.environ, {"AZURE_KEY_VAULT_NAME": "test"})
@patch("metadata.utils.secrets.kubernetes_secrets_manager.config")
@patch("metadata.utils.secrets.kubernetes_secrets_manager.client")
@patch("metadata.clients.aws_client.boto3")
def test_all_providers_has_implementation(self, mocked_boto3):
def test_all_providers_has_implementation(
self, mocked_boto3, mocked_k8s_client, mocked_k8s_config
):
mocked_boto3.s3_client.return_value = {}
# Mock Kubernetes client
mocked_k8s_client.CoreV1Api.return_value = None
secret_manager_providers = [
secret_manager_provider
for secret_manager_provider in SecretsManagerProvider

View File

@ -36,6 +36,7 @@
<logback-core.version>1.5.18</logback-core.version>
<logback-classic.version>1.5.18</logback-classic.version>
<resilience4j-ratelimiter.version>2.3.0</resilience4j-ratelimiter.version>
<kubernetes-client.version>21.0.1</kubernetes-client.version>
</properties>
<dependencyManagement>
@ -890,6 +891,12 @@
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-secretmanager</artifactId>
</dependency>
<!-- Kubernetes Client for Secrets Management -->
<dependency>
<groupId>io.kubernetes</groupId>
<artifactId>client-java</artifactId>
<version>${kubernetes-client.version}</version>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>bolt-servlet</artifactId>

View File

@ -30,6 +30,10 @@ public class SecretsManagerException extends WebServiceException {
super(status.getStatusCode(), SECRETS_MANAGER_ERROR, message);
}
public SecretsManagerException(String message, Throwable cause) {
super(Response.Status.INTERNAL_SERVER_ERROR, SECRETS_MANAGER_ERROR, message, cause);
}
public static SecretsManagerException byMessage(
String secretManager, String connectionType, String errorMessage) {
return new SecretsManagerException(

View File

@ -0,0 +1,286 @@
/*
* Copyright 2025 Collate
* Licensed 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.openmetadata.service.secrets;
import com.google.common.annotations.VisibleForTesting;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.Configuration;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1Secret;
import io.kubernetes.client.util.ClientBuilder;
import io.kubernetes.client.util.Config;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openmetadata.schema.security.secrets.SecretsManagerProvider;
import org.openmetadata.service.exception.SecretsManagerException;
import org.openmetadata.service.exception.SecretsManagerUpdateException;
/**
* Kubernetes implementation of the SecretsManager.
* This implementation stores secrets as Kubernetes Secret objects.
*/
@Slf4j
public class KubernetesSecretsManager extends ExternalSecretsManager {
private static final String NAMESPACE = "namespace";
private static final String KUBECONFIG_PATH = "kubeconfigPath";
private static final String IN_CLUSTER = "inCluster";
private static final String SKIP_INIT = "skipInit";
private static final String DEFAULT_NAMESPACE = "default";
private static final String SECRET_KEY = "value";
private static KubernetesSecretsManager instance = null;
@Getter private CoreV1Api apiClient;
private String namespace;
private String prefix;
private KubernetesSecretsManager(SecretsConfig secretsConfig) {
super(SecretsManagerProvider.KUBERNETES, secretsConfig, 100);
prefix =
Objects.isNull(secretsConfig.prefix()) || secretsConfig.prefix().isEmpty()
? "om"
: secretsConfig.prefix();
// Check if we should skip initialization (for testing)
boolean skipInit =
Boolean.parseBoolean(
(String)
secretsConfig
.parameters()
.getAdditionalProperties()
.getOrDefault(SKIP_INIT, "false"));
if (!skipInit) {
initializeKubernetesClient();
}
}
public static KubernetesSecretsManager getInstance(SecretsConfig secretsConfig) {
if (instance == null) {
instance = new KubernetesSecretsManager(secretsConfig);
}
return instance;
}
private void initializeKubernetesClient() {
try {
ApiClient client;
boolean inCluster =
Boolean.parseBoolean(
(String)
getSecretsConfig()
.parameters()
.getAdditionalProperties()
.getOrDefault(IN_CLUSTER, "false"));
if (inCluster) {
client = ClientBuilder.cluster().build();
LOG.info("Using in-cluster Kubernetes configuration");
} else {
String kubeconfigPath =
(String) getSecretsConfig().parameters().getAdditionalProperties().get(KUBECONFIG_PATH);
if (StringUtils.isNotBlank(kubeconfigPath)) {
client = Config.fromConfig(kubeconfigPath);
LOG.info("Using kubeconfig from path: {}", kubeconfigPath);
} else {
// Default to ~/.kube/config
client = Config.defaultClient();
LOG.info("Using default kubeconfig");
}
}
Configuration.setDefaultApiClient(client);
this.apiClient = new CoreV1Api(client);
// Set namespace
this.namespace =
(String)
getSecretsConfig()
.parameters()
.getAdditionalProperties()
.getOrDefault(NAMESPACE, DEFAULT_NAMESPACE);
LOG.info("Kubernetes SecretsManager initialized with namespace: {}", namespace);
} catch (IOException e) {
throw new SecretsManagerException("Failed to initialize Kubernetes client", e);
}
}
@Override
protected void storeSecret(String secretName, String secretValue) {
String k8sSecretName = sanitizeSecretName(secretName);
try {
V1Secret secret = new V1Secret();
V1ObjectMeta metadata = new V1ObjectMeta();
metadata.setName(k8sSecretName);
metadata.setNamespace(namespace);
Map<String, String> labels = new HashMap<>();
labels.put("app", "openmetadata");
labels.put("managed-by", "openmetadata-secrets-manager");
if (getSecretsConfig().tags() != null && !getSecretsConfig().tags().isEmpty()) {
// Convert tags list to map if needed
getSecretsConfig()
.tags()
.forEach(
tag -> {
String[] parts = tag.split(":", 2);
if (parts.length == 2) {
labels.put(parts[0], parts[1]);
}
});
}
metadata.setLabels(labels);
secret.setMetadata(metadata);
Map<String, byte[]> data = new HashMap<>();
data.put(SECRET_KEY, secretValue.getBytes(StandardCharsets.UTF_8));
secret.setData(data);
apiClient.createNamespacedSecret(namespace, secret).execute();
LOG.debug("Created Kubernetes secret: {}", k8sSecretName);
} catch (ApiException e) {
throw new SecretsManagerException(
String.format("Failed to create Kubernetes secret: %s", k8sSecretName), e);
}
}
@Override
protected void updateSecret(String secretName, String secretValue) {
String k8sSecretName = sanitizeSecretName(secretName);
try {
// Get existing secret
V1Secret existingSecret = apiClient.readNamespacedSecret(k8sSecretName, namespace).execute();
// Update the secret data
Map<String, byte[]> data = new HashMap<>();
data.put(SECRET_KEY, secretValue.getBytes(StandardCharsets.UTF_8));
existingSecret.setData(data);
apiClient.replaceNamespacedSecret(k8sSecretName, namespace, existingSecret).execute();
LOG.debug("Updated Kubernetes secret: {}", k8sSecretName);
} catch (ApiException e) {
if (e.getCode() == 404) {
// Secret doesn't exist, create it
LOG.debug("Secret {} not found, creating new secret", k8sSecretName);
storeSecret(secretName, secretValue);
} else {
throw new SecretsManagerUpdateException(
String.format("Failed to update Kubernetes secret: %s", k8sSecretName), e);
}
}
}
@Override
protected String getSecret(String secretName) {
String k8sSecretName = sanitizeSecretName(secretName);
try {
V1Secret secret = apiClient.readNamespacedSecret(k8sSecretName, namespace).execute();
if (secret.getData() != null && secret.getData().containsKey(SECRET_KEY)) {
byte[] secretData = secret.getData().get(SECRET_KEY);
return new String(secretData, StandardCharsets.UTF_8);
}
LOG.warn("Secret {} exists but has no data", k8sSecretName);
return null;
} catch (ApiException e) {
if (e.getCode() == 404) {
LOG.debug("Secret {} not found", k8sSecretName);
return null;
}
throw new SecretsManagerException(
String.format("Failed to retrieve Kubernetes secret: %s", k8sSecretName), e);
}
}
@Override
public boolean existSecret(String secretName) {
String k8sSecretName = sanitizeSecretName(secretName);
try {
apiClient.readNamespacedSecret(k8sSecretName, namespace).execute();
return true;
} catch (ApiException e) {
if (e.getCode() == 404) {
return false;
}
throw new SecretsManagerException(
String.format("Failed to check existence of Kubernetes secret: %s", k8sSecretName), e);
}
}
@Override
protected void deleteSecretInternal(String secretName) {
String k8sSecretName = sanitizeSecretName(secretName);
try {
apiClient.deleteNamespacedSecret(k8sSecretName, namespace).execute();
LOG.debug("Deleted Kubernetes secret: {}", k8sSecretName);
} catch (ApiException e) {
if (e.getCode() != 404) {
throw new SecretsManagerException(
String.format("Failed to delete Kubernetes secret: %s", k8sSecretName), e);
}
LOG.debug("Secret {} already deleted or does not exist", k8sSecretName);
}
}
/**
* Sanitize secret name to be Kubernetes compliant.
* Kubernetes secret names must be lowercase alphanumeric or '-',
* and must start and end with an alphanumeric character.
*/
private String sanitizeSecretName(String secretName) {
// Remove leading slashes
String sanitized = secretName.replaceAll("^/+", "");
sanitized = sanitized.replaceAll("[^a-zA-Z0-9-]", "-");
sanitized = sanitized.toLowerCase();
sanitized = sanitized.replaceAll("-+", "-");
sanitized = sanitized.replaceAll("^-+|-+$", "");
if (sanitized.length() > 253) {
String hash = Integer.toHexString(secretName.hashCode());
sanitized = sanitized.substring(0, 240) + "-" + hash;
}
if (!sanitized.matches("^[a-z0-9].*")) {
sanitized = prefix + "-" + sanitized;
}
return sanitized;
}
@VisibleForTesting
void setApiClient(CoreV1Api apiClient) {
this.apiClient = apiClient;
}
@VisibleForTesting
void setNamespace(String namespace) {
this.namespace = namespace;
}
}

View File

@ -54,6 +54,7 @@ public class SecretsManagerFactory {
case IN_MEMORY -> secretsManager = InMemorySecretsManager.getInstance(secretsConfig);
case MANAGED_AZURE_KV -> secretsManager = AzureKVSecretsManager.getInstance(secretsConfig);
case GCP -> secretsManager = GCPSecretsManager.getInstance(secretsConfig);
case KUBERNETES -> secretsManager = KubernetesSecretsManager.getInstance(secretsConfig);
}
return secretsManager;
}

View File

@ -0,0 +1,623 @@
/*
* Copyright 2025 Collate
* Licensed 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.openmetadata.service.secrets;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1Secret;
import io.kubernetes.client.openapi.models.V1Status;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.security.secrets.Parameters;
@ExtendWith(MockitoExtension.class)
class KubernetesSecretsManagerTest {
private static final String CLUSTER_NAME = "openmetadata";
private static final String NAMESPACE = "default";
private static final String SECRET_NAME = "/openmetadata/database/password";
private static final String SECRET_VALUE = "test-password";
private static final String K8S_SECRET_NAME = "openmetadata-database-password";
private KubernetesSecretsManager secretsManager;
@Mock private CoreV1Api mockApiClient;
@Mock private CoreV1Api.APIcreateNamespacedSecretRequest createRequest;
@Mock private CoreV1Api.APIreadNamespacedSecretRequest readRequest;
@Mock private CoreV1Api.APIreplaceNamespacedSecretRequest replaceRequest;
@Mock private CoreV1Api.APIdeleteNamespacedSecretRequest deleteRequest;
@BeforeEach
void setUp() throws Exception {
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters);
secretsManager = KubernetesSecretsManager.getInstance(secretsConfig);
secretsManager.setApiClient(mockApiClient);
secretsManager.setNamespace(NAMESPACE);
}
@Test
void testStoreSecret() throws ApiException {
ArgumentCaptor<V1Secret> secretCaptor = ArgumentCaptor.forClass(V1Secret.class);
when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(createRequest);
when(createRequest.execute()).thenReturn(new V1Secret());
secretsManager.storeValue("password", SECRET_VALUE, SECRET_NAME, true);
verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture());
verify(createRequest).execute();
V1Secret createdSecret = secretCaptor.getValue();
assertNotNull(createdSecret);
String expectedName = "openmetadata-database-password-password";
assertEquals(expectedName, Objects.requireNonNull(createdSecret.getMetadata()).getName());
assertEquals(NAMESPACE, createdSecret.getMetadata().getNamespace());
Map<String, String> labels = createdSecret.getMetadata().getLabels();
assertNotNull(labels);
assertEquals("openmetadata", labels.get("app"));
assertEquals("openmetadata-secrets-manager", labels.get("managed-by"));
Map<String, byte[]> data = createdSecret.getData();
assertNotNull(data);
assertEquals(SECRET_VALUE, new String(data.get("value"), StandardCharsets.UTF_8));
}
@Test
void testGetSecret() throws ApiException {
V1Secret mockSecret = createMockSecret(SECRET_VALUE);
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenReturn(mockSecret);
String retrievedValue = secretsManager.getSecret(SECRET_NAME);
assertEquals(SECRET_VALUE, retrievedValue);
verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(readRequest).execute();
}
@Test
void testGetSecretNotFound() throws ApiException {
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
String retrievedValue = secretsManager.getSecret(SECRET_NAME);
assertNull(retrievedValue);
verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(readRequest).execute();
}
@Test
void testUpdateSecret() throws ApiException {
V1Secret existingSecret = createMockSecret("old-value");
ArgumentCaptor<V1Secret> secretCaptor = ArgumentCaptor.forClass(V1Secret.class);
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenReturn(existingSecret);
when(mockApiClient.replaceNamespacedSecret(
eq(K8S_SECRET_NAME), eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(replaceRequest);
when(replaceRequest.execute()).thenReturn(new V1Secret());
secretsManager.updateSecret(SECRET_NAME, SECRET_VALUE);
verify(mockApiClient)
.replaceNamespacedSecret(eq(K8S_SECRET_NAME), eq(NAMESPACE), secretCaptor.capture());
verify(replaceRequest).execute();
V1Secret updatedSecret = secretCaptor.getValue();
Map<String, byte[]> data = updatedSecret.getData();
assert data != null;
assertEquals(SECRET_VALUE, new String(data.get("value"), StandardCharsets.UTF_8));
}
@Test
void testUpdateSecretNotFoundCreatesNew() throws ApiException {
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(createRequest);
when(createRequest.execute()).thenReturn(new V1Secret());
secretsManager.updateSecret(SECRET_NAME, SECRET_VALUE);
verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class));
verify(createRequest).execute();
}
@Test
void testExistSecret() throws ApiException {
V1Secret mockSecret = createMockSecret(SECRET_VALUE);
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenReturn(mockSecret);
boolean exists = secretsManager.existSecret(SECRET_NAME);
assertTrue(exists);
verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(readRequest).execute();
}
@Test
void testExistSecretNotFound() throws ApiException {
when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
boolean exists = secretsManager.existSecret(SECRET_NAME);
assertFalse(exists);
verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(readRequest).execute();
}
@Test
void testDeleteSecret() throws Exception {
// Use reflection to test protected method
when(mockApiClient.deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE))
.thenReturn(deleteRequest);
when(deleteRequest.execute()).thenReturn(new V1Status());
// Call deleteSecretInternal via reflection
java.lang.reflect.Method deleteMethod =
KubernetesSecretsManager.class.getDeclaredMethod("deleteSecretInternal", String.class);
deleteMethod.setAccessible(true);
deleteMethod.invoke(secretsManager, K8S_SECRET_NAME);
verify(mockApiClient).deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(deleteRequest).execute();
}
@Test
void testDeleteSecretNotFound() throws Exception {
when(mockApiClient.deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE))
.thenReturn(deleteRequest);
when(deleteRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
// Call deleteSecretInternal via reflection
java.lang.reflect.Method deleteMethod =
KubernetesSecretsManager.class.getDeclaredMethod("deleteSecretInternal", String.class);
deleteMethod.setAccessible(true);
// Should not throw exception
deleteMethod.invoke(secretsManager, K8S_SECRET_NAME);
verify(mockApiClient).deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE);
verify(deleteRequest).execute();
}
@Test
void testSanitizeSecretName() throws ApiException {
// Test various secret name patterns
// The storeValue returns "secret:" + original secretId + "/" + fieldName
testSanitizedName(
"/prefix/cluster/service/password",
"prefix-cluster-service-password-field",
"secret:/prefix/cluster/service/password/field");
testSanitizedName(
"///leading-slashes", "leading-slashes-field", "secret:///leading-slashes/field");
testSanitizedName("UPPERCASE-NAME", "uppercase-name-field", "secret:uppercase-name/field");
testSanitizedName(
"special@#$%characters", "special-characters-field", "secret:special____characters/field");
testSanitizedName(
"consecutive---hyphens", "consecutive-hyphens-field", "secret:consecutive---hyphens/field");
testSanitizedName("-leading-hyphen", "leading-hyphen-field", "secret:-leading-hyphen/field");
testSanitizedName("trailing-hyphen-", "trailing-hyphen-field", "secret:trailing-hyphen-/field");
}
@Test
void testNullSecretHandling() throws ApiException {
ArgumentCaptor<V1Secret> secretCaptor = ArgumentCaptor.forClass(V1Secret.class);
when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(createRequest);
when(createRequest.execute()).thenReturn(new V1Secret());
secretsManager.storeValue("field", "", SECRET_NAME, true);
verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture());
verify(createRequest).execute();
V1Secret createdSecret = secretCaptor.getValue();
Map<String, byte[]> data = createdSecret.getData();
assert data != null;
assertEquals("", new String(data.get("value"), StandardCharsets.UTF_8));
}
// New tests for configurable prefix functionality
@Test
void testDefaultPrefixWhenEmpty() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with empty prefix
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithEmptyPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithEmptyPrefix.setApiClient(mockApiClient);
secretsManagerWithEmptyPrefix.setNamespace(NAMESPACE);
// Test that names starting with non-alphanumeric characters get "om-" prefix
// Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId
// After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed)
testSanitizedNameWithPrefix(
secretsManagerWithEmptyPrefix,
"-leading-hyphen",
"leading-hyphen-field",
"secret:-leading-hyphen/field");
}
@Test
void testDefaultPrefixWhenNull() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with null prefix - this should be handled gracefully
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, null, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithNullPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithNullPrefix.setApiClient(mockApiClient);
secretsManagerWithNullPrefix.setNamespace(NAMESPACE);
// Test that names starting with non-alphanumeric characters get "om-" prefix
// Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId
// After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed)
testSanitizedNameWithPrefix(
secretsManagerWithNullPrefix,
"-leading-hyphen",
"leading-hyphen-field",
"secret:-leading-hyphen/field");
}
@Test
void testCustomPrefix() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with custom prefix
String customPrefix = "myapp";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test that names starting with non-alphanumeric characters get custom prefix
// Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId
// After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed)
testSanitizedNameWithPrefix(
secretsManagerWithCustomPrefix,
"-leading-hyphen",
"leading-hyphen-field",
"secret:-leading-hyphen/field");
}
@Test
void testPrefixNotAppliedWhenNameStartsWithAlphanumeric() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with custom prefix
String customPrefix = "myapp";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test that names starting with alphanumeric characters don't get prefix
testSanitizedNameWithPrefix(
secretsManagerWithCustomPrefix,
"normal-name",
"normal-name-field",
"secret:normal-name/field");
}
@Test
void testPrefixWithSpecialCharacters() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with prefix containing special characters
String customPrefix = "my-app";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test that names starting with non-alphanumeric characters get custom prefix
// Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId
// After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed)
testSanitizedNameWithPrefix(
secretsManagerWithCustomPrefix,
"-leading-hyphen",
"leading-hyphen-field",
"secret:-leading-hyphen/field");
}
@Test
void testEmptyStringNameWithDefaultPrefix() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with empty prefix
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithEmptyPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithEmptyPrefix.setApiClient(mockApiClient);
secretsManagerWithEmptyPrefix.setNamespace(NAMESPACE);
// Test empty string name - this should result in "om-secret-field"
// Input "" becomes "/field" after buildSecretId
// After sanitization: "field" (starts with 'f', so no prefix needed)
testSanitizedNameWithPrefix(secretsManagerWithEmptyPrefix, "", "field", "secret:/field");
}
@Test
void testEmptyStringNameWithCustomPrefix() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with custom prefix
String customPrefix = "myapp";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test empty string name with custom prefix
// Input "" becomes "/field" after buildSecretId
// After sanitization: "field" (starts with 'f', so no prefix needed)
testSanitizedNameWithPrefix(secretsManagerWithCustomPrefix, "", "field", "secret:/field");
}
// Add tests that actually trigger prefix application
@Test
void testPrefixAppliedWhenNameStartsWithHyphen() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with custom prefix
String customPrefix = "myapp";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test with a name that will actually trigger prefix application
// Input "-" becomes "-/field" after buildSecretId
// After sanitization: "field" (leading hyphen gets removed, so no prefix needed)
testSanitizedNameWithPrefix(secretsManagerWithCustomPrefix, "-", "field", "secret:-/field");
}
@Test
void testPrefixAppliedWhenNameStartsWithSpecialChar() throws Exception {
// Reset singleton instance
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
Parameters parameters = new Parameters();
parameters.setAdditionalProperty("namespace", NAMESPACE);
parameters.setAdditionalProperty("inCluster", "false");
parameters.setAdditionalProperty("skipInit", "true");
// Test with custom prefix
String customPrefix = "myapp";
SecretsManager.SecretsConfig secretsConfig =
new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters);
KubernetesSecretsManager secretsManagerWithCustomPrefix =
KubernetesSecretsManager.getInstance(secretsConfig);
secretsManagerWithCustomPrefix.setApiClient(mockApiClient);
secretsManagerWithCustomPrefix.setNamespace(NAMESPACE);
// Test with a name that will actually trigger prefix application
// Input "@special" becomes "_special/field" after buildSecretId (special chars replaced with _)
// After sanitization: "special-field" (starts with 's', so no prefix needed)
testSanitizedNameWithPrefix(
secretsManagerWithCustomPrefix, "@special", "special-field", "secret:_special/field");
}
private V1Secret createMockSecret(String value) {
V1Secret secret = new V1Secret();
V1ObjectMeta metadata = new V1ObjectMeta();
metadata.setName(K8S_SECRET_NAME);
metadata.setNamespace(NAMESPACE);
secret.setMetadata(metadata);
Map<String, byte[]> data = new HashMap<>();
data.put("value", value.getBytes(StandardCharsets.UTF_8));
secret.setData(data);
return secret;
}
private void testSanitizedName(String input, String expectedSanitizedName, String expectedResult)
throws ApiException {
reset(mockApiClient, readRequest, createRequest);
when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
ArgumentCaptor<V1Secret> secretCaptor = ArgumentCaptor.forClass(V1Secret.class);
when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(createRequest);
when(createRequest.execute()).thenReturn(new V1Secret());
String result = secretsManager.storeValue("field", "value", input, true);
verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture());
V1Secret createdSecret = secretCaptor.getValue();
String actualName = Objects.requireNonNull(createdSecret.getMetadata()).getName();
assertEquals(expectedSanitizedName, actualName);
assertEquals(expectedResult, result);
}
private void testSanitizedNameWithPrefix(
KubernetesSecretsManager secretsManagerInstance,
String input,
String expectedSanitizedName,
String expectedResult)
throws ApiException {
reset(mockApiClient, readRequest, createRequest);
when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest);
when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found"));
ArgumentCaptor<V1Secret> secretCaptor = ArgumentCaptor.forClass(V1Secret.class);
when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)))
.thenReturn(createRequest);
when(createRequest.execute()).thenReturn(new V1Secret());
String result = secretsManagerInstance.storeValue("field", "value", input, true);
verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture());
V1Secret createdSecret = secretCaptor.getValue();
String actualName = Objects.requireNonNull(createdSecret.getMetadata()).getName();
assertEquals(expectedSanitizedName, actualName);
assertEquals(expectedResult, result);
}
@AfterEach
void tearDown() throws Exception {
// Reset singleton instance after each test
java.lang.reflect.Field instanceField =
KubernetesSecretsManager.class.getDeclaredField("instance");
instanceField.setAccessible(true);
instanceField.set(null, null);
}
}

View File

@ -0,0 +1,27 @@
{
"$id": "https://open-metadata.org/schema/security/credentials/kubernetesCredentials.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KubernetesCredentials",
"description": "Credentials for a Kubernetes cluster",
"type": "object",
"javaType": "org.openmetadata.schema.security.credentials.KubernetesCredentials",
"properties": {
"namespace": {
"title": "Namespace",
"description": "The namespace of the Kubernetes cluster",
"type": "string"
},
"inCluster": {
"title": "In Cluster",
"description": "Whether the Kubernetes secrets manager is running in the same cluster where the OpenMetadata services are running",
"type": "boolean"
},
"kubeconfigPath": {
"title": "Kubeconfig Path",
"description": "The path to the kubeconfig file",
"type": "string"
}
},
"additionalProperties": false
}

View File

@ -5,7 +5,7 @@
"description": "OpenMetadata Secrets Manager Provider. Make sure to configure the same secrets manager providers as the ones configured on the OpenMetadata server.",
"type": "string",
"javaType": "org.openmetadata.schema.security.secrets.SecretsManagerProvider",
"enum": ["db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp"],
"enum": ["db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp", "kubernetes"],
"default": "db",
"additionalProperties": false
}

View File

@ -3691,6 +3691,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -751,6 +751,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -5384,6 +5384,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -1331,6 +1331,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -3573,6 +3573,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -456,6 +456,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -226,6 +226,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -3554,6 +3554,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -579,6 +579,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -871,6 +871,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -1164,6 +1164,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -3598,6 +3598,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -3647,6 +3647,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -44,6 +44,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",

View File

@ -21,6 +21,7 @@ export enum SecretsManagerProvider {
DB = "db",
Gcp = "gcp",
InMemory = "in-memory",
Kubernetes = "kubernetes",
ManagedAws = "managed-aws",
ManagedAwsSsm = "managed-aws-ssm",
ManagedAzureKv = "managed-azure-kv",