This is an automated email from the ASF dual-hosted git repository.

jerryshao 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 bb9a7e5e42 [#13085] improvement(server): Allow catalog users to test 
existing catalog connections with stored configuration (#13086)
bb9a7e5e42 is described below

commit bb9a7e5e4230546f225bea80184d7bd382ecdd33
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Sep 11 16:24:26 2026 +0800

    [#13085] improvement(server): Allow catalog users to test existing catalog 
connections with stored configuration (#13086)
    
    ### What changes were proposed in this pull request?
    
    - Authorize `testExistingConnection` with the load-catalog expression.
    - Add `CatalogConnectionTestAuthorizationExecutor` (request type
    `TEST_CATALOG_CONNECTION`). When the request carries non-empty
    `updates`, it switches to the annotation's secondary expression, which
    requires metalake or catalog ownership
    (`TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION`,
    condition `HAS_PROPOSED_CHANGES`).
    - In `GravitinoInterceptionService`, report the expression the executor
    evaluated (recorded in `AuthorizationRequestContext`) in the
    authorization denial event and log, falling back to the annotation
    expression. Before this change, a denial by a secondary expression was
    reported with the primary expression. This applies to every
    executor-based endpoint; endpoints that evaluate only the annotation
    expression report the same value as before.
    - Document the rule in `docs/security/access-control.md` and declare the
    403 response in `docs/open-api/catalogs.yaml`.
    
    ### Why are the changes needed?
    
    A user who can load a catalog already reaches the backing system with
    its stored configuration. Owner-only for that test adds no protection.
    Proposed changes still require ownership because the caller chooses what
    the server connects to.
    
    Fix: #13085
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    - Users with `USE_CATALOG` can now test an existing catalog with its
    stored configuration.
    - Testing with proposed changes still requires metalake or catalog
    ownership.
    - Authorization denial audit events report the expression that was
    actually evaluated.
    
    ### How was this patch tested?
    
    - `TestCatalogConnectionTestAuthorizationExecutor`: expression selection
    for no body, empty or null `updates`, proposed changes, and the `NEVER`
    condition, plus creation through `AuthorizeExecutorFactory`.
    - `TestGravitinoInterceptionService`: the real `CatalogOperations`
    annotations through the interceptor. A `USE_CATALOG` user can test the
    stored configuration and is denied for proposed changes (the denial
    event reports the owner expression); the catalog owner can test proposed
    changes.
    - `TestCatalogAuthorizationExpression`: the primary and secondary
    expressions on `testExistingConnection`.
    - `CatalogAuthorizationIT#testTestExistingCatalogConnection`: a user
    with `USE_CATALOG` and the owner end to end.
    - Python `test_error_handler`: the catalog error handler raises
    `ForbiddenException` for a forbidden response.
    - `./gradlew :server:test --tests
    'org.apache.gravitino.server.web.filter.*' --tests
    '*TestCatalogAuthorizationExpression' --tests '*TestCatalogOperations'
    :docs:build -PskipITs`
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../test/authorization/CatalogAuthorizationIT.java |  44 ++++++-
 .../tests/unittests/test_error_handler.py          |   5 +
 docs/open-api/catalogs.yaml                        |  14 ++-
 docs/security/access-control.md                    |   5 +
 .../annotations/AuthorizationRequest.java          |   3 +-
 .../annotations/ExpressionCondition.java           |   4 +-
 .../AuthorizationExpressionConstants.java          |   9 ++
 .../web/filter/GravitinoInterceptionService.java   |  10 +-
 .../authorization/AuthorizeExecutorFactory.java    |   9 ++
 ...CatalogConnectionTestAuthorizationExecutor.java |  80 ++++++++++++
 .../server/web/rest/CatalogOperations.java         |  11 +-
 .../filter/TestGravitinoInterceptionService.java   | 134 +++++++++++++++++++++
 ...CatalogConnectionTestAuthorizationExecutor.java | 132 ++++++++++++++++++++
 .../TestCatalogAuthorizationExpression.java        |  46 +++++++
 14 files changed, 498 insertions(+), 8 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/CatalogAuthorizationIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/CatalogAuthorizationIT.java
index f67f1168fc..004b20fdc9 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/CatalogAuthorizationIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/authorization/CatalogAuthorizationIT.java
@@ -21,10 +21,16 @@ import static org.junit.Assert.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import java.lang.reflect.Method;
+import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.Catalog;
+import org.apache.gravitino.CatalogChange;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObjects;
 import org.apache.gravitino.client.GravitinoMetalake;
 import org.apache.gravitino.dto.MetalakeDTO;
 import org.apache.gravitino.exceptions.ForbiddenException;
@@ -131,6 +137,42 @@ public class CatalogAuthorizationIT extends 
BaseRestApiAuthorizationIT {
 
   @Test
   @Order(3)
+  public void testTestExistingCatalogConnection() throws Exception {
+    GravitinoMetalake adminMetalake = client.loadMetalake(METALAKE);
+    CatalogChange proposedChange = CatalogChange.updateComment("proposed 
comment");
+    assertThrows(
+        "Can not access metadata {" + catalog1 + "}.",
+        ForbiddenException.class,
+        () -> 
normalUserClient.loadMetalake(METALAKE).testConnection(catalog1));
+
+    // USE_CATALOG allows testing the stored configuration, but not proposed 
changes.
+    String role = "testConnectionRole";
+    adminMetalake.createRole(
+        role,
+        new HashMap<>(),
+        ImmutableList.of(
+            SecurableObjects.ofCatalog(
+                catalog1, 
ImmutableList.<Privilege>of(Privileges.UseCatalog.allow()))));
+    adminMetalake.grantRolesToUser(ImmutableList.of(role), NORMAL_USER);
+    try {
+      GravitinoMetalake normalUserMetalake = 
normalUserClient.loadMetalake(METALAKE);
+      normalUserMetalake.testConnection(catalog1);
+      assertThrows(
+          "Can not access metadata {" + catalog1 + "}.",
+          ForbiddenException.class,
+          () -> normalUserMetalake.testConnection(catalog1, proposedChange));
+    } finally {
+      adminMetalake.revokeRolesFromUser(ImmutableList.of(role), NORMAL_USER);
+      adminMetalake.deleteRole(role);
+    }
+
+    // The owner can test both the stored configuration and proposed changes.
+    adminMetalake.testConnection(catalog1);
+    adminMetalake.testConnection(catalog1, proposedChange);
+  }
+
+  @Test
+  @Order(4)
   public void testDeleteCatalog() {
     String[] catalogs = client.loadMetalake(METALAKE).listCatalogs();
     assertEquals(2, catalogs.length);
@@ -151,7 +193,7 @@ public class CatalogAuthorizationIT extends 
BaseRestApiAuthorizationIT {
   }
 
   @Test
-  @Order(4)
+  @Order(5)
   public void testListCatalogsWithNonExistentMetalake() throws Exception {
     // Test that listCatalogs with @AuthorizationExpression returns 403 
Forbidden
     // when the metalake doesn't exist, instead of 404 response
diff --git a/clients/client-python/tests/unittests/test_error_handler.py 
b/clients/client-python/tests/unittests/test_error_handler.py
index 64c190e43d..778581450e 100644
--- a/clients/client-python/tests/unittests/test_error_handler.py
+++ b/clients/client-python/tests/unittests/test_error_handler.py
@@ -230,6 +230,11 @@ class TestErrorHandler(unittest.TestCase):
                 )
             )
 
+        with self.assertRaises(ForbiddenException):
+            CATALOG_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(ForbiddenException, 
"mock error")
+            )
+
         with self.assertRaises(InternalError):
             CATALOG_ERROR_HANDLER.handle(
                 ErrorResponse.generate_error_response(InternalError, "mock 
error")
diff --git a/docs/open-api/catalogs.yaml b/docs/open-api/catalogs.yaml
index 973be684dd..cc290f39db 100644
--- a/docs/open-api/catalogs.yaml
+++ b/docs/open-api/catalogs.yaml
@@ -165,7 +165,11 @@ paths:
         regular catalog operations, but does not guarantee that every 
object-level or mutating
         operation will succeed. Fileset catalogs test all catalog-level 
`location` and `location-*`
         targets. Model and Generic catalogs do not support connection testing. 
Expected test
-        failures are returned as application error codes in an HTTP 200 
response.
+        failures are returned as application error codes in an HTTP 200 
response. When
+        authorization is enabled, testing with the stored configuration 
requires the same access
+        as loading the catalog, and testing with proposed changes requires 
owning the metalake or
+        the catalog, the same as altering it. Callers without that access 
receive an HTTP 403
+        response.
       operationId: testExistingCatalogConnection
       requestBody:
         required: false
@@ -225,6 +229,14 @@ paths:
                     message: Catalog my_metalake.my_catalog is not in use
         "400":
           $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse"
+        "403":
+          description: >-
+            Forbidden - The caller cannot load the catalog, or tests proposed 
changes without
+            owning the metalake or the catalog
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
         "5xx":
           $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
 
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index 69b1827651..397a923a9b 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -254,6 +254,11 @@ return only the entries the caller is entitled to see, 
which for a metalake owne
 | Model    | `REGISTER_MODEL`    | `USE_MODEL`                             | 
Owner             | Owner |
 | Function | `REGISTER_FUNCTION` | `EXECUTE_FUNCTION` or `MODIFY_FUNCTION` | 
`MODIFY_FUNCTION` | Owner |
 
+Testing a catalog connection follows the catalog row. Testing a catalog before 
it is created takes
+`CREATE_CATALOG`. Testing an existing catalog with its stored configuration 
takes `USE_CATALOG`, the
+same as loading it. Testing an existing catalog with proposed changes that are 
not saved takes
+ownership, the same as altering it, because the caller chooses what the server 
connects to.
+
 Table statistics follow the table itself: reading them takes `SELECT_TABLE` or 
`MODIFY_TABLE`,
 writing them takes `MODIFY_TABLE`. Model versions follow the model: 
`USE_MODEL` to read, owner to
 alter or delete. Fetching plaintext secrets (`getSecrets`) or vend credentials 
(`getCredentials`)
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
index 2492fc9976..f90679324f 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
@@ -35,6 +35,7 @@ public @interface AuthorizationRequest {
     RUN_JOB,
     LINEAGE,
     LOAD_TABLE,
-    CREATE_SCHEMA
+    CREATE_SCHEMA,
+    TEST_CATALOG_CONNECTION
   }
 }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
index e811871217..f1bd58c1c6 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
@@ -20,5 +20,7 @@ package org.apache.gravitino.server.authorization.annotations;
 
 public enum ExpressionCondition {
   NEVER,
-  REQUIRED_MODIFY_PRIVILEGES
+  REQUIRED_MODIFY_PRIVILEGES,
+  /** The request carries proposed changes that are not saved, for example in 
its request body. */
+  HAS_PROPOSED_CHANGES
 }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
index ac7a073daf..705025153e 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
@@ -20,6 +20,15 @@ public class AuthorizationExpressionConstants {
   public static final String LOAD_CATALOG_AUTHORIZATION_EXPRESSION =
       "ANY_USE_CATALOG || ANY(OWNER, METALAKE, CATALOG)";
 
+  /**
+   * Authorizes testing an existing catalog connection with proposed changes. 
The caller chooses the
+   * configuration the server connects to, so this matches the authorization 
for altering the
+   * catalog. Testing with the stored configuration uses {@link
+   * #LOAD_CATALOG_AUTHORIZATION_EXPRESSION} instead.
+   */
+  public static final String 
TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION =
+      "ANY(OWNER, METALAKE, CATALOG)";
+
   public static final String LOAD_SCHEMA_AUTHORIZATION_EXPRESSION =
       """
           ANY(OWNER, METALAKE, CATALOG) ||
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 cb82a46798..af16c2efd1 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
@@ -256,12 +256,18 @@ public class GravitinoInterceptionService implements 
InterceptionService {
               MetadataObject.Type type = 
expressionAnnotation.accessMetadataType();
               NameIdentifier accessMetadataName =
                   metadataContext.get(Entity.EntityType.valueOf(type.name()));
+              // An executor can evaluate a request-specific expression, such 
as the secondary
+              // expression, so report the expression it evaluated when it 
recorded one.
+              String evaluatedExpression =
+                  StringUtils.defaultIfBlank(
+                      
authorizationRequestContext.getOriginalAuthorizationExpression(), expression);
               dispatchAuthzDenialEvent(
                   PrincipalUtils.getCurrentUserName(),
                   accessMetadataName,
                   method.getName(),
-                  expression);
-              return buildNoAuthResponse(expressionAnnotation, 
metadataContext, method, expression);
+                  evaluatedExpression);
+              return buildNoAuthResponse(
+                  expressionAnnotation, metadataContext, method, 
evaluatedExpression);
             }
           }
         }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
index aae03326ae..12a526cb2b 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
@@ -60,6 +60,15 @@ public class AuthorizeExecutorFactory {
           allowCheckExistenceExpression);
       case CREATE_SCHEMA -> new CreateSchemaAuthorizationExecutor(
           parameters, args, expression, metadataContext, pathParams, 
entityType);
+      case TEST_CATALOG_CONNECTION -> new 
CatalogConnectionTestAuthorizationExecutor(
+          parameters,
+          args,
+          expression,
+          metadataContext,
+          pathParams,
+          entityType,
+          secondaryExpression,
+          secondaryExpressionCondition);
     };
   }
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CatalogConnectionTestAuthorizationExecutor.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CatalogConnectionTestAuthorizationExecutor.java
new file mode 100644
index 0000000000..47380d4218
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/CatalogConnectionTestAuthorizationExecutor.java
@@ -0,0 +1,80 @@
+/*
+ * 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.filter.authorization;
+
+import java.lang.reflect.Parameter;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
+import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.server.web.filter.ParameterUtil;
+
+/**
+ * Authorization executor for testing the connection of an existing catalog.
+ *
+ * <p>Testing with the stored configuration uses the default expression. When 
the request carries
+ * proposed changes and the condition is {@link 
ExpressionCondition#HAS_PROPOSED_CHANGES}, the
+ * secondary expression is used instead, because the caller chooses the 
configuration the server
+ * connects to.
+ */
+public class CatalogConnectionTestAuthorizationExecutor extends 
CommonAuthorizerExecutor {
+
+  /**
+   * Creates an authorization executor for an existing catalog connection test.
+   *
+   * @param parameters the parameters of the intercepted method
+   * @param args the arguments passed to the intercepted method
+   * @param expression the expression for testing with the stored configuration
+   * @param metadataContext the metadata context bound to the authorization 
expression
+   * @param pathParams the path parameters of the request
+   * @param entityType the optional entity type of the request
+   * @param secondaryExpression the expression for testing with proposed 
changes
+   * @param secondaryExpressionCondition the condition for using the secondary 
expression
+   */
+  public CatalogConnectionTestAuthorizationExecutor(
+      Parameter[] parameters,
+      Object[] args,
+      String expression,
+      Map<Entity.EntityType, NameIdentifier> metadataContext,
+      Map<String, Object> pathParams,
+      Optional<String> entityType,
+      String secondaryExpression,
+      ExpressionCondition secondaryExpressionCondition) {
+    super(expression, metadataContext, pathParams, entityType);
+    if (StringUtils.isBlank(secondaryExpression)
+        || secondaryExpressionCondition != 
ExpressionCondition.HAS_PROPOSED_CHANGES) {
+      return;
+    }
+
+    // An empty change list is the same as testing the stored configuration.
+    Object request = ParameterUtil.extractFromParameters(parameters, args);
+    if (request instanceof CatalogUpdatesRequest updatesRequest
+        && updatesRequest.getUpdates() != null
+        && !updatesRequest.getUpdates().isEmpty()) {
+      this.expression = secondaryExpression;
+      this.authorizationExpressionEvaluator =
+          new AuthorizationExpressionEvaluator(secondaryExpression);
+    }
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
index 60f17c0239..09679b7515 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
@@ -59,6 +59,8 @@ import org.apache.gravitino.metrics.MetricNames;
 import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
 import org.apache.gravitino.server.web.Utils;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -230,7 +232,11 @@ public class CatalogOperations {
   @Produces("application/vnd.gravitino.v1+json")
   @Timed(name = "test-existing-connection." + 
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
   @AuthorizationExpression(
-      expression = "ANY(OWNER, METALAKE, CATALOG)",
+      expression = 
AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+      secondaryExpression =
+          AuthorizationExpressionConstants
+              .TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION,
+      secondaryExpressionCondition = ExpressionCondition.HAS_PROPOSED_CHANGES,
       accessMetadataType = MetadataObject.Type.CATALOG)
   @ResponseMetered(name = "test-existing-connection", absolute = true)
   public Response testExistingConnection(
@@ -238,7 +244,8 @@ public class CatalogOperations {
           String metalake,
       @PathParam("catalog") @AuthorizationMetadata(type = 
Entity.EntityType.CATALOG)
           String catalogName,
-      CatalogUpdatesRequest request) {
+      @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.TEST_CATALOG_CONNECTION)
+          CatalogUpdatesRequest request) {
     LOG.info("Received test connection request for existing catalog: {}.{}", 
metalake, catalogName);
     try {
       return Utils.doAs(
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 c82726272a..572c492d68 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
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.web.filter;
 import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.CAN_ACCESS_METADATA_AND_TAG;
 import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION;
 import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.PROBE_TABLE_LIKE_AUTHORIZATION_EXPRESSION;
+import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
@@ -29,6 +30,7 @@ import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.lang.reflect.Method;
 import java.security.Principal;
@@ -52,6 +54,8 @@ import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.catalog.TableDispatcher;
 import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
+import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
 import org.apache.gravitino.dto.requests.SchemaCreateRequest;
 import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.responses.ErrorConstants;
@@ -69,6 +73,7 @@ import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetada
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationObjectType;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.server.web.rest.CatalogOperations;
 import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
 import org.apache.gravitino.server.web.rest.SchemaOperations;
 import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
@@ -913,6 +918,58 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  @Test
+  public void 
testExistingCatalogConnectionAllowsUseCatalogWithoutProposedChanges()
+      throws Throwable {
+    GravitinoAuthorizer authorizer = catalogConnectionAuthorizer(true, false);
+
+    MethodInvocation noBody = testExistingConnectionInvocation(null);
+    Response noBodyResponse =
+        invokeTestExistingConnection(authorizer, mock(EventBus.class), noBody);
+    assertEquals(Response.Status.OK.getStatusCode(), 
noBodyResponse.getStatus());
+    verify(noBody).proceed();
+
+    MethodInvocation emptyChanges =
+        testExistingConnectionInvocation(new 
CatalogUpdatesRequest(Collections.emptyList()));
+    Response emptyChangesResponse =
+        invokeTestExistingConnection(authorizer, mock(EventBus.class), 
emptyChanges);
+    assertEquals(Response.Status.OK.getStatusCode(), 
emptyChangesResponse.getStatus());
+    verify(emptyChanges).proceed();
+  }
+
+  @Test
+  public void 
testExistingCatalogConnectionWithProposedChangesDeniesUseCatalog() throws 
Throwable {
+    EventBus eventBus = spy(new EventBus(Collections.emptyList()));
+    MethodInvocation invocation = 
testExistingConnectionInvocation(proposedCatalogChanges());
+
+    Response response =
+        invokeTestExistingConnection(
+            catalogConnectionAuthorizer(true, false), eventBus, invocation);
+
+    assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+    verify(invocation, never()).proceed();
+    // The denial event reports the owner expression the executor evaluated, 
not the default one.
+    ArgumentCaptor<AuthorizationDenialFailureEvent> captor =
+        ArgumentCaptor.forClass(AuthorizationDenialFailureEvent.class);
+    verify(eventBus).dispatchEvent(captor.capture());
+    assertEquals(
+        TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION,
+        captor.getValue().expression());
+  }
+
+  @Test
+  public void 
testExistingCatalogConnectionWithProposedChangesAllowsCatalogOwner()
+      throws Throwable {
+    MethodInvocation invocation = 
testExistingConnectionInvocation(proposedCatalogChanges());
+
+    Response response =
+        invokeTestExistingConnection(
+            catalogConnectionAuthorizer(false, true), mock(EventBus.class), 
invocation);
+
+    assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+    verify(invocation).proceed();
+  }
+
   public static class TestMetadataObjectTagAssociationOperations {
 
     @AuthorizationExpression(expression = CAN_ACCESS_METADATA_AND_TAG)
@@ -1017,6 +1074,83 @@ public class TestGravitinoInterceptionService {
     return authorizer;
   }
 
+  private Response invokeTestExistingConnection(
+      GravitinoAuthorizer authorizer, EventBus eventBus, MethodInvocation 
invocation)
+      throws Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
+        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class)) 
{
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      authUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(ignored -> null);
+
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+      when(mockEnv.eventBus()).thenReturn(eventBus);
+
+      // Use the real resource method so its annotations drive the 
authorization executor.
+      MethodInterceptor interceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(invocation.getMethod()).get(0);
+      return (Response) interceptor.invoke(invocation);
+    }
+  }
+
+  private MethodInvocation 
testExistingConnectionInvocation(CatalogUpdatesRequest request)
+      throws Throwable {
+    Method method =
+        CatalogOperations.class.getMethod(
+            "testExistingConnection", String.class, String.class, 
CatalogUpdatesRequest.class);
+    MethodInvocation invocation = mock(MethodInvocation.class);
+    when(invocation.getMethod()).thenReturn(method);
+    when(invocation.getArguments())
+        .thenReturn(new Object[] {"testMetalake", "testCatalog", request});
+    when(invocation.proceed()).thenReturn(Utils.ok("ok"));
+    return invocation;
+  }
+
+  private CatalogUpdatesRequest proposedCatalogChanges() {
+    return new CatalogUpdatesRequest(
+        ImmutableList.of(new 
CatalogUpdateRequest.SetCatalogPropertyRequest("key", "value")));
+  }
+
+  private GravitinoAuthorizer catalogConnectionAuthorizer(boolean useCatalog, 
boolean owner) {
+    GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+    when(authorizer.authorize(
+            ArgumentMatchers.any(),
+            ArgumentMatchers.eq("testMetalake"),
+            ArgumentMatchers.argThat(
+                metadataObject ->
+                    metadataObject.type() == MetadataObject.Type.CATALOG
+                        && "testCatalog".equals(metadataObject.name())),
+            ArgumentMatchers.eq(Privilege.Name.USE_CATALOG),
+            ArgumentMatchers.any()))
+        .thenReturn(useCatalog);
+    when(authorizer.isOwner(
+            ArgumentMatchers.any(),
+            ArgumentMatchers.eq("testMetalake"),
+            ArgumentMatchers.argThat(
+                metadataObject ->
+                    metadataObject.type() == MetadataObject.Type.CATALOG
+                        && "testCatalog".equals(metadataObject.name())),
+            ArgumentMatchers.any()))
+        .thenReturn(owner);
+    return authorizer;
+  }
+
   private static class MockGravitinoAuthorizer implements GravitinoAuthorizer {
 
     @Override
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestCatalogConnectionTestAuthorizationExecutor.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestCatalogConnectionTestAuthorizationExecutor.java
new file mode 100644
index 0000000000..26cd29987b
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestCatalogConnectionTestAuthorizationExecutor.java
@@ -0,0 +1,132 @@
+/*
+ * 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.filter.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Optional;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
+import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.junit.jupiter.api.Test;
+
+public class TestCatalogConnectionTestAuthorizationExecutor {
+  private static final String PRIMARY_EXPRESSION =
+      AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION;
+  private static final String SECONDARY_EXPRESSION =
+      AuthorizationExpressionConstants
+          .TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION;
+
+  @Test
+  public void testUsesPrimaryExpressionWithoutRequestBody() throws Exception {
+    CatalogConnectionTestAuthorizationExecutor executor =
+        createExecutor(null, ExpressionCondition.HAS_PROPOSED_CHANGES);
+
+    assertEquals(PRIMARY_EXPRESSION, executor.expression);
+  }
+
+  @Test
+  public void testUsesPrimaryExpressionForEmptyChanges() throws Exception {
+    CatalogConnectionTestAuthorizationExecutor executor =
+        createExecutor(
+            new CatalogUpdatesRequest(Collections.emptyList()),
+            ExpressionCondition.HAS_PROPOSED_CHANGES);
+
+    assertEquals(PRIMARY_EXPRESSION, executor.expression);
+  }
+
+  @Test
+  public void testUsesSecondaryExpressionForProposedChanges() throws Exception 
{
+    CatalogConnectionTestAuthorizationExecutor executor =
+        createExecutor(proposedChanges(), 
ExpressionCondition.HAS_PROPOSED_CHANGES);
+
+    assertEquals(SECONDARY_EXPRESSION, executor.expression);
+  }
+
+  @Test
+  public void testUsesPrimaryExpressionWhenConditionNever() throws Exception {
+    CatalogConnectionTestAuthorizationExecutor executor =
+        createExecutor(proposedChanges(), ExpressionCondition.NEVER);
+
+    assertEquals(PRIMARY_EXPRESSION, executor.expression);
+  }
+
+  @Test
+  public void testUsesPrimaryExpressionForNullChanges() throws Exception {
+    CatalogConnectionTestAuthorizationExecutor executor =
+        createExecutor(new CatalogUpdatesRequest(), 
ExpressionCondition.HAS_PROPOSED_CHANGES);
+
+    assertEquals(PRIMARY_EXPRESSION, executor.expression);
+  }
+
+  @Test
+  public void testFactoryCreatesExecutorForTestCatalogConnection() throws 
Exception {
+    Method method = TestOperations.class.getMethod("testConnection", 
CatalogUpdatesRequest.class);
+    AuthorizationExecutor executor =
+        AuthorizeExecutorFactory.create(
+            PRIMARY_EXPRESSION,
+            AuthorizationRequest.RequestType.TEST_CATALOG_CONNECTION,
+            Collections.emptyMap(),
+            Collections.emptyMap(),
+            Optional.empty(),
+            method.getParameters(),
+            new Object[] {proposedChanges()},
+            SECONDARY_EXPRESSION,
+            ExpressionCondition.HAS_PROPOSED_CHANGES,
+            "");
+
+    assertTrue(executor instanceof CatalogConnectionTestAuthorizationExecutor);
+    assertEquals(
+        SECONDARY_EXPRESSION, ((CatalogConnectionTestAuthorizationExecutor) 
executor).expression);
+  }
+
+  private static CatalogUpdatesRequest proposedChanges() {
+    return new CatalogUpdatesRequest(
+        ImmutableList.of(new 
CatalogUpdateRequest.SetCatalogPropertyRequest("key", "value")));
+  }
+
+  private static CatalogConnectionTestAuthorizationExecutor createExecutor(
+      CatalogUpdatesRequest request, ExpressionCondition condition) throws 
Exception {
+    Method method = TestOperations.class.getMethod("testConnection", 
CatalogUpdatesRequest.class);
+    return new CatalogConnectionTestAuthorizationExecutor(
+        method.getParameters(),
+        new Object[] {request},
+        PRIMARY_EXPRESSION,
+        Collections.<Entity.EntityType, NameIdentifier>emptyMap(),
+        Collections.emptyMap(),
+        Optional.empty(),
+        SECONDARY_EXPRESSION,
+        condition);
+  }
+
+  public static class TestOperations {
+    public void testConnection(
+        @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.TEST_CATALOG_CONNECTION)
+            CatalogUpdatesRequest request) {}
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestCatalogAuthorizationExpression.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestCatalogAuthorizationExpression.java
index 124ce01e6a..3879f6693d 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestCatalogAuthorizationExpression.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestCatalogAuthorizationExpression.java
@@ -16,6 +16,7 @@
  */
 package org.apache.gravitino.server.web.rest.authorization;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -26,6 +27,7 @@ import ognl.OgnlException;
 import org.apache.gravitino.dto.requests.CatalogCreateRequest;
 import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
 import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
 import org.apache.gravitino.server.web.rest.CatalogOperations;
 import org.junit.jupiter.api.Test;
@@ -115,6 +117,50 @@ public class TestCatalogAuthorizationExpression {
     
assertFalse(mockEvaluator.getResult(ImmutableSet.of("CATALOG::USE_CATALOG")));
   }
 
+  @Test
+  public void testTestExistingConnection() throws NoSuchMethodException, 
OgnlException {
+    Method method =
+        CatalogOperations.class.getMethod(
+            "testExistingConnection", String.class, String.class, 
CatalogUpdatesRequest.class);
+    AuthorizationExpression authorizationExpressionAnnotation =
+        method.getAnnotation(AuthorizationExpression.class);
+    String expression = authorizationExpressionAnnotation.expression();
+    MockAuthorizationExpressionEvaluator mockEvaluator =
+        new MockAuthorizationExpressionEvaluator(expression);
+    assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::USE_SCHEMA")));
+    
assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::USE_CATALOG")));
+    assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+    assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::CREATE_CATALOG")));
+    
assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::USE_CATALOG")));
+    assertFalse(
+        mockEvaluator.getResult(
+            ImmutableSet.of("METALAKE::USE_CATALOG", 
"CATALOG::DENY_USE_CATALOG")));
+  }
+
+  @Test
+  public void testTestExistingConnectionWithChanges() throws 
NoSuchMethodException, OgnlException {
+    Method method =
+        CatalogOperations.class.getMethod(
+            "testExistingConnection", String.class, String.class, 
CatalogUpdatesRequest.class);
+    AuthorizationExpression authorizationExpressionAnnotation =
+        method.getAnnotation(AuthorizationExpression.class);
+    assertEquals(
+        ExpressionCondition.HAS_PROPOSED_CHANGES,
+        authorizationExpressionAnnotation.secondaryExpressionCondition());
+    MockAuthorizationExpressionEvaluator mockEvaluator =
+        new MockAuthorizationExpressionEvaluator(
+            authorizationExpressionAnnotation.secondaryExpression());
+    assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::USE_SCHEMA")));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::USE_CATALOG")));
+    assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+    assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("METALAKE::CREATE_CATALOG")));
+    
assertFalse(mockEvaluator.getResult(ImmutableSet.of("CATALOG::USE_CATALOG")));
+  }
+
   @Test
   public void testDropCatalog() throws NoSuchMethodException, OgnlException {
     Method method =

Reply via email to