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 96f257ecd2 [#12921] fix(server): Authenticate secrets-provider 
discovery under metalake (#12940)
96f257ecd2 is described below

commit 96f257ecd2fb3c433b130371817bb7fbee141e10
Author: MaSai <[email protected]>
AuthorDate: Wed Sep 9 14:05:15 2026 +0800

    [#12921] fix(server): Authenticate secrets-provider discovery under 
metalake (#12940)
    
    ### What changes were proposed in this pull request?
    
    Replace anonymous `GET /configs/secrets/providers` with an authenticated
    metalake-scoped API:
    
    - `GET /api/metalakes/{metalake}/secrets/providers` via
    `SecretsProviderOperations`
    - Authorize with metalake `OWNER` or `VIEW_SECRET_PROVIDERS` (new
    metalake-bound privilege)
    - Remove the root `SecretProvidersConfigServlet` (no deprecated alias;
    the old path never shipped)
    - Response is `name` and `type` only (no `uri`)
    - OpenAPI and access-control docs updated
    
    The provider registry remains process-global server config; the metalake
    path scopes authorization only.
    
    Fix: #12921
    
    ### Why are the changes needed?
    
    The root servlet bypassed authentication and audit and returned
    infrastructure topology (`uri`) to unauthenticated callers. Discovery
    belongs under `/api`, behind metalake ownership or an explicit view
    privilege.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    - `GET /configs/secrets/providers` is removed
    - Clients must call `GET /api/metalakes/{metalake}/secrets/providers`
    with authentication
    - When authorization is enabled, callers need metalake ownership or
    `VIEW_SECRET_PROVIDERS`
    - The JSON body no longer includes `uri`
    
    ### How was this patch tested?
    
    - Unit tests for the REST resource, privilege binding, authorization
    expression, interceptor path, and in-process server (including audit and
    404 on the old path)
    - `./gradlew :docs:lintOpenAPI` / docs build as applicable
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../apache/gravitino/authorization/Privilege.java  |   4 +-
 .../apache/gravitino/authorization/Privileges.java |  40 ++++
 .../authorization/TestSecurableObjects.java        |  10 +
 .../dto/responses/SecretProviderListResponse.java  |  74 ++++++++
 .../gravitino/dto/secret/SecretProviderDTO.java    |  44 +++++
 .../gravitino/dto/responses/TestResponses.java     |  24 +++
 .../gravitino/catalog/TestCatalogWrapperLease.java |  10 +-
 docs/open-api/openapi.yaml                         |   3 +
 docs/open-api/roles.yaml                           |   5 +
 docs/open-api/secrets.yaml                         |  84 +++++++++
 docs/security/access-control.md                    |  92 +++++-----
 .../apache/gravitino/server/GravitinoServer.java   |  13 +-
 .../server/web/SecretProvidersConfigServlet.java   |  89 ---------
 .../web/filter/GravitinoInterceptionService.java   |   2 +
 .../server/web/rest/SecretsProviderOperations.java | 114 ++++++++++++
 .../gravitino/server/TestGravitinoServer.java      |  94 +++++++++-
 .../web/TestSecretProvidersConfigServlet.java      | 136 --------------
 .../filter/TestGravitinoInterceptionService.java   |  10 +
 .../web/rest/TestSecretsProviderOperations.java    | 202 +++++++++++++++++++++
 .../MockAuthorizationExpressionEvaluator.java      |   4 +
 ...TestSecretsProviderAuthorizationExpression.java |  50 +++++
 21 files changed, 821 insertions(+), 283 deletions(-)

diff --git 
a/api/src/main/java/org/apache/gravitino/authorization/Privilege.java 
b/api/src/main/java/org/apache/gravitino/authorization/Privilege.java
index b004909913..676cf99ac8 100644
--- a/api/src/main/java/org/apache/gravitino/authorization/Privilege.java
+++ b/api/src/main/java/org/apache/gravitino/authorization/Privilege.java
@@ -157,7 +157,9 @@ public interface Privilege {
     /** The privilege to view a tag. */
     VIEW_TAG(0L, 1L << 34),
     /** The privilege to view a policy. */
-    VIEW_POLICY(0L, 1L << 35);
+    VIEW_POLICY(0L, 1L << 35),
+    /** The privilege to list configured secrets providers. */
+    VIEW_SECRET_PROVIDERS(0L, 1L << 36);
 
     private final long highBits;
     private final long lowBits;
diff --git 
a/api/src/main/java/org/apache/gravitino/authorization/Privileges.java 
b/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
index abe1c3f88f..2ca291c199 100644
--- a/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
+++ b/api/src/main/java/org/apache/gravitino/authorization/Privileges.java
@@ -195,6 +195,10 @@ public class Privileges {
       case CREATE_POLICY:
         return CreatePolicy.allow();
 
+        // Secrets
+      case VIEW_SECRET_PROVIDERS:
+        return ViewSecretProviders.allow();
+
         // Job template
       case REGISTER_JOB_TEMPLATE:
         return RegisterJobTemplate.allow();
@@ -323,6 +327,10 @@ public class Privileges {
       case CREATE_POLICY:
         return CreatePolicy.deny();
 
+        // Secrets
+      case VIEW_SECRET_PROVIDERS:
+        return ViewSecretProviders.deny();
+
         // Job template
       case REGISTER_JOB_TEMPLATE:
         return RegisterJobTemplate.deny();
@@ -1356,6 +1364,38 @@ public class Privileges {
     }
   }
 
+  /** The privilege to list configured secrets providers. */
+  public static final class ViewSecretProviders extends 
GenericPrivilege<ViewSecretProviders> {
+
+    private static final ViewSecretProviders ALLOW_INSTANCE =
+        new ViewSecretProviders(Condition.ALLOW, Name.VIEW_SECRET_PROVIDERS);
+    private static final ViewSecretProviders DENY_INSTANCE =
+        new ViewSecretProviders(Condition.DENY, Name.VIEW_SECRET_PROVIDERS);
+
+    private ViewSecretProviders(Condition condition, Name name) {
+      super(condition, name);
+    }
+
+    /**
+     * @return The instance with allow condition of the privilege.
+     */
+    public static ViewSecretProviders allow() {
+      return ALLOW_INSTANCE;
+    }
+
+    /**
+     * @return The instance with deny condition of the privilege.
+     */
+    public static ViewSecretProviders deny() {
+      return DENY_INSTANCE;
+    }
+
+    @Override
+    public boolean canBindTo(MetadataObject.Type type) {
+      return type == MetadataObject.Type.METALAKE;
+    }
+  }
+
   /** The privilege to register a job template. */
   public static class RegisterJobTemplate extends 
GenericPrivilege<RegisterJobTemplate> {
     private static final RegisterJobTemplate ALLOW_INSTANCE =
diff --git 
a/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
 
b/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
index 5463a4c3bd..f17d2e4d11 100644
--- 
a/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
+++ 
b/api/src/test/java/org/apache/gravitino/authorization/TestSecurableObjects.java
@@ -199,6 +199,7 @@ public class TestSecurableObjects {
     Privilege manageUsers = Privileges.ManageUsers.allow();
     Privilege manageGroups = Privileges.ManageGroups.allow();
     Privilege manageGrants = Privileges.ManageGrants.allow();
+    Privilege viewSecretProviders = Privileges.ViewSecretProviders.allow();
     Privilege createModel = Privileges.RegisterModel.allow();
     Privilege createModelVersion = Privileges.LinkModelVersion.allow();
     Privilege useModel = Privileges.UseModel.allow();
@@ -374,6 +375,15 @@ public class TestSecurableObjects {
     Assertions.assertFalse(manageUsers.canBindTo(MetadataObject.Type.ROLE));
     Assertions.assertFalse(manageUsers.canBindTo(MetadataObject.Type.COLUMN));
 
+    
Assertions.assertTrue(viewSecretProviders.canBindTo(MetadataObject.Type.METALAKE));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.CATALOG));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.SCHEMA));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.TABLE));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.TOPIC));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.FILESET));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.ROLE));
+    
Assertions.assertFalse(viewSecretProviders.canBindTo(MetadataObject.Type.COLUMN));
+
     // Test manager groups
     
Assertions.assertTrue(manageGroups.canBindTo(MetadataObject.Type.METALAKE));
     
Assertions.assertFalse(manageGroups.canBindTo(MetadataObject.Type.CATALOG));
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/SecretProviderListResponse.java
 
b/common/src/main/java/org/apache/gravitino/dto/responses/SecretProviderListResponse.java
new file mode 100644
index 0000000000..90758fc295
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/SecretProviderListResponse.java
@@ -0,0 +1,74 @@
+/*
+ * 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.dto.responses;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.dto.secret.SecretProviderDTO;
+
+/** Represents a response for a list of secrets providers. */
+@Getter
+@ToString
+@EqualsAndHashCode(callSuper = true)
+public class SecretProviderListResponse extends BaseResponse {
+
+  @JsonProperty("providers")
+  private final SecretProviderDTO[] providers;
+
+  /**
+   * Constructor for SecretProviderListResponse.
+   *
+   * @param providers The array of secrets-provider DTOs.
+   */
+  public SecretProviderListResponse(SecretProviderDTO[] providers) {
+    super(0);
+    this.providers = providers;
+  }
+
+  /** Default constructor for Jackson deserialization. */
+  public SecretProviderListResponse() {
+    super();
+    this.providers = null;
+  }
+
+  /**
+   * Validates the response data.
+   *
+   * @throws IllegalArgumentException if providers is null or contains a blank 
name or type.
+   */
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+
+    Preconditions.checkArgument(providers != null, "providers must not be 
null");
+    Arrays.stream(providers)
+        .forEach(
+            provider -> {
+              Preconditions.checkArgument(
+                  StringUtils.isNotBlank(provider.getName()), "provider 'name' 
must not be blank");
+              Preconditions.checkArgument(
+                  StringUtils.isNotBlank(provider.getType()), "provider 'type' 
must not be blank");
+            });
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/secret/SecretProviderDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/secret/SecretProviderDTO.java
new file mode 100644
index 0000000000..8aa42c56bc
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/secret/SecretProviderDTO.java
@@ -0,0 +1,44 @@
+/*
+ * 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.dto.secret;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+/** Discovery metadata for a configured secrets-provider instance. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor
+@Builder(setterPrefix = "with")
+@ToString
+public class SecretProviderDTO {
+
+  @JsonProperty("name")
+  private String name;
+
+  @JsonProperty("type")
+  private String type;
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
index 856ff1fa61..de286ac75a 100644
--- a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
+++ b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
@@ -50,6 +50,7 @@ import org.apache.gravitino.dto.model.ModelVersionDTO;
 import org.apache.gravitino.dto.rel.ColumnDTO;
 import org.apache.gravitino.dto.rel.TableDTO;
 import org.apache.gravitino.dto.rel.partitioning.Partitioning;
+import org.apache.gravitino.dto.secret.SecretProviderDTO;
 import org.apache.gravitino.dto.stats.PartitionStatisticsDTO;
 import org.apache.gravitino.dto.stats.StatisticDTO;
 import org.apache.gravitino.dto.tag.TagDTO;
@@ -549,6 +550,29 @@ public class TestResponses {
     assertFalse(response.isServiceAdmin());
   }
 
+  @Test
+  void testSecretProviderListResponse() throws JsonProcessingException {
+    SecretProviderListResponse response =
+        new SecretProviderListResponse(
+            new SecretProviderDTO[] {
+              
SecretProviderDTO.builder().withName("memory").withType("memory").build()
+            });
+    response.validate();
+    assertEquals(0, response.getCode());
+    assertEquals(1, response.getProviders().length);
+    assertEquals("memory", response.getProviders()[0].getName());
+    assertEquals("memory", response.getProviders()[0].getType());
+
+    String serJson = JsonUtils.objectMapper().writeValueAsString(response);
+    assertFalse(serJson.contains("uri"));
+    SecretProviderListResponse deserResponse =
+        JsonUtils.objectMapper().readValue(serJson, 
SecretProviderListResponse.class);
+    assertEquals(response, deserResponse);
+
+    SecretProviderListResponse empty = new SecretProviderListResponse();
+    assertThrows(IllegalArgumentException.class, empty::validate);
+  }
+
   @Test
   void testPartitionStatisticsListResponseNullElement() {
     AuditDTO audit = 
AuditDTO.builder().withCreator("user1").withCreateTime(Instant.now()).build();
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java
index a524795e5f..5bdfc09d5b 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java
@@ -616,8 +616,14 @@ public class TestCatalogWrapperLease {
 
     // The cache's removal listener retires the wrapper as well, 
asynchronously.
     Mockito.verify(failingWrapper, Mockito.atLeastOnce()).retire();
-    Assertions.assertEquals(
-        0, pool.size(), "a failing retirement must not keep the ClassLoader 
pool open");
+    await()
+        .atMost(Duration.ofSeconds(10))
+        .untilAsserted(
+            () ->
+                Assertions.assertEquals(
+                    0,
+                    pool.size(),
+                    "a failing retirement must not keep the ClassLoader pool 
open"));
   }
 
   private Future<?> submitCloseAndAssertBlocked(ExecutorService executor)
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 70af40d2da..84b0d64e67 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -267,6 +267,9 @@ paths:
   /authn/me:
     $ref: "./authn.yaml#/paths/~1authn~1me"
 
+  /metalakes/{metalake}/secrets/providers:
+    $ref: 
"./secrets.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1secrets~1providers"
+
 components:
 
   schemas:
diff --git a/docs/open-api/roles.yaml b/docs/open-api/roles.yaml
index 888dc966ab..4bd1995720 100644
--- a/docs/open-api/roles.yaml
+++ b/docs/open-api/roles.yaml
@@ -220,11 +220,16 @@ components:
             - MODIFY_FUNCTION
             - CREATE_TAG
             - APPLY_TAG
+            - VIEW_TAG
             - CREATE_POLICY
             - APPLY_POLICY
+            - VIEW_POLICY
+            - VIEW_SECRET_PROVIDERS
             - REGISTER_JOB_TEMPLATE
             - USE_JOB_TEMPLATE
             - RUN_JOB
+            - CREATE_VIEW
+            - SELECT_VIEW
           description: The name of the privilege
         condition:
           type: string
diff --git a/docs/open-api/secrets.yaml b/docs/open-api/secrets.yaml
index dfea6a9041..de6b3f89fa 100644
--- a/docs/open-api/secrets.yaml
+++ b/docs/open-api/secrets.yaml
@@ -19,6 +19,53 @@
 
 paths:
 
+  /metalakes/{metalake}/secrets/providers:
+    parameters:
+      - $ref: "./openapi.yaml#/components/parameters/metalake"
+    get:
+      tags:
+        - secrets
+      summary: List secrets providers
+      operationId: listSecretProviders
+      description: >
+        Returns the process-global secrets-provider registry as discovery 
metadata.
+        The response contains each provider's configured name and type.
+        The registry itself is server-wide; the metalake path scopes 
authorization.
+        Callers must be the metalake owner or hold VIEW_SECRET_PROVIDERS on 
the metalake.
+      responses:
+        "200":
+          description: Returns the configured secrets providers
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "#/components/schemas/SecretProviderListResponse"
+              examples:
+                SecretProviderListResponse:
+                  $ref: "#/components/examples/SecretProviderListResponse"
+        "401":
+          description: Unauthorized - Authentication credentials are missing 
or invalid
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+        "403":
+          description: Forbidden - The caller is not the metalake owner and 
lacks VIEW_SECRET_PROVIDERS
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+        "404":
+          description: Not Found - The specified metalake does not exist or is 
not in use
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                NoSuchMetalakeException:
+                  $ref: 
"./metalakes.yaml#/components/examples/NoSuchMetalakeException"
+        "5xx":
+          $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+
   
/metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/secrets:
     parameters:
       - $ref: "./openapi.yaml#/components/parameters/metalake"
@@ -99,6 +146,33 @@ components:
             type: string
           minProperties: 1
 
+    SecretProvider:
+      type: object
+      required:
+        - name
+        - type
+      properties:
+        name:
+          type: string
+          description: The configured secrets-provider instance name
+        type:
+          type: string
+          description: The secrets-provider type identifier
+
+    SecretProviderListResponse:
+      type: object
+      properties:
+        code:
+          type: integer
+          format: int32
+          description: Status code of the response
+          enum:
+            - 0
+        providers:
+          type: array
+          items:
+            $ref: "#/components/schemas/SecretProvider"
+
   responses:
     SecretsResponse:
       type: object
@@ -116,6 +190,16 @@ components:
             type: string
 
   examples:
+    SecretProviderListResponse:
+      value: {
+        "code": 0,
+        "providers": [
+          {
+            "name": "memory",
+            "type": "memory"
+          }
+        ]
+      }
     SecretsResponse:
       value: {
         "code": 0,
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index 80bb901c36..9e0b118627 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -210,6 +210,7 @@ they will be removed in a future release. Use the current 
names in new roles.
 | `APPLY_TAG`             | Metalake, Tag                                      
                     | Attach tags to metadata objects                    |
 | `CREATE_POLICY`         | Metalake                                           
                     | Create policies                                    |
 | `APPLY_POLICY`          | Metalake, Policy                                   
                     | Attach policies to metadata objects                |
+| `VIEW_SECRET_PROVIDERS` | Metalake                                           
                     | List configured secrets providers                  |
 | `REGISTER_JOB_TEMPLATE` | Metalake                                           
                     | Register job templates                             |
 | `USE_JOB_TEMPLATE`      | Metalake, JobTemplate                              
                     | Run jobs from a job template                       |
 | `RUN_JOB`               | Metalake                                           
                     | Run jobs                                           |
@@ -241,15 +242,15 @@ return only the entries the caller is entitled to see, 
which for a metalake owne
 
 #### Data Objects
 
-| Object   | Create              | Load                                 | 
Alter             | Drop  |
-|----------|---------------------|--------------------------------------|-------------------|-------|
-| Catalog  | `CREATE_CATALOG`    | `USE_CATALOG`                        | 
Owner             | Owner |
-| Schema   | `CREATE_SCHEMA`     | `USE_SCHEMA`                         | 
Owner             | Owner |
-| Table    | `CREATE_TABLE`      | `SELECT_TABLE` or `MODIFY_TABLE`     | 
`MODIFY_TABLE`    | Owner |
-| View     | `CREATE_VIEW`       | `SELECT_VIEW`                        | 
Owner             | Owner |
-| Topic    | `CREATE_TOPIC`      | `CONSUME_TOPIC` or `PRODUCE_TOPIC`   | 
`PRODUCE_TOPIC`   | Owner |
-| Fileset  | `CREATE_FILESET`    | `READ_FILESET` or `WRITE_FILESET`    | 
`WRITE_FILESET`   | Owner |
-| Model    | `REGISTER_MODEL`    | `USE_MODEL`                          | 
Owner             | Owner |
+| Object   | Create              | Load                                    | 
Alter             | Drop  |
+|----------|---------------------|-----------------------------------------|-------------------|-------|
+| Catalog  | `CREATE_CATALOG`    | `USE_CATALOG`                           | 
Owner             | Owner |
+| Schema   | `CREATE_SCHEMA`     | `USE_SCHEMA`                            | 
Owner             | Owner |
+| Table    | `CREATE_TABLE`      | `SELECT_TABLE` or `MODIFY_TABLE`        | 
`MODIFY_TABLE`    | Owner |
+| View     | `CREATE_VIEW`       | `SELECT_VIEW`                           | 
Owner             | Owner |
+| Topic    | `CREATE_TOPIC`      | `CONSUME_TOPIC` or `PRODUCE_TOPIC`      | 
`PRODUCE_TOPIC`   | Owner |
+| Fileset  | `CREATE_FILESET`    | `READ_FILESET` or `WRITE_FILESET`       | 
`WRITE_FILESET`   | Owner |
+| Model    | `REGISTER_MODEL`    | `USE_MODEL`                             | 
Owner             | Owner |
 | Function | `REGISTER_FUNCTION` | `EXECUTE_FUNCTION` or `MODIFY_FUNCTION` | 
`MODIFY_FUNCTION` | Owner |
 
 Table statistics follow the table itself: reading them takes `SELECT_TABLE` or 
`MODIFY_TABLE`,
@@ -272,30 +273,35 @@ owner-only; it does not accept a target schema.
 
 #### Metalake Objects
 
-| Object       | Create                  | Read                                
   | Alter or delete | Use                                       |
-|--------------|-------------------------|----------------------------------------|-----------------|-------------------------------------------|
-| Metalake     | Service administrator   | Membership                          
   | Owner           |                                           |
-| User         | `MANAGE_USERS`          | `MANAGE_USERS`, or the user 
themselves | `MANAGE_USERS`  |                                           |
-| Group        | `MANAGE_GROUPS`         | `MANAGE_GROUPS`, or a member        
   | `MANAGE_GROUPS` |                                           |
-| Role         | `CREATE_ROLE`           | `MANAGE_GRANTS`, or a holder or 
owner  | Owner           | Grant or revoke: `MANAGE_GRANTS`          |
-| Tag          | `CREATE_TAG`            | `APPLY_TAG`                         
   | Owner           | Attach: `APPLY_TAG` and access to the object |
-| Policy       | `CREATE_POLICY`         | `APPLY_POLICY`                      
   | Owner           | Attach: `APPLY_POLICY` and access to the object |
-| Job template | `REGISTER_JOB_TEMPLATE` | `USE_JOB_TEMPLATE`                  
   | Owner           | Run a job: `RUN_JOB` and `USE_JOB_TEMPLATE` |
-| Job          |                         | Owner                               
   | Owner           |                                           |
+| Object           | Create                  | Read                            
       | Alter or delete | Use                                             |
+|------------------|-------------------------|----------------------------------------|-----------------|-------------------------------------------------|
+| Metalake         | Service administrator   | Membership                      
       | Owner           |                                                 |
+| User             | `MANAGE_USERS`          | `MANAGE_USERS`, or the user 
themselves | `MANAGE_USERS`  |                                                 |
+| Group            | `MANAGE_GROUPS`         | `MANAGE_GROUPS`, or a member    
       | `MANAGE_GROUPS` |                                                 |
+| Role             | `CREATE_ROLE`           | `MANAGE_GRANTS`, or a holder or 
owner  | Owner           | Grant or revoke: `MANAGE_GRANTS`                |
+| Tag              | `CREATE_TAG`            | `APPLY_TAG`                     
       | Owner           | Attach: `APPLY_TAG` and access to the object    |
+| Policy           | `CREATE_POLICY`         | `APPLY_POLICY`                  
       | Owner           | Attach: `APPLY_POLICY` and access to the object |
+| Job template     | `REGISTER_JOB_TEMPLATE` | `USE_JOB_TEMPLATE`              
       | Owner           | Run a job: `RUN_JOB` and `USE_JOB_TEMPLATE`     |
+| Job              |                         | Owner                           
       | Owner           |                                                 |
+| Secret providers |                         | Owner or 
`VIEW_SECRET_PROVIDERS`       |                 |                               
                  |
+
+The secrets-provider registry is process-global server configuration; the 
metalake path only scopes
+authorization. Listing providers does not return secret material.
 
 Bulk access-control APIs use the same privileges as the matching single-entity 
operations. Most
 bulk operations are authorized once before processing the request. Role 
removal is authorized per
 item because each role can be removed by the metalake owner or by the owner of 
that role. Bulk
 requests report item-level failures in `errors`.
 
-| API                                                 | Required privilege     
                              |
-|-----------------------------------------------------|------------------------------------------------------|
-| `POST /api/bulk/metalakes/{metalake}/users/add`     | `OWNER` of the 
metalake or `MANAGE_USERS`            |
-| `POST /api/bulk/metalakes/{metalake}/users/remove`  | `OWNER` of the 
metalake or `MANAGE_USERS`            |
-| `POST /api/bulk/metalakes/{metalake}/groups/add`    | `OWNER` of the 
metalake or `MANAGE_GROUPS`           |
-| `POST /api/bulk/metalakes/{metalake}/groups/remove` | `OWNER` of the 
metalake or `MANAGE_GROUPS`           |
-| `POST /api/bulk/metalakes/{metalake}/roles/add`     | `OWNER` of the 
metalake or `CREATE_ROLE`             |
-| `POST /api/bulk/metalakes/{metalake}/roles/remove`  | `OWNER` of the 
metalake, or `OWNER` of the role      |
+| API                                                 | Required privilege     
                            |
+|-----------------------------------------------------|----------------------------------------------------|
+| `POST /api/bulk/metalakes/{metalake}/users/add`     | `OWNER` of the 
metalake or `MANAGE_USERS`          |
+| `POST /api/bulk/metalakes/{metalake}/users/remove`  | `OWNER` of the 
metalake or `MANAGE_USERS`          |
+| `POST /api/bulk/metalakes/{metalake}/groups/add`    | `OWNER` of the 
metalake or `MANAGE_GROUPS`         |
+| `POST /api/bulk/metalakes/{metalake}/groups/remove` | `OWNER` of the 
metalake or `MANAGE_GROUPS`         |
+| `POST /api/bulk/metalakes/{metalake}/roles/add`     | `OWNER` of the 
metalake or `CREATE_ROLE`           |
+| `POST /api/bulk/metalakes/{metalake}/roles/remove`  | `OWNER` of the 
metalake, or `OWNER` of the role    |
+| `GET /api/metalakes/{metalake}/secrets/providers`   | `OWNER` of the 
metalake or `VIEW_SECRET_PROVIDERS` |
 
 For example, add users in bulk:
 
@@ -422,9 +428,9 @@ as before.
 
 | Condition                                                          | 
Response          |
 
|--------------------------------------------------------------------|-------------------|
-| An empty entry, such as the trailing comma in `analyst,`             | `400 
Bad Request` |
-| `ALL` or `NONE` combined with anything else, such as `ALL,analyst`   | `400 
Bad Request` |
-| A well-formed value naming a role the caller does not hold           | `403 
Forbidden`   |
+| An empty entry, such as the trailing comma in `analyst,`           | `400 
Bad Request` |
+| `ALL` or `NONE` combined with anything else, such as `ALL,analyst` | `400 
Bad Request` |
+| A well-formed value naming a role the caller does not hold         | `403 
Forbidden`   |
 
 A role that does not exist and a role the caller was never granted both return 
`403`, so the response
 cannot be used to discover which role names exist. An unheld role is rejected 
rather than ignored,
@@ -701,10 +707,10 @@ schemas, see the [Gravitino REST 
API](https://gravitino.apache.org/docs/latest/a
 Users, groups, and roles share one shape. Substitute `users`, `groups`, or 
`roles` for
 `{collection}`, and the user, group, or role name for `{name}`:
 
-| Operation | Method   | Path                  |
-|-----------|----------|-----------------------|
-| Create    | `POST`   | `/{collection}`       |
-| List      | `GET`    | `/{collection}`       |
+| Operation | Method   | Path                   |
+|-----------|----------|------------------------|
+| Create    | `POST`   | `/{collection}`        |
+| List      | `GET`    | `/{collection}`        |
 | Get       | `GET`    | `/{collection}/{name}` |
 | Delete    | `DELETE` | `/{collection}/{name}` |
 
@@ -712,15 +718,15 @@ Add `?details=true` to a list path to get full objects 
instead of names.
 
 The rest are one of a kind:
 
-| Operation                          | Method       | Path                     
                                       |
-|------------------------------------|--------------|-----------------------------------------------------------------|
-| Grant privileges to a role         | `PUT`        | 
`/permissions/roles/{role}/{object_type}/{object_name}/grant`   |
-| Revoke privileges from a role      | `PUT`        | 
`/permissions/roles/{role}/{object_type}/{object_name}/revoke`  |
-| Replace a role's privileges        | `PUT`        | 
`/permissions/roles/{role}/`                                    |
-| Grant roles to a user or group     | `PUT`        | 
`/permissions/{collection}/{name}/grant`                        |
-| Revoke roles from a user or group  | `PUT`        | 
`/permissions/{collection}/{name}/revoke`                       |
-| List the roles bound to an object  | `GET`        | 
`/objects/{object_type}/{object_name}/roles`                    |
-| Get or set an object's owner       | `GET`, `PUT` | 
`/owners/{object_type}/{object_name}`                           |
+| Operation                         | Method       | Path                      
                                     |
+|-----------------------------------|--------------|----------------------------------------------------------------|
+| Grant privileges to a role        | `PUT`        | 
`/permissions/roles/{role}/{object_type}/{object_name}/grant`  |
+| Revoke privileges from a role     | `PUT`        | 
`/permissions/roles/{role}/{object_type}/{object_name}/revoke` |
+| Replace a role's privileges       | `PUT`        | 
`/permissions/roles/{role}/`                                   |
+| Grant roles to a user or group    | `PUT`        | 
`/permissions/{collection}/{name}/grant`                       |
+| Revoke roles from a user or group | `PUT`        | 
`/permissions/{collection}/{name}/revoke`                      |
+| List the roles bound to an object | `GET`        | 
`/objects/{object_type}/{object_name}/roles`                   |
+| Get or set an object's owner      | `GET`, `PUT` | 
`/owners/{object_type}/{object_name}`                          |
 
 Replacing a role's privileges is destructive: afterwards the role holds 
exactly what the request body
 contains, and any object absent from it is dropped.
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 628da89fd2..7898a158fe 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -50,6 +50,7 @@ import org.apache.gravitino.metrics.MetricsSystem;
 import org.apache.gravitino.metrics.source.MetricsSource;
 import org.apache.gravitino.policy.PolicyDispatcher;
 import org.apache.gravitino.secret.SecretPropertyOperationDispatcher;
+import org.apache.gravitino.secret.SecretProviderRegistry;
 import org.apache.gravitino.server.authentication.ServerAuthenticator;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import org.apache.gravitino.server.web.ConfigServlet;
@@ -60,7 +61,6 @@ 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;
@@ -96,7 +96,7 @@ public class GravitinoServer extends ResourceConfig {
   // JettyServer), outside GravitinoServer's own control entirely. See 
GH-12760.
   private static final ImmutableList<String> ROOT_MOUNTED_PATHS =
       ImmutableList.<String>builder()
-          .add("/configs", "/configs/secrets/providers")
+          .add("/configs")
           .addAll(JettyServer.METRICS_PATH_SPECS)
           .build();
 
@@ -178,6 +178,7 @@ public class GravitinoServer extends ResourceConfig {
             bind(gravitinoEnv.secretPropertyOperationDispatcher())
                 .to(SecretPropertyOperationDispatcher.class)
                 .ranked(1);
+            
bind(gravitinoEnv.secretProviderRegistry()).to(SecretProviderRegistry.class).ranked(1);
             
bind(gravitinoEnv.modelDispatcher()).to(ModelDispatcher.class).ranked(1);
             
bind(gravitinoEnv.functionDispatcher()).to(FunctionDispatcher.class).ranked(1);
             bind(lineageService).to(LineageDispatcher.class).ranked(1);
@@ -207,9 +208,6 @@ 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
@@ -249,9 +247,8 @@ public class GravitinoServer extends ResourceConfig {
     server.addCustomFilters(customFilterPaths.toArray(new String[0]));
 
     // Only API_ANY_PATH requires authentication today. /configs must stay 
open for the Web UI's
-    // pre-login OAuth bootstrap (see docs/gravitino-server-config.md); 
/configs/secrets/providers
-    // is open pending GH-12921, which will add an operator-controlled 
authorization gate for it
-    // specifically.
+    // pre-login OAuth bootstrap (see docs/gravitino-server-config.md). Secret 
provider discovery
+    // lives under /api/metalakes/{metalake}/secrets/providers (see GH-12921).
     server.addSystemFilters(API_ANY_PATH);
     if (server.isWebUiEnabled()) {
       server.addFilter(new WebUIFilter(), "/"); // Redirect to the /ui/index 
html page.
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
deleted file mode 100644
index 3fc41427f1..0000000000
--- 
a/server/src/main/java/org/apache/gravitino/server/web/SecretProvidersConfigServlet.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * 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/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 4220a79f20..acd9499fa8 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -75,6 +75,7 @@ import 
org.apache.gravitino.server.web.rest.PermissionOperations;
 import org.apache.gravitino.server.web.rest.PolicyOperations;
 import org.apache.gravitino.server.web.rest.RoleOperations;
 import org.apache.gravitino.server.web.rest.SchemaOperations;
+import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
 import org.apache.gravitino.server.web.rest.StatisticOperations;
 import org.apache.gravitino.server.web.rest.TableOperations;
 import org.apache.gravitino.server.web.rest.TagOperations;
@@ -123,6 +124,7 @@ public class GravitinoInterceptionService implements 
InterceptionService {
             JobOperations.class.getName(),
             MetadataObjectCredentialOperations.class.getName(),
             MetadataObjectSecretOperations.class.getName(),
+            SecretsProviderOperations.class.getName(),
             LineageOperations.class.getName()));
   }
 
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/SecretsProviderOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SecretsProviderOperations.java
new file mode 100644
index 0000000000..91c5ab9070
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SecretsProviderOperations.java
@@ -0,0 +1,114 @@
+/*
+ * 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.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import java.util.List;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.dto.responses.SecretProviderListResponse;
+import org.apache.gravitino.dto.secret.SecretProviderDTO;
+import org.apache.gravitino.metalake.MetalakeManager;
+import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.secret.SecretProviderInfo;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import org.apache.gravitino.server.web.Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Lists process-global secrets-provider discovery metadata under a metalake.
+ *
+ * <p>The provider registry is server configuration (not per-metalake state). 
The metalake path
+ * segment scopes authorization: callers need metalake ownership or {@code 
VIEW_SECRET_PROVIDERS}.
+ */
+@Path("metalakes/{metalake}/secrets")
+public class SecretsProviderOperations {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(SecretsProviderOperations.class);
+
+  private static final String LIST_SECRET_PROVIDERS_PRIVILEGE =
+      "METALAKE::OWNER || METALAKE::VIEW_SECRET_PROVIDERS";
+
+  private final SecretProviderRegistry secretProviderRegistry;
+
+  @Context private HttpServletRequest httpRequest;
+
+  /**
+   * Creates a secrets-provider REST resource.
+   *
+   * @param secretProviderRegistry the process-owned provider registry
+   */
+  @Inject
+  public SecretsProviderOperations(SecretProviderRegistry 
secretProviderRegistry) {
+    this.secretProviderRegistry = secretProviderRegistry;
+  }
+
+  /**
+   * Lists configured secrets providers.
+   *
+   * @param metalake the metalake used for authorization scoping
+   * @return a list of provider names and types
+   */
+  @GET
+  @Path("providers")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "list-secret-providers." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "list-secret-providers", absolute = true)
+  @AuthorizationExpression(
+      expression = LIST_SECRET_PROVIDERS_PRIVILEGE,
+      errorMessage = "Current user cannot list secrets providers")
+  public Response listSecretProviders(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake) {
+    LOG.info("Received list secrets providers request for metalake: {}", 
metalake);
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            MetalakeManager.checkMetalakeInUse(metalake);
+            List<SecretProviderInfo> infos = 
secretProviderRegistry.listProviders();
+            SecretProviderDTO[] providers =
+                infos.stream()
+                    .map(
+                        info ->
+                            SecretProviderDTO.builder()
+                                .withName(info.name())
+                                .withType(info.type())
+                                .build())
+                    .toArray(SecretProviderDTO[]::new);
+            Response response = Utils.ok(new 
SecretProviderListResponse(providers));
+            LOG.info("Listed {} secrets providers for metalake: {}", 
providers.length, metalake);
+            return response;
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleMetalakeException(OperationType.LIST, 
metalake, 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 12e8eb68d4..2ad3f616ef 100644
--- a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
+++ b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
@@ -40,6 +40,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import org.apache.commons.io.FileUtils;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
@@ -95,7 +96,6 @@ public class TestGravitinoServer {
       ImmutableSet.<String>builder()
           .addAll(STATIC_AND_FORWARDING_PATHS)
           .add("/configs") // Intentionally public: backs the Web UI's 
pre-login OAuth bootstrap.
-          .add("/configs/secrets/providers") // Open pending GH-12921 (tracked 
authz gate).
           .addAll(JettyServer.METRICS_PATH_SPECS) // Conventionally scraped 
without credentials.
           .build();
 
@@ -212,10 +212,54 @@ public class TestGravitinoServer {
     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("uri"));
     assertFalse(providers.get(0).containsKey("className"));
   }
 
+  @Test
+  public void testSecretProvidersOldPathRemoved() throws Exception {
+    gravitinoServer.initialize();
+    gravitinoServer.start();
+
+    int port =
+        JettyServerConfig.fromConfig(spyServerConfig, 
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(404, response.statusCode());
+  }
+
+  @Test
+  public void testSecretProvidersRequestIsAudited() throws Exception {
+    // GH-12921 moved discovery under 
/api/metalakes/{metalake}/secrets/providers so it inherits
+    // the /api/* HttpAuditFilter (and AuthenticationFilter) binding. 
Capturing live EventBus
+    // traffic here is flaky under the shared GravitinoEnv singleton used by 
this suite (CI saw
+    // empty captures or create-metalake HttpRequestEvent noise even when GET 
returned 200).
+    // Assert the concrete path is covered; HttpAuditFilter emission itself is 
covered by
+    // TestHttpAuditFilter / HttpAuditFilterIT.
+    gravitinoServer.initialize();
+    gravitinoServer.start();
+
+    ServletHandler servletHandler = 
getServletContextHandler(gravitinoServer).getServletHandler();
+    Set<String> auditedPathSpecs =
+        JettyServerTestUtils.filterPathSpecsFor(servletHandler, 
HttpAuditFilter.class);
+    assertTrue(
+        auditedPathSpecs.stream()
+            .anyMatch(
+                spec ->
+                    new 
ServletPathSpec(spec).matches("/api/metalakes/test_ml/secrets/providers")),
+        "GET /api/metalakes/{metalake}/secrets/providers must be covered by 
HttpAuditFilter");
+
+    List<Map<String, Object>> providers = 
fetchSecretProviders(spyServerConfig);
+    assertTrue(providers.isEmpty());
+  }
+
   @Test
   public void testEveryServletPathIsCoveredByAuditFilter() throws Exception {
     gravitinoServer.initialize();
@@ -319,6 +363,16 @@ public class TestGravitinoServer {
     return serverConfig;
   }
 
+  private static ServerConfig serverConfigWithAvailablePort() 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)));
+    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))
@@ -328,6 +382,32 @@ public class TestGravitinoServer {
 
   private static List<Map<String, Object>> fetchSecretProviders(ServerConfig 
serverConfig)
       throws Exception {
+    return fetchSecretProviders(serverConfig, 
createMetalakeForSecretProviders(serverConfig));
+  }
+
+  private static String createMetalakeForSecretProviders(ServerConfig 
serverConfig)
+      throws Exception {
+    int port =
+        JettyServerConfig.fromConfig(serverConfig, 
GravitinoServer.WEBSERVER_CONF_PREFIX)
+            .getHttpPort();
+    String metalake = "secret_providers_" + 
UUID.randomUUID().toString().replace("-", "");
+    HttpResponse<String> createResponse =
+        HttpClient.newHttpClient()
+            .send(
+                HttpRequest.newBuilder(URI.create("http://127.0.0.1:"; + port + 
"/api/metalakes"))
+                    .header("Accept", "application/vnd.gravitino.v1+json")
+                    .header("Content-Type", "application/json")
+                    .POST(
+                        HttpRequest.BodyPublishers.ofString(
+                            "{\"name\":\"" + metalake + 
"\",\"comment\":\"\",\"properties\":{}}"))
+                    .build(),
+                HttpResponse.BodyHandlers.ofString());
+    assertEquals(200, createResponse.statusCode(), createResponse.body());
+    return metalake;
+  }
+
+  private static List<Map<String, Object>> fetchSecretProviders(
+      ServerConfig serverConfig, String metalake) throws Exception {
     int port =
         JettyServerConfig.fromConfig(serverConfig, 
GravitinoServer.WEBSERVER_CONF_PREFIX)
             .getHttpPort();
@@ -335,11 +415,17 @@ public class TestGravitinoServer {
         HttpClient.newHttpClient()
             .send(
                 HttpRequest.newBuilder(
-                        URI.create("http://127.0.0.1:"; + port + 
"/configs/secrets/providers"))
+                        URI.create(
+                            "http://127.0.0.1:";
+                                + port
+                                + "/api/metalakes/"
+                                + metalake
+                                + "/secrets/providers"))
+                    .header("Accept", "application/vnd.gravitino.v1+json")
                     .GET()
                     .build(),
                 HttpResponse.BodyHandlers.ofString());
-    assertEquals(200, response.statusCode());
+    assertEquals(200, response.statusCode(), response.body());
     Map<String, Object> body =
         ObjectMapperProvider.objectMapper()
             .readValue(response.body(), new TypeReference<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
deleted file mode 100644
index 403fd6d9d1..0000000000
--- 
a/server/src/test/java/org/apache/gravitino/server/web/TestSecretProvidersConfigServlet.java
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * 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>>>() {});
-  }
-}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 0e06ba92dd..bedec592e1 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -70,6 +70,7 @@ import 
org.apache.gravitino.server.authorization.annotations.AuthorizationReques
 import org.apache.gravitino.server.web.Utils;
 import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
 import org.apache.gravitino.server.web.rest.SchemaOperations;
+import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
 import org.apache.gravitino.server.web.rest.ViewOperations;
 import org.apache.gravitino.tag.TagDispatcher;
 import org.apache.gravitino.utils.PrincipalUtils;
@@ -134,6 +135,15 @@ public class TestGravitinoInterceptionService {
         new 
GravitinoInterceptionService().getDescriptorFilter().matches(descriptor));
   }
 
+  @Test
+  public void testSecretsProviderOperationsIsRegisteredForInterception() {
+    Descriptor descriptor = mock(Descriptor.class);
+    
when(descriptor.getImplementation()).thenReturn(SecretsProviderOperations.class.getName());
+
+    Assertions.assertTrue(
+        new 
GravitinoInterceptionService().getDescriptorFilter().matches(descriptor));
+  }
+
   @Test
   public void testDeniedListViewsDoesNotReachDispatcher() throws Throwable {
     try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSecretsProviderOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSecretsProviderOperations.java
new file mode 100644
index 0000000000..62411ee07e
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSecretsProviderOperations.java
@@ -0,0 +1,202 @@
+/*
+ * 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.rest;
+
+import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
+import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
+import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.connector.PropertiesMetadata;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.SecretProviderListResponse;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.lock.LockManager;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.secret.SecretProviderInfo;
+import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.apache.gravitino.server.web.mapper.WebApplicationExceptionMapper;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestSecretsProviderOperations extends BaseOperationsTest {
+
+  private static final String METALAKE = "test_metalake";
+
+  private static final EntityStore entityStore = mock(EntityStore.class);
+
+  private static class MockServletRequestFactory extends 
ServletRequestFactoryBase {
+    @Override
+    public HttpServletRequest get() {
+      return mock(HttpServletRequest.class);
+    }
+  }
+
+  private final SecretProviderRegistry secretProviderRegistry = 
mock(SecretProviderRegistry.class);
+
+  @BeforeAll
+  public static void setup() throws IllegalAccessException {
+    Config config = mock(Config.class);
+    Mockito.doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
+    Mockito.doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
+    Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "entityStore", 
entityStore, true);
+  }
+
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    ResourceConfig resourceConfig = new ResourceConfig();
+    resourceConfig.register(SecretsProviderOperations.class);
+    resourceConfig.register(WebApplicationExceptionMapper.class);
+    resourceConfig.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            
bind(secretProviderRegistry).to(SecretProviderRegistry.class).ranked(2);
+            
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class);
+          }
+        });
+    return resourceConfig;
+  }
+
+  @Test
+  public void testListSecretProvidersEmpty() throws IOException {
+    when(secretProviderRegistry.listProviders()).thenReturn(List.of());
+    stubMetalakeInUse();
+
+    Response response =
+        target("/metalakes")
+            .path(METALAKE)
+            .path("secrets/providers")
+            .request()
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    SecretProviderListResponse body = 
response.readEntity(SecretProviderListResponse.class);
+    Assertions.assertEquals(0, body.getCode());
+    Assertions.assertEquals(0, body.getProviders().length);
+  }
+
+  @Test
+  public void testListSecretProvidersOmitsUri() throws IOException {
+    when(secretProviderRegistry.listProviders())
+        .thenReturn(List.of(new SecretProviderInfo("vault", "vault", 
"https://vault.example.com";)));
+    stubMetalakeInUse();
+
+    Response response =
+        target("/metalakes")
+            .path(METALAKE)
+            .path("secrets/providers")
+            .request()
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    String json = response.readEntity(String.class);
+    Assertions.assertFalse(json.contains("uri"));
+    Assertions.assertFalse(json.contains("vault.example.com"));
+
+    SecretProviderListResponse body =
+        target("/metalakes")
+            .path(METALAKE)
+            .path("secrets/providers")
+            .request()
+            .accept("application/vnd.gravitino.v1+json")
+            .get()
+            .readEntity(SecretProviderListResponse.class);
+    Assertions.assertEquals(1, body.getProviders().length);
+    Assertions.assertEquals("vault", body.getProviders()[0].getName());
+    Assertions.assertEquals("vault", body.getProviders()[0].getType());
+  }
+
+  @Test
+  public void testListSecretProvidersNoSuchMetalake() throws IOException {
+    doThrow(new NoSuchEntityException("mock 
error")).when(entityStore).get(any(), any(), any());
+
+    Response response =
+        target("/metalakes")
+            .path(METALAKE)
+            .path("secrets/providers")
+            .request()
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+    ErrorResponse error = response.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, error.getCode());
+    Assertions.assertEquals(NoSuchMetalakeException.class.getSimpleName(), 
error.getType());
+  }
+
+  @Test
+  public void testUnsupportedMethodReturnsErrorResponse() {
+    Response response =
+        target("/metalakes")
+            .path(METALAKE)
+            .path("secrets/providers")
+            .request()
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity("{}", MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(
+        Response.Status.METHOD_NOT_ALLOWED.getStatusCode(), 
response.getStatus());
+    ErrorResponse error = response.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.UNSUPPORTED_OPERATION_CODE, 
error.getCode());
+  }
+
+  private static void stubMetalakeInUse() throws IOException {
+    Mockito.reset(entityStore);
+    BaseMetalake metalake = mock(BaseMetalake.class);
+    PropertiesMetadata propertiesMetadata = mock(PropertiesMetadata.class);
+    when(propertiesMetadata.getOrDefault(any(), any())).thenReturn(true);
+    when(metalake.propertiesMetadata()).thenReturn(propertiesMetadata);
+    when(entityStore.get(any(), any(), any())).thenReturn(metalake);
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/MockAuthorizationExpressionEvaluator.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/MockAuthorizationExpressionEvaluator.java
index e782f1dadb..fcabf48393 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/MockAuthorizationExpressionEvaluator.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/MockAuthorizationExpressionEvaluator.java
@@ -70,5 +70,9 @@ public class MockAuthorizationExpressionEvaluator {
     public boolean authorize(String metadataPrivilege) {
       return mockPrivilege.contains(metadataPrivilege);
     }
+
+    public boolean isServiceAdmin() {
+      return mockPrivilege.contains("SERVICE_ADMIN");
+    }
   }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestSecretsProviderAuthorizationExpression.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestSecretsProviderAuthorizationExpression.java
new file mode 100644
index 0000000000..00eabe6d75
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestSecretsProviderAuthorizationExpression.java
@@ -0,0 +1,50 @@
+/*
+ * 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.rest.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Method;
+import ognl.OgnlException;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
+import org.junit.jupiter.api.Test;
+
+public class TestSecretsProviderAuthorizationExpression {
+
+  @Test
+  public void testListSecretProvidersRequiresOwnerOrViewPrivilege()
+      throws NoSuchMethodException, OgnlException {
+    Method method = 
SecretsProviderOperations.class.getMethod("listSecretProviders", String.class);
+    AuthorizationExpression authorizationExpressionAnnotation =
+        method.getAnnotation(AuthorizationExpression.class);
+    MockAuthorizationExpressionEvaluator mockEvaluator =
+        new 
MockAuthorizationExpressionEvaluator(authorizationExpressionAnnotation.expression());
+    assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+    assertFalse(mockEvaluator.getResult(ImmutableSet.of("SERVICE_ADMIN")));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::MANAGE_USERS")));
+    assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+    
assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::VIEW_SECRET_PROVIDERS")));
+    assertTrue(
+        mockEvaluator.getResult(
+            ImmutableSet.of("METALAKE::OWNER", 
"METALAKE::VIEW_SECRET_PROVIDERS")));
+  }
+}

Reply via email to