This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 4be59c2597 [#12218] feat(secret): Add secret provider registry and
discovery API (#12295)
4be59c2597 is described below
commit 4be59c25970d2f6dea156764bcc27d48d37c0921
Author: MaSai <[email protected]>
AuthorDate: Tue Aug 4 10:09:00 2026 +0800
[#12218] feat(secret): Add secret provider registry and discovery API
(#12295)
### What changes were proposed in this pull request?
- Add `SecretProviderRegistry` to load named `SecretProvider` instances
from server conf.
- Own the registry in `GravitinoEnv` (same lifecycle pattern as
`KmsClientRegistry`).
- Expose `GET /configs/secrets/providers` for safe provider discovery
(`name` / `type` / optional `uri`).
### Why are the changes needed?
Clients need to discover registered secret-provider instance names for
create/alter bindings, and the server must keep providers loaded for the
process lifetime before write/resolve paths land.
Fix: #12218
### Does this PR introduce _any_ user-facing change?
- New endpoint: `GET /configs/secrets/providers`
- New config keys: `gravitino.secret.providers`,
`gravitino.secret.provider.<name>.className`, optional
`gravitino.secret.provider.<name>.uri`
### How was this patch tested?
- `./gradlew :core:test --tests
'org.apache.gravitino.secret.TestSecretProviderRegistry' --tests
'org.apache.gravitino.TestGravitinoEnvSecretProviderRegistry' -PskipITs`
- `./gradlew :server:test --tests
'org.apache.gravitino.server.web.TestSecretProvidersConfigServlet'
--tests 'org.apache.gravitino.server.TestGravitinoServer' -PskipITs`
---------
Co-authored-by: Cursor <[email protected]>
---
.../gravitino/secret/SecretProviderInfo.java | 109 +++++++++
.../java/org/apache/gravitino/GravitinoEnv.java | 23 ++
.../gravitino/secret/SecretProviderRegistry.java | 246 +++++++++++++++++++++
.../TestGravitinoEnvSecretProviderRegistry.java | 72 ++++++
.../secret/TestSecretProviderRegistry.java | 111 ++++++++++
.../apache/gravitino/server/GravitinoServer.java | 4 +
.../server/web/SecretProvidersConfigServlet.java | 89 ++++++++
.../gravitino/server/TestGravitinoServer.java | 87 ++++++++
.../web/TestSecretProvidersConfigServlet.java | 136 ++++++++++++
9 files changed, 877 insertions(+)
diff --git
a/common/src/main/java/org/apache/gravitino/secret/SecretProviderInfo.java
b/common/src/main/java/org/apache/gravitino/secret/SecretProviderInfo.java
new file mode 100644
index 0000000000..ef541bbcfd
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/secret/SecretProviderInfo.java
@@ -0,0 +1,109 @@
+/*
+ * 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.gravitino.secret;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+/** Safe metadata for a configured secrets-provider instance. */
+public final class SecretProviderInfo {
+
+ private final String name;
+ private final String type;
+ @Nullable private final String uri;
+
+ /**
+ * Creates provider metadata.
+ *
+ * @param name the configured provider instance name
+ * @param type the provider type identifier from {@link
SecretProvider#type()}
+ * @param uri optional non-secret provider endpoint; may be {@code null}
+ */
+ @JsonCreator
+ public SecretProviderInfo(
+ @JsonProperty("name") String name,
+ @JsonProperty("type") String type,
+ @JsonProperty("uri") @Nullable String uri) {
+ Preconditions.checkArgument(StringUtils.isNotBlank(name), "name must not
be blank");
+ Preconditions.checkArgument(StringUtils.isNotBlank(type), "type must not
be blank");
+ this.name = name;
+ this.type = type;
+ this.uri = StringUtils.isBlank(uri) ? null : uri;
+ }
+
+ /**
+ * Returns the configured provider instance name.
+ *
+ * @return the provider name
+ */
+ @JsonProperty("name")
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the provider type identifier.
+ *
+ * @return the provider type
+ */
+ @JsonProperty("type")
+ public String type() {
+ return type;
+ }
+
+ /**
+ * Returns the optional non-secret provider endpoint.
+ *
+ * @return the URI, or {@code null} when not configured
+ */
+ @Nullable
+ @JsonProperty("uri")
+ public String uri() {
+ return uri;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof SecretProviderInfo)) {
+ return false;
+ }
+ SecretProviderInfo that = (SecretProviderInfo) o;
+ return Objects.equals(name, that.name)
+ && Objects.equals(type, that.type)
+ && Objects.equals(uri, that.uri);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, type, uri);
+ }
+
+ @Override
+ public String toString() {
+ return "SecretProviderInfo{name='" + name + "', type='" + type + "',
uri='" + uri + "'}";
+ }
+}
diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index 0b955f08d7..f2e10a712c 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -98,6 +98,7 @@ import org.apache.gravitino.metrics.MetricsSystem;
import org.apache.gravitino.metrics.source.JVMMetricsSource;
import org.apache.gravitino.policy.PolicyDispatcher;
import org.apache.gravitino.policy.PolicyManager;
+import org.apache.gravitino.secret.SecretProviderRegistry;
import org.apache.gravitino.stats.StatisticDispatcher;
import org.apache.gravitino.stats.StatisticManager;
import org.apache.gravitino.storage.IdGenerator;
@@ -157,6 +158,8 @@ public class GravitinoEnv {
private KmsClientRegistry kmsClientRegistry;
+ private SecretProviderRegistry secretProviderRegistry;
+
private TagDispatcher tagDispatcher;
private PolicyDispatcher policyDispatcher;
@@ -436,6 +439,21 @@ public class GravitinoEnv {
return kmsClientRegistry;
}
+ /**
+ * Get the secrets-provider registry associated with the Gravitino
environment.
+ *
+ * <p>The environment owns this registry. Callers may inject it into
dependent components but must
+ * not close it.
+ *
+ * @return The secrets-provider registry instance.
+ * @throws IllegalStateException if the environment has not been initialized
+ */
+ public SecretProviderRegistry secretProviderRegistry() {
+ Preconditions.checkState(
+ secretProviderRegistry != null, "GravitinoEnv components are not
initialized.");
+ return secretProviderRegistry;
+ }
+
/**
* Get the IdGenerator associated with the Gravitino environment.
*
@@ -657,11 +675,16 @@ public class GravitinoEnv {
kmsClientRegistry.close();
}
+ if (secretProviderRegistry != null) {
+ secretProviderRegistry.close();
+ }
+
LOG.info("Gravitino Environment is shut down.");
}
private void initBaseComponents() {
this.kmsClientRegistry = new KmsClientRegistry(config);
+ this.secretProviderRegistry = new SecretProviderRegistry(config);
this.metricsSystem = new MetricsSystem();
metricsSystem.register(new JVMMetricsSource());
diff --git
a/core/src/main/java/org/apache/gravitino/secret/SecretProviderRegistry.java
b/core/src/main/java/org/apache/gravitino/secret/SecretProviderRegistry.java
new file mode 100644
index 0000000000..ac2b4e3e26
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/secret/SecretProviderRegistry.java
@@ -0,0 +1,246 @@
+/*
+ * 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.gravitino.secret;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.utils.MapUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Loads and owns named {@link SecretProvider} instances from server
configuration for the process
+ * lifetime (owned by {@code GravitinoEnv}, analogous to the KMS client
registry).
+ *
+ * <p>Configuration shape:
+ *
+ * <pre>{@code
+ * gravitino.secret.providers=memory
+ *
gravitino.secret.provider.memory.className=org.apache.gravitino.secret.memory.InMemorySecretsProvider
+ * gravitino.secret.provider.<name>.uri=<optional-non-secret-endpoint>
+ * }</pre>
+ */
+public final class SecretProviderRegistry implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SecretProviderRegistry.class);
+
+ /** Comma-separated configured provider instance names. */
+ public static final String GRAVITINO_SECRET_PROVIDERS =
"gravitino.secret.providers";
+
+ /** Prefix for per-provider settings: {@code
gravitino.secret.provider.<name>.}. */
+ public static final String GRAVITINO_SECRET_PROVIDER_PREFIX =
"gravitino.secret.provider.";
+
+ /** Fully qualified {@link SecretProvider} implementation class. */
+ public static final String CLASS_NAME = "className";
+
+ /** Optional non-secret provider endpoint exposed by the discovery API. */
+ public static final String URI = "uri";
+
+ private static final Splitter COMMA_SPLITTER =
Splitter.on(',').omitEmptyStrings().trimResults();
+ private static final Pattern PROVIDER_NAME_PATTERN =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]*");
+
+ private final Map<String, SecretProvider> providers;
+ private final List<SecretProviderInfo> providerInfos;
+ private volatile boolean closed;
+
+ /**
+ * Creates a registry from Gravitino configuration.
+ *
+ * @param config Gravitino configuration; must not be {@code null}
+ * @throws IllegalArgumentException if configuration is invalid
+ * @throws IllegalStateException if a provider cannot be loaded or
initialized
+ */
+ public SecretProviderRegistry(Config config) {
+ Preconditions.checkArgument(config != null, "config must not be null");
+ Map<String, SecretProvider> loaded = new LinkedHashMap<>();
+ List<SecretProviderInfo> infos = new ArrayList<>();
+ try {
+ for (String name :
parseProviderNames(config.getRawString(GRAVITINO_SECRET_PROVIDERS, ""))) {
+ LoadedProvider loadedProvider = loadProvider(name, config);
+ loaded.put(name, loadedProvider.provider);
+ infos.add(loadedProvider.info);
+ }
+ } catch (RuntimeException e) {
+ closeQuietly(loaded.values());
+ throw e;
+ }
+ this.providers = ImmutableMap.copyOf(loaded);
+ this.providerInfos = ImmutableList.copyOf(infos);
+ }
+
+ /**
+ * Returns safe metadata for all configured providers.
+ *
+ * @return an immutable list of provider metadata (empty when none are
configured)
+ */
+ public List<SecretProviderInfo> listProviders() {
+ checkOpen();
+ return providerInfos;
+ }
+
+ /**
+ * Returns the live provider registered under {@code name}.
+ *
+ * @param name the configured provider instance name
+ * @return the provider
+ * @throws IllegalArgumentException if the name is unknown
+ * @throws IllegalStateException if the registry is closed
+ */
+ public SecretProvider getProvider(String name) {
+ checkOpen();
+ Preconditions.checkArgument(StringUtils.isNotBlank(name), "name must not
be blank");
+ SecretProvider provider = providers.get(name);
+ Preconditions.checkArgument(provider != null, "Unknown secret provider
'%s'", name);
+ return provider;
+ }
+
+ /**
+ * Returns whether a provider is registered under {@code name}.
+ *
+ * @param name the configured provider instance name
+ * @return {@code true} if the provider exists
+ */
+ public boolean contains(String name) {
+ checkOpen();
+ return providers.containsKey(name);
+ }
+
+ /** Closes all configured providers. This operation is idempotent. */
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ closeQuietly(providers.values());
+ }
+
+ private void checkOpen() {
+ if (closed) {
+ throw new IllegalStateException("SecretProviderRegistry is closed");
+ }
+ }
+
+ private static ImmutableList<String> parseProviderNames(String value) {
+ if (StringUtils.isBlank(value)) {
+ return ImmutableList.of();
+ }
+ ImmutableList.Builder<String> names = ImmutableList.builder();
+ Set<String> unique = new LinkedHashSet<>();
+ for (String name : COMMA_SPLITTER.split(value)) {
+ if (!PROVIDER_NAME_PATTERN.matcher(name).matches()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Invalid secret provider name '%s' in %s", name,
GRAVITINO_SECRET_PROVIDERS));
+ }
+ if (!unique.add(name)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Duplicate secret provider name '%s' in %s", name,
GRAVITINO_SECRET_PROVIDERS));
+ }
+ names.add(name);
+ }
+ return names.build();
+ }
+
+ private static LoadedProvider loadProvider(String name, Config config) {
+ String prefix = GRAVITINO_SECRET_PROVIDER_PREFIX + name + ".";
+ Map<String, String> properties =
MapUtils.getPrefixMap(config.getAllConfig(), prefix);
+ String className = properties.get(CLASS_NAME);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(className),
+ "Secret provider '%s' is missing required config %s%s",
+ name,
+ prefix,
+ CLASS_NAME);
+
+ Map<String, String> providerConfig = new LinkedHashMap<>(properties);
+ providerConfig.remove(CLASS_NAME);
+ String uri = blankToNull(providerConfig.get(URI));
+
+ SecretProvider provider = instantiate(name, className);
+ try {
+ provider.initialize(name, ImmutableMap.copyOf(providerConfig));
+ } catch (RuntimeException e) {
+ closeQuietly(ImmutableList.of(provider));
+ throw new IllegalStateException(
+ String.format("Failed to initialize secret provider '%s' (%s)",
name, className), e);
+ }
+
+ String type = provider.type();
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(type),
+ "Secret provider '%s' (%s) returned a blank type()",
+ name,
+ className);
+ LOG.info("Loaded secret provider '{}' of type '{}'", name, type);
+ return new LoadedProvider(provider, new SecretProviderInfo(name, type,
uri));
+ }
+
+ private static SecretProvider instantiate(String name, String className) {
+ try {
+ Object instance =
Class.forName(className).getDeclaredConstructor().newInstance();
+ Preconditions.checkArgument(
+ instance instanceof SecretProvider,
+ "Secret provider '%s' className '%s' does not implement
SecretProvider",
+ name,
+ className);
+ return (SecretProvider) instance;
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(
+ String.format("Failed to load secret provider '%s' (%s)", name,
className), e);
+ }
+ }
+
+ private static void closeQuietly(Iterable<SecretProvider> providers) {
+ for (SecretProvider provider : providers) {
+ try {
+ provider.close();
+ } catch (RuntimeException e) {
+ LOG.warn("Failed to close secret provider", e);
+ }
+ }
+ }
+
+ private static String blankToNull(String value) {
+ return StringUtils.isBlank(value) ? null : value;
+ }
+
+ private static final class LoadedProvider {
+ private final SecretProvider provider;
+ private final SecretProviderInfo info;
+
+ private LoadedProvider(SecretProvider provider, SecretProviderInfo info) {
+ this.provider = provider;
+ this.info = info;
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvSecretProviderRegistry.java
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvSecretProviderRegistry.java
new file mode 100644
index 0000000000..370802d327
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvSecretProviderRegistry.java
@@ -0,0 +1,72 @@
+/*
+ * 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.gravitino;
+
+import java.util.Properties;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.apache.gravitino.secret.memory.InMemorySecretsProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestGravitinoEnvSecretProviderRegistry {
+
+ @Test
+ void testEmptyRegistryIsOptionalAndClosedWithEnvironment() throws
IllegalAccessException {
+ TestGravitinoEnv env = new TestGravitinoEnv();
+ Assertions.assertThrows(IllegalStateException.class,
env::secretProviderRegistry);
+
+ SecretProviderRegistry registry = new SecretProviderRegistry(new
Config(false) {});
+ FieldUtils.writeField(env, "secretProviderRegistry", registry, true);
+
+ Assertions.assertSame(registry, env.secretProviderRegistry());
+ Assertions.assertTrue(registry.listProviders().isEmpty());
+
+ env.shutdown();
+
+ Assertions.assertSame(registry, env.secretProviderRegistry());
+ Assertions.assertThrows(IllegalStateException.class,
registry::listProviders);
+ }
+
+ @Test
+ void testBaseEnvironmentInitializesSecretProviderRegistry() {
+ TestGravitinoEnv env = new TestGravitinoEnv();
+ Config config = new Config(false) {};
+ Properties properties = new Properties();
+ properties.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
"memory");
+ properties.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "memory."
+ + SecretProviderRegistry.CLASS_NAME,
+ InMemorySecretsProvider.class.getName());
+ config.loadFromProperties(properties);
+
+ env.initializeBaseComponents(config);
+ SecretProviderRegistry registry = env.secretProviderRegistry();
+ Assertions.assertEquals(1, registry.listProviders().size());
+ Assertions.assertEquals("memory", registry.getProvider("memory").type());
+
+ env.shutdown();
+
+ Assertions.assertSame(registry, env.secretProviderRegistry());
+ Assertions.assertThrows(IllegalStateException.class,
registry::listProviders);
+ }
+
+ private static final class TestGravitinoEnv extends GravitinoEnv {}
+}
diff --git
a/core/src/test/java/org/apache/gravitino/secret/TestSecretProviderRegistry.java
b/core/src/test/java/org/apache/gravitino/secret/TestSecretProviderRegistry.java
new file mode 100644
index 0000000000..0a1a2e5e1a
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/secret/TestSecretProviderRegistry.java
@@ -0,0 +1,111 @@
+/*
+ * 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.gravitino.secret;
+
+import java.util.List;
+import java.util.Properties;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.secret.memory.InMemorySecretsProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestSecretProviderRegistry {
+
+ @Test
+ public void testEmptyProviders() {
+ Config config = new Config(false) {};
+ try (SecretProviderRegistry registry = new SecretProviderRegistry(config))
{
+ Assertions.assertTrue(registry.listProviders().isEmpty());
+ Assertions.assertFalse(registry.contains("memory"));
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
registry.getProvider("memory"));
+ }
+ }
+
+ @Test
+ public void testLoadInMemoryProvider() {
+ Config config = configWithMemoryProvider(null);
+ try (SecretProviderRegistry registry = new SecretProviderRegistry(config))
{
+ List<SecretProviderInfo> infos = registry.listProviders();
+ Assertions.assertEquals(1, infos.size());
+ Assertions.assertEquals(new SecretProviderInfo("memory", "memory",
null), infos.get(0));
+ Assertions.assertTrue(registry.contains("memory"));
+ Assertions.assertEquals("memory", registry.getProvider("memory").type());
+ }
+ }
+
+ @Test
+ public void testOptionalUri() {
+ Config config = configWithMemoryProvider("https://secrets.example.com");
+ try (SecretProviderRegistry registry = new SecretProviderRegistry(config))
{
+ Assertions.assertEquals(
+ new SecretProviderInfo("memory", "memory",
"https://secrets.example.com"),
+ registry.listProviders().get(0));
+ }
+ }
+
+ @Test
+ public void testDuplicateProviderNameRejected() {
+ Config config = new Config(false) {};
+ Properties properties = new Properties();
+ properties.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
"memory,memory");
+ config.loadFromProperties(properties);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new
SecretProviderRegistry(config));
+ }
+
+ @Test
+ public void testMissingClassNameRejected() {
+ Config config = new Config(false) {};
+ Properties properties = new Properties();
+ properties.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
"memory");
+ config.loadFromProperties(properties);
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new
SecretProviderRegistry(config));
+ }
+
+ @Test
+ public void testClosedRegistryRejectsAccess() {
+ Config config = configWithMemoryProvider(null);
+ SecretProviderRegistry registry = new SecretProviderRegistry(config);
+ registry.close();
+ Assertions.assertThrows(IllegalStateException.class,
registry::listProviders);
+ Assertions.assertThrows(IllegalStateException.class, () ->
registry.getProvider("memory"));
+ }
+
+ private static Config configWithMemoryProvider(String uri) {
+ Config config = new Config(false) {};
+ Properties properties = new Properties();
+ properties.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
"memory");
+ properties.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "memory."
+ + SecretProviderRegistry.CLASS_NAME,
+ InMemorySecretsProvider.class.getName());
+ if (uri != null) {
+ properties.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "memory."
+ + SecretProviderRegistry.URI,
+ uri);
+ }
+ config.loadFromProperties(properties);
+ return config;
+ }
+}
diff --git
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index e3825a7706..a9391470af 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -56,6 +56,7 @@ import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
import org.apache.gravitino.server.web.ObjectMapperProvider;
import org.apache.gravitino.server.web.RequestContextFilter;
+import org.apache.gravitino.server.web.SecretProvidersConfigServlet;
import org.apache.gravitino.server.web.VersioningFilter;
import org.apache.gravitino.server.web.filter.AccessControlNotAllowedFilter;
import org.apache.gravitino.server.web.filter.GravitinoInterceptionService;
@@ -181,6 +182,9 @@ public class GravitinoServer extends ResourceConfig {
server.addServlet(servlet, API_ANY_PATH);
Servlet configServlet = new ConfigServlet(serverConfig);
server.addServlet(configServlet, "/configs");
+ server.addServlet(
+ new
SecretProvidersConfigServlet(gravitinoEnv.secretProviderRegistry()),
+ "/configs/secrets/providers");
// Root-level aliases for enterprise GTMs that require probes at
well-known root paths.
// Forwards /health, /health/live, /health/ready, and /health.html to the
canonical
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/SecretProvidersConfigServlet.java
b/server/src/main/java/org/apache/gravitino/server/web/SecretProvidersConfigServlet.java
new file mode 100644
index 0000000000..3fc41427f1
--- /dev/null
+++
b/server/src/main/java/org/apache/gravitino/server/web/SecretProvidersConfigServlet.java
@@ -0,0 +1,89 @@
+/*
+ * 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.gravitino.server.web;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Map;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.secret.SecretProviderInfo;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Serves {@code GET /configs/secrets/providers} with safe secrets-provider
discovery metadata.
+ *
+ * <p>Uses the same auth model as {@link ConfigServlet} (no additional
privilege check). Returns
+ * only {@code name}, {@code type}, and optional non-secret {@code uri}. The
process-owned {@link
+ * SecretProviderRegistry} from {@code GravitinoEnv} is the source of truth.
+ */
+public class SecretProvidersConfigServlet extends HttpServlet {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SecretProvidersConfigServlet.class);
+
+ private final List<SecretProviderInfo> providers;
+
+ /**
+ * Creates a servlet backed by the process-owned secrets-provider registry.
+ *
+ * @param registry the environment-owned registry; must not be closed by
this servlet
+ */
+ public SecretProvidersConfigServlet(SecretProviderRegistry registry) {
+ this.providers = ImmutableList.copyOf(registry.listProviders());
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse res) throws
IOException {
+ Map<String, Object> body = ImmutableMap.of("providers", providers);
+ try (PrintWriter writer = res.getWriter()) {
+ res.setContentType("application/json;charset=utf-8");
+
writer.write(ObjectMapperProvider.objectMapper().writeValueAsString(body));
+ } catch (IllegalStateException exception) {
+ LOG.error("Illegal state occurred when calling getWriter()", exception);
+ res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ sendErrorResponse(res, "Failed to get response writer");
+ } catch (IOException exception) {
+ LOG.error("Failed to perform IO", exception);
+ res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ sendErrorResponse(res, "IO error occurred");
+ } catch (Exception e) {
+ LOG.error("Unexpected error: {}", e.getMessage(), e);
+ res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ sendErrorResponse(res, "Internal server error");
+ }
+ }
+
+ private void sendErrorResponse(HttpServletResponse res, String message) {
+ try (PrintWriter writer = res.getWriter()) {
+ res.setContentType("application/json;charset=utf-8");
+ Map<String, String> error = Map.of("error", message);
+
writer.write(ObjectMapperProvider.objectMapper().writeValueAsString(error));
+ } catch (IOException e) {
+ LOG.error("Failed to send error response", e);
+ } catch (IllegalStateException e) {
+ LOG.error("Failed to send error response: illegal state", e);
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
index 0c5cfa48e6..7b8dbca075 100644
--- a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
+++ b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
@@ -19,19 +19,32 @@
package org.apache.gravitino.server;
import static org.apache.gravitino.Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PATH;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.ImmutableMap;
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.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import org.apache.commons.io.FileUtils;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.apache.gravitino.secret.memory.InMemorySecretsProvider;
import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
@@ -136,4 +149,78 @@ public class TestGravitinoServer {
hookBlock.contains("server.gracefulStop()"),
"Shutdown hook should invoke server.gracefulStop() so app-level
cleanup runs on SIGTERM");
}
+
+ @Test
+ public void testSecretProvidersDiscoveryEmpty() throws Exception {
+ gravitinoServer.initialize();
+ gravitinoServer.start();
+
+ List<Map<String, Object>> providers =
fetchSecretProviders(spyServerConfig);
+ assertTrue(providers.isEmpty());
+ }
+
+ @Test
+ public void testSecretProvidersDiscoveryWithMemoryProvider() throws
Exception {
+ ServerConfig serverConfig =
spyServerConfig(serverConfigWithMemoryProvider());
+ gravitinoServer = new GravitinoServer(serverConfig,
GravitinoEnv.getInstance());
+ gravitinoServer.initialize();
+ gravitinoServer.start();
+
+ List<Map<String, Object>> providers = fetchSecretProviders(serverConfig);
+ assertEquals(1, providers.size());
+ assertEquals("memory", providers.get(0).get("name"));
+ assertEquals("memory", providers.get(0).get("type"));
+ assertEquals("https://secrets.example.com", providers.get(0).get("uri"));
+ assertFalse(providers.get(0).containsKey("className"));
+ }
+
+ private static ServerConfig serverConfigWithMemoryProvider() throws
IOException {
+ Map<String, String> configs = new HashMap<>();
+ configs.put(
+ GravitinoServer.WEBSERVER_CONF_PREFIX +
JettyServerConfig.WEBSERVER_HTTP_PORT.getKey(),
+ String.valueOf(RESTUtils.findAvailablePort(5000, 6000)));
+ configs.put(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS, "memory");
+ configs.put(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "memory."
+ + SecretProviderRegistry.CLASS_NAME,
+ InMemorySecretsProvider.class.getName());
+ configs.put(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "memory."
+ + SecretProviderRegistry.URI,
+ "https://secrets.example.com");
+
+ ServerConfig serverConfig = new ServerConfig();
+ serverConfig.loadFromMap(configs, t -> true);
+ return serverConfig;
+ }
+
+ private static ServerConfig spyServerConfig(ServerConfig serverConfig) {
+ ServerConfig spy = Mockito.spy(serverConfig);
+
Mockito.when(spy.getConfigsWithPrefix(AuxiliaryServiceManager.GRAVITINO_AUX_SERVICE_PREFIX))
+ .thenReturn(ImmutableMap.of(AuxiliaryServiceManager.AUX_SERVICE_NAMES,
""));
+ return spy;
+ }
+
+ private static List<Map<String, Object>> fetchSecretProviders(ServerConfig
serverConfig)
+ throws Exception {
+ int port =
+ JettyServerConfig.fromConfig(serverConfig,
GravitinoServer.WEBSERVER_CONF_PREFIX)
+ .getHttpPort();
+ HttpResponse<String> response =
+ HttpClient.newHttpClient()
+ .send(
+ HttpRequest.newBuilder(
+ URI.create("http://127.0.0.1:" + port +
"/configs/secrets/providers"))
+ .GET()
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, response.statusCode());
+ Map<String, Object> body =
+ ObjectMapperProvider.objectMapper()
+ .readValue(response.body(), new TypeReference<Map<String,
Object>>() {});
+ return ObjectMapperProvider.objectMapper()
+ .convertValue(body.get("providers"), new
TypeReference<List<Map<String, Object>>>() {});
+ }
}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/TestSecretProvidersConfigServlet.java
b/server/src/test/java/org/apache/gravitino/server/web/TestSecretProvidersConfigServlet.java
new file mode 100644
index 0000000000..403fd6d9d1
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/TestSecretProvidersConfigServlet.java
@@ -0,0 +1,136 @@
+/*
+ * 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.gravitino.server.web;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.apache.gravitino.secret.memory.InMemorySecretsProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+public class TestSecretProvidersConfigServlet {
+
+ @Test
+ public void testEmptyProviders() throws Exception {
+ try (SecretProviderRegistry registry = new SecretProviderRegistry(new
Config(false) {})) {
+ Assertions.assertTrue(fetchProviderList(registry).isEmpty());
+ }
+ }
+
+ @Test
+ public void testListsConfiguredProvider() throws Exception {
+ try (SecretProviderRegistry registry = registryWithMemoryProvider(null)) {
+ List<Map<String, Object>> providers = fetchProviderList(registry);
+ Assertions.assertEquals(1, providers.size());
+ Assertions.assertEquals("memory", providers.get(0).get("name"));
+ Assertions.assertEquals("memory", providers.get(0).get("type"));
+ Assertions.assertFalse(providers.get(0).containsKey("uri"));
+ }
+ }
+
+ @Test
+ public void testListsOptionalUri() throws Exception {
+ try (SecretProviderRegistry registry =
+ registryWithNamedProvider("vault", "https://vault.example.com")) {
+ List<Map<String, Object>> providers = fetchProviderList(registry);
+ Assertions.assertEquals(1, providers.size());
+ Assertions.assertEquals("vault", providers.get(0).get("name"));
+ Assertions.assertEquals("memory", providers.get(0).get("type"));
+ Assertions.assertEquals("https://vault.example.com",
providers.get(0).get("uri"));
+ }
+ }
+
+ @Test
+ public void testHandlesIOException() throws Exception {
+ try (SecretProviderRegistry registry = new SecretProviderRegistry(new
Config(false) {})) {
+ SecretProvidersConfigServlet servlet = new
SecretProvidersConfigServlet(registry);
+ servlet.init();
+ HttpServletResponse res = mock(HttpServletResponse.class);
+ PrintWriter writer = mock(PrintWriter.class);
+ when(res.getWriter()).thenReturn(writer);
+ doThrow(new IOException("Test IO
error")).when(writer).write(any(String.class));
+
+ assertDoesNotThrow(() -> servlet.doGet(null, res));
+ verify(res).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ servlet.destroy();
+ }
+ }
+
+ private static SecretProviderRegistry registryWithMemoryProvider(String uri)
{
+ return registryWithNamedProvider("memory", uri);
+ }
+
+ private static SecretProviderRegistry registryWithNamedProvider(String name,
String uri) {
+ Config config = new Config(false) {};
+ Properties properties = new Properties();
+ properties.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
name);
+ properties.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + name
+ + "."
+ + SecretProviderRegistry.CLASS_NAME,
+ InMemorySecretsProvider.class.getName());
+ if (uri != null) {
+ properties.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + name
+ + "."
+ + SecretProviderRegistry.URI,
+ uri);
+ }
+ config.loadFromProperties(properties);
+ return new SecretProviderRegistry(config);
+ }
+
+ private Map<String, Object> fetchProviders(SecretProviderRegistry registry)
throws Exception {
+ SecretProvidersConfigServlet servlet = new
SecretProvidersConfigServlet(registry);
+ servlet.init();
+ HttpServletResponse res = mock(HttpServletResponse.class);
+ PrintWriter writer = mock(PrintWriter.class);
+ when(res.getWriter()).thenReturn(writer);
+ servlet.doGet(null, res);
+ ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
+ verify(writer).write(captor.capture());
+ servlet.destroy();
+ return ObjectMapperProvider.objectMapper()
+ .readValue(captor.getValue(), new TypeReference<Map<String, Object>>()
{});
+ }
+
+ private List<Map<String, Object>> fetchProviderList(SecretProviderRegistry
registry)
+ throws Exception {
+ Map<String, Object> body = fetchProviders(registry);
+ return ObjectMapperProvider.objectMapper()
+ .convertValue(body.get("providers"), new
TypeReference<List<Map<String, Object>>>() {});
+ }
+}