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>>>() {});
+  }
+}

Reply via email to