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

github-actions[bot] pushed a commit to branch cherry-pick-f1a13e7c-to-branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git

commit 60c82530595da9171f55495654fc69a7cc5ba20b
Author: MaSai <[email protected]>
AuthorDate: Wed Nov 4 19:29:43 2026 +0800

    [#14242] fix(iceberg-rest): Authorize registerTable overwrite and foreign 
metadata sources (#14482)
    
    ### What changes were proposed in this pull request?
    
    Authorize Iceberg REST `registerTable` more tightly in
    `RegisterTableAuthzHandler`:
    
    1. **Destination first.** Before any metadata FileIO or catalog scan,
    evaluate create (plain register) or modify (overwrite) on the
    destination. Authorization completes in the handler
    (`authorizationCompleted()`), matching the Lance overwrite pattern
    without shared secondary-expression hooks.
    2. **Foreign sources.** After destination authorization, load the
    metadata file, find live catalog tables that share the metadata file /
    UUID / table location, and require the load-table expression on each
    foreign source. Orphaned metadata (no live match, for example after
    drop) remains allowed. Matching lives in `RegisterTableSourceResolver`.
    Root namespace table listing is skipped for catalogs that reject it (for
    example Hive/JDBC). Non-`NoSuchTableException` failures while reading a
    candidate's current metadata propagate (fail-closed).
    3. **Denial attribution.** `AuthorizationHandler.entityType()` (no-arg)
    tells the shared interceptor which object to attribute denials to;
    `RegisterTableAuthzHandler` binds the destination TABLE id at
    construction for overwrite audits.
    
    ### Why are the changes needed?
    
    `CREATE_TABLE` alone let a caller replace an existing table's metadata
    pointer, or register a new name that adopts metadata of a table they
    cannot read and become its owner. Doing destination authorization after
    metadata IO also let unauthorized callers open user-supplied paths and
    scan the catalog before a 403.
    
    Fixes #14242
    Fixes #14441
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    1. `registerTable` with `overwrite=true` requires ownership of the named
    table or `MODIFY_TABLE`.
    2. Plain `registerTable` still requires the create-table expression, now
    checked before metadata IO.
    3. `registerTable` that points at metadata still owned by another live
    table requires `SELECT_TABLE` / `MODIFY_TABLE` / ownership on that
    source table.
    
    ### Performance / availability note
    
    Every authorized register traverses namespaces/tables in this catalog
    and may load each unrelated table's current metadata to decide
    ownership. An unreadable unrelated table can therefore block
    registration. Keeping unknown ownership fail-closed is intentional; this
    patch does not add a global ownership index, and it does not restore a
    broad exception catch around source matching.
    
    ### How was this patch tested?
    
    ```
    ./gradlew :iceberg:iceberg-rest-server:test -PskipITs \
      --tests 
org.apache.gravitino.server.web.filter.TestRegisterTableAuthzHandler \
      --tests 
org.apache.gravitino.iceberg.service.dispatcher.TestRegisterTableSourceResolver
    ./gradlew :server-common:test -PskipITs \
      --tests 
org.apache.gravitino.server.web.filter.TestBaseMetadataAuthorizationMethodInterceptor
    ```
    
    Also adds
    
`IcebergNamespaceAuthorizationIT#testRegisterTableCannotAdoptUnreadableSource`
    (docker IT).
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
    # Conflicts:
    #       
server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
---
 .../dispatcher/RegisterTableSourceResolver.java    | 144 +++++++++
 .../service/rest/IcebergNamespaceOperations.java   |   3 +-
 .../annotations/IcebergAuthorizationMetadata.java  |   1 +
 .../web/filter/CreateNamespaceAuthzHandler.java    |   5 +
 ...bergMetadataAuthorizationMethodInterceptor.java |   7 +-
 .../server/web/filter/LoadTableAuthzHandler.java   |   5 +
 .../server/web/filter/LoadViewAuthzHandler.java    |   5 +
 .../web/filter/RegisterTableAuthzHandler.java      | 211 +++++++++++++
 .../server/web/filter/RenameTableAuthzHandler.java |   5 +
 .../server/web/filter/RenameViewAuthzHandler.java  |   5 +
 .../test/IcebergNamespaceAuthorizationIT.java      |  71 +++++
 .../TestRegisterTableSourceResolver.java           | 136 +++++++++
 ...bergMetadataAuthorizationMethodInterceptor.java |  25 +-
 .../web/filter/TestRegisterTableAuthzHandler.java  | 339 +++++++++++++++++++++
 ...anceMetadataAuthorizationMethodInterceptor.java |  37 ++-
 .../annotations/AuthorizationExpression.java       |   6 +-
 ...BaseMetadataAuthorizationMethodInterceptor.java |  23 +-
 ...BaseMetadataAuthorizationMethodInterceptor.java | 185 ++++++++++-
 18 files changed, 1187 insertions(+), 26 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java
new file mode 100644
index 0000000000..a4ef6aa76d
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java
@@ -0,0 +1,144 @@
+/*
+ * 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.iceberg.service.dispatcher;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchNamespaceException;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+
+/**
+ * Resolves live catalog tables that share a register-table metadata file.
+ *
+ * <p>This class only discovers identities. Privilege checks belong in the 
Iceberg metadata
+ * authorization interceptor handlers.
+ */
+public final class RegisterTableSourceResolver {
+
+  private RegisterTableSourceResolver() {}
+
+  /**
+   * Finds tables other than {@code target} that share the given metadata 
file, UUID, or table
+   * location.
+   *
+   * @param catalogWrapper catalog to scan
+   * @param target register destination table
+   * @param metadataLocation metadata file from the register request
+   * @param metadata parsed metadata at that location
+   * @return matching live table identities
+   */
+  public static List<TableIdentifier> findSourceTables(
+      IcebergCatalogWrapper catalogWrapper,
+      TableIdentifier target,
+      String metadataLocation,
+      TableMetadata metadata) {
+    List<TableIdentifier> matches = new ArrayList<>();
+    String uuid = metadata.uuid();
+    String tableLocation = normalizePath(metadata.location());
+    String normalizedMeta = normalizePath(metadataLocation);
+    collectMatchingTables(
+        catalogWrapper, Namespace.empty(), target, uuid, tableLocation, 
normalizedMeta, matches);
+    return matches;
+  }
+
+  private static void collectMatchingTables(
+      IcebergCatalogWrapper catalogWrapper,
+      Namespace parent,
+      TableIdentifier target,
+      String uuid,
+      String tableLocation,
+      String metadataLocation,
+      List<TableIdentifier> matches) {
+    List<Namespace> children;
+    List<TableIdentifier> tables;
+    try {
+      children = catalogWrapper.listNamespace(parent).namespaces();
+      // Catalogs such as JDBC reject listing tables in the root namespace, 
and tables always live
+      // in a named namespace, so only list tables below the root.
+      tables = parent.isEmpty() ? List.of() : 
catalogWrapper.listTable(parent).identifiers();
+    } catch (NoSuchNamespaceException e) {
+      // The namespace was dropped while scanning.
+      return;
+    }
+    for (Namespace child : children) {
+      collectMatchingTables(
+          catalogWrapper, child, target, uuid, tableLocation, 
metadataLocation, matches);
+    }
+    for (TableIdentifier identifier : tables) {
+      if (identifier.equals(target)) {
+        continue;
+      }
+      if (matchesSource(catalogWrapper, identifier, uuid, tableLocation, 
metadataLocation)) {
+        matches.add(identifier);
+      }
+    }
+  }
+
+  private static boolean matchesSource(
+      IcebergCatalogWrapper catalogWrapper,
+      TableIdentifier identifier,
+      String uuid,
+      String tableLocation,
+      String metadataLocation) {
+    Optional<String> currentMeta = 
catalogWrapper.getTableMetadataLocation(identifier);
+    if (currentMeta.isPresent() && 
normalizePath(currentMeta.get()).equals(metadataLocation)) {
+      return true;
+    }
+    try {
+      TableMetadata existing = catalogWrapper.loadTableMetadata(identifier);
+      if (uuid != null && uuid.equals(existing.uuid())) {
+        return true;
+      }
+      return tableLocation != null
+          && !tableLocation.isEmpty()
+          && tableLocation.equals(normalizePath(existing.location()));
+    } catch (NoSuchTableException e) {
+      // Only a table that no longer exists is unrelated. Any other failure to 
read its metadata
+      // must not be mistaken for unrelated metadata, which would skip source 
authorization.
+      return false;
+    }
+  }
+
+  /** Normalizes path strings so {@code file:/} and {@code file:///} compare 
equal. */
+  public static String normalizePath(String path) {
+    if (path == null) {
+      return "";
+    }
+    String trimmed = path.trim();
+    if (trimmed.startsWith("file:")) {
+      String withoutScheme = trimmed.substring("file:".length());
+      while (withoutScheme.startsWith("//")) {
+        withoutScheme = withoutScheme.substring(1);
+      }
+      if (!withoutScheme.startsWith("/")) {
+        withoutScheme = "/" + withoutScheme;
+      }
+      return "file:" + withoutScheme;
+    }
+    if (trimmed.endsWith("/")) {
+      return trimmed.substring(0, trimmed.length() - 1);
+    }
+    return trimmed;
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
index 49da1f1643..886ffde502 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
@@ -321,7 +321,8 @@ public class IcebergNamespaceOperations {
       @AuthorizationMetadata(type = Entity.EntityType.CATALOG) 
@PathParam("prefix") String prefix,
       @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) @Encoded() 
@PathParam("namespace")
           String namespace,
-      RegisterTableRequest registerTableRequest,
+      @IcebergAuthorizationMetadata(type = 
IcebergAuthorizationMetadata.RequestType.REGISTER_TABLE)
+          RegisterTableRequest registerTableRequest,
       @HeaderParam(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION) String 
accessDelegation) {
     IcebergRESTUtils.validateRequest(registerTableRequest, 
"RegisterTableRequest");
     boolean isCredentialVending = 
IcebergTableOperations.isCredentialVending(accessDelegation);
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
index 6488acb84f..2e2a3dba01 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
@@ -34,6 +34,7 @@ public @interface IcebergAuthorizationMetadata {
     RENAME_TABLE,
     RENAME_VIEW,
     CREATE_NAMESPACE,
+    REGISTER_TABLE,
   }
   /**
    * The type of the parameter to be used for authorization.
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
index 1d12ee4364..32e34a1b0c 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java
@@ -86,6 +86,11 @@ public class CreateNamespaceAuthzHandler implements 
AuthorizationHandler {
     return false;
   }
 
+  @Override
+  public EntityType entityType() {
+    return EntityType.SCHEMA;
+  }
+
   private CreateNamespaceRequest extractRequest() {
     for (int i = 0; i < parameters.length; i++) {
       IcebergAuthorizationMetadata metadata =
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index baeb09901d..2980614e5a 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -149,7 +149,10 @@ public class IcebergMetadataAuthorizationMethodInterceptor
    */
   @Override
   protected Optional<AuthorizationHandler> createAuthorizationHandler(
-      Method method, Parameter[] parameters, Object[] args) {
+      Method method,
+      Parameter[] parameters,
+      Object[] args,
+      Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
     AuthorizationExpression authExpr = 
method.getAnnotation(AuthorizationExpression.class);
     for (Parameter parameter : parameters) {
       IcebergAuthorizationMetadata icebergMetadata =
@@ -168,6 +171,8 @@ public class IcebergMetadataAuthorizationMethodInterceptor
             return Optional.of(new RenameViewAuthzHandler(parameters, args));
           case CREATE_NAMESPACE:
             return Optional.of(new CreateNamespaceAuthzHandler(parameters, 
args));
+          case REGISTER_TABLE:
+            return Optional.of(new RegisterTableAuthzHandler(parameters, args, 
nameIdentifierMap));
           default:
             break;
         }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
index ecbb2188ad..6b72ea2c0d 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
@@ -110,6 +110,11 @@ public class LoadTableAuthzHandler implements 
AuthorizationHandler {
     return true;
   }
 
+  @Override
+  public EntityType entityType() {
+    return EntityType.TABLE;
+  }
+
   /**
    * Perform TABLE-level authorization aligned with {@link
    * 
org.apache.gravitino.iceberg.service.rest.IcebergTableOperations#loadTable}: 
evaluate the
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
index 304046e270..ec0b9bbf64 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
@@ -98,6 +98,11 @@ public class LoadViewAuthzHandler implements 
AuthorizationHandler {
     return true;
   }
 
+  @Override
+  public EntityType entityType() {
+    return EntityType.VIEW;
+  }
+
   /**
    * Perform VIEW-level authorization aligned with {@link
    * 
org.apache.gravitino.iceberg.service.rest.IcebergViewOperations#loadView}: 
evaluate the primary
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java
new file mode 100644
index 0000000000..209cbf5776
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java
@@ -0,0 +1,211 @@
+/*
+ * 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;
+
+import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION;
+import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.MODIFY_TABLE_AUTHORIZATION_EXPRESSION;
+
+import java.lang.reflect.Parameter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import 
org.apache.gravitino.iceberg.service.dispatcher.RegisterTableSourceResolver;
+import 
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import 
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
+
+/**
+ * Authorizes Iceberg REST {@code registerTable} requests that the method 
annotation alone cannot
+ * express.
+ *
+ * <p>Destination authorization runs first, before any metadata FileIO or 
catalog scan: overwrite
+ * uses the modify-table expression on the named table; a plain register uses 
the same create-table
+ * expression as {@code IcebergNamespaceOperations#registerTable}. 
Authorization then completes, so
+ * the shared interceptor does not re-evaluate the method expression. When the 
metadata file still
+ * belongs to another live table, the load-table expression on that table is 
required so {@code
+ * CREATE_TABLE} alone cannot adopt an unreadable table's metadata.
+ */
+@SuppressWarnings("FormatStringAnnotation")
+public class RegisterTableAuthzHandler implements AuthorizationHandler {
+
+  /** Same create expression as {@code 
IcebergNamespaceOperations#registerTable}. */
+  private static final String CREATE_TABLE_EXPRESSION =
+      "ANY(OWNER, METALAKE, CATALOG) || "
+          + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+          + "ANY_USE_CATALOG && ANY_USE_SCHEMA && ANY_CREATE_TABLE";
+
+  private static final Pattern SCHEMA_SPLIT =
+      Pattern.compile(Pattern.quote(HierarchicalSchemaUtil.schemaSeparator()));
+
+  private final Parameter[] parameters;
+  private final Object[] args;
+  private final EntityType accessEntityType;
+
+  /**
+   * Creates a handler for the register-table request arguments.
+   *
+   * @param parameters invoked method parameters
+   * @param args invoked method arguments
+   * @param nameIdentifierMap path identifiers; the destination TABLE id is 
bound here for denial
+   *     attribution before {@link #process}
+   */
+  public RegisterTableAuthzHandler(
+      Parameter[] parameters, Object[] args, Map<EntityType, NameIdentifier> 
nameIdentifierMap) {
+    this.parameters = parameters;
+    this.args = args;
+    RegisterTableRequest request = registerTableRequest();
+    this.accessEntityType = request.overwrite() ? EntityType.TABLE : 
EntityType.SCHEMA;
+    NameIdentifier metalakeIdent = nameIdentifierMap.get(EntityType.METALAKE);
+    NameIdentifier catalogIdent = nameIdentifierMap.get(EntityType.CATALOG);
+    NameIdentifier schemaIdent = nameIdentifierMap.get(EntityType.SCHEMA);
+    if (metalakeIdent != null && catalogIdent != null && schemaIdent != null) {
+      nameIdentifierMap.put(
+          EntityType.TABLE,
+          NameIdentifierUtil.ofTable(
+              metalakeIdent.name(), catalogIdent.name(), schemaIdent.name(), 
request.name()));
+    }
+  }
+
+  @Override
+  public void process(Map<EntityType, NameIdentifier> nameIdentifierMap) {
+    RegisterTableRequest request = registerTableRequest();
+
+    NameIdentifier metalakeIdent = nameIdentifierMap.get(EntityType.METALAKE);
+    NameIdentifier catalogIdent = nameIdentifierMap.get(EntityType.CATALOG);
+    NameIdentifier schemaIdent = nameIdentifierMap.get(EntityType.SCHEMA);
+    if (metalakeIdent == null || catalogIdent == null || schemaIdent == null) {
+      throw new ForbiddenException(
+          "Missing metalake, catalog, or schema context for register-table 
authorization");
+    }
+
+    String metalakeName = metalakeIdent.name();
+    String catalog = catalogIdent.name();
+    String schema = schemaIdent.name();
+    String tableName = request.name();
+    nameIdentifierMap.put(
+        EntityType.TABLE, NameIdentifierUtil.ofTable(metalakeName, catalog, 
schema, tableName));
+
+    requireCanAccessDestination(nameIdentifierMap, request.overwrite());
+    authorizeMetadataSources(metalakeName, catalog, schema, tableName, 
request);
+  }
+
+  @Override
+  public boolean authorizationCompleted() {
+    // Destination create/overwrite and any source checks are finished in 
process().
+    return true;
+  }
+
+  @Override
+  public EntityType entityType() {
+    return accessEntityType;
+  }
+
+  private static void requireCanAccessDestination(
+      Map<EntityType, NameIdentifier> nameIdentifierMap, boolean overwrite) {
+    String expression = overwrite ? MODIFY_TABLE_AUTHORIZATION_EXPRESSION : 
CREATE_TABLE_EXPRESSION;
+    EntityType accessType = overwrite ? EntityType.TABLE : EntityType.SCHEMA;
+    boolean authorized =
+        new AuthorizationExpressionEvaluator(expression)
+            .evaluate(
+                nameIdentifierMap,
+                new HashMap<>(),
+                new AuthorizationRequestContext(),
+                Optional.of(accessType.name()));
+    if (!authorized) {
+      throw new ForbiddenException(
+          "User '%s' is not authorized to %s table '%s'",
+          PrincipalUtils.getCurrentUserName(),
+          overwrite ? "overwrite" : "register",
+          nameIdentifierMap.get(EntityType.TABLE));
+    }
+  }
+
+  private void authorizeMetadataSources(
+      String metalakeName,
+      String catalog,
+      String schema,
+      String tableName,
+      RegisterTableRequest request) {
+    CatalogWrapperForREST catalogWrapper =
+        
IcebergRESTServerContext.getInstance().catalogWrapperManager().getCatalogWrapper(catalog);
+    TableMetadata metadata =
+        
catalogWrapper.loadTableMetadataFromLocation(request.metadataLocation());
+    Namespace targetNamespace = Namespace.of(SCHEMA_SPLIT.split(schema, -1));
+    TableIdentifier target = TableIdentifier.of(targetNamespace, tableName);
+    List<TableIdentifier> sources =
+        RegisterTableSourceResolver.findSourceTables(
+            catalogWrapper, target, request.metadataLocation(), metadata);
+    for (TableIdentifier source : sources) {
+      requireCanLoadTable(metalakeName, catalog, source);
+    }
+  }
+
+  private static void requireCanLoadTable(
+      String metalake, String catalogName, TableIdentifier source) {
+    String sourceSchema =
+        String.join(HierarchicalSchemaUtil.schemaSeparator(), 
source.namespace().levels());
+    Map<EntityType, NameIdentifier> identifiers = new HashMap<>();
+    identifiers.put(EntityType.METALAKE, 
NameIdentifierUtil.ofMetalake(metalake));
+    identifiers.put(EntityType.CATALOG, NameIdentifierUtil.ofCatalog(metalake, 
catalogName));
+    identifiers.put(
+        EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalake, catalogName, 
sourceSchema));
+    identifiers.put(
+        EntityType.TABLE,
+        NameIdentifierUtil.ofTable(metalake, catalogName, sourceSchema, 
source.name()));
+    boolean authorized =
+        new 
AuthorizationExpressionEvaluator(LOAD_TABLE_AUTHORIZATION_EXPRESSION)
+            .evaluate(
+                identifiers,
+                new HashMap<>(),
+                new AuthorizationRequestContext(),
+                Optional.of(EntityType.TABLE.name()));
+    if (!authorized) {
+      throw new ForbiddenException(
+          "User '%s' is not authorized to register metadata from table '%s'",
+          PrincipalUtils.getCurrentUserName(), source);
+    }
+  }
+
+  private RegisterTableRequest registerTableRequest() {
+    for (int i = 0; i < parameters.length; i++) {
+      IcebergAuthorizationMetadata metadata =
+          parameters[i].getAnnotation(IcebergAuthorizationMetadata.class);
+      if (metadata != null
+          && metadata.type() == 
IcebergAuthorizationMetadata.RequestType.REGISTER_TABLE) {
+        return (RegisterTableRequest) args[i];
+      }
+    }
+    throw new ForbiddenException("RegisterTableRequest not found in 
parameters");
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java
index ef408e7048..9839c920a5 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java
@@ -117,6 +117,11 @@ public class RenameTableAuthzHandler implements 
AuthorizationHandler {
     return crossNamespaceRename;
   }
 
+  @Override
+  public EntityType entityType() {
+    return EntityType.TABLE;
+  }
+
   /**
    * Validates authorization for cross-namespace renames following MySQL 
privilege model: - Requires
    * ownership on source table (equivalent to DROP privilege) - Requires 
CREATE_TABLE privilege on
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
index bc2f43b8a2..d5996b810f 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
@@ -116,6 +116,11 @@ public class RenameViewAuthzHandler implements 
AuthorizationHandler {
     return crossNamespaceRename;
   }
 
+  @Override
+  public EntityType entityType() {
+    return EntityType.VIEW;
+  }
+
   /**
    * Validates authorization for cross-namespace view renames: ownership is 
required on the source
    * view, and {@code CREATE_VIEW} is required on the destination schema. 
Gravitino does not define
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java
index e1968bcab4..799590add1 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java
@@ -32,6 +32,9 @@ import org.apache.gravitino.authorization.Owner;
 import org.apache.gravitino.authorization.Privileges;
 import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.ForbiddenException;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
@@ -455,6 +458,58 @@ public class IcebergNamespaceAuthorizationIT extends 
IcebergAuthorizationIT {
     Assertions.assertDoesNotThrow(() -> sql("DESC TABLE %s", registeredTable));
   }
 
+  @Test
+  void testRegisterTableCannotAdoptUnreadableSource() {
+    String sourceNamespace = "ns_register_secret";
+    String destNamespace = "ns_register_mine";
+    String sourceTable = "secret";
+    String copyTable = "copy";
+
+    catalogClientWithAllPrivilege
+        .asSchemas()
+        .createSchema(sourceNamespace, "secret schema", new HashMap<>());
+    catalogClientWithAllPrivilege
+        .asSchemas()
+        .createSchema(destNamespace, "caller schema", new HashMap<>());
+
+    // The super user owns the secret table; the normal user never receives 
privileges on it.
+    String[] sourceLevels = new String[] {sourceNamespace};
+    createTableViaIRC(sourceLevels, sourceTable);
+    String metadataLocation =
+        ((HasTableOperations)
+                adminIcebergCatalog.loadTable(
+                    TableIdentifier.of(Namespace.of(sourceLevels), 
sourceTable)))
+            .operations()
+            .current()
+            .metadataFileLocation();
+
+    revokeUserRoles();
+    resetMetalakeAndCatalogOwner();
+    grantUseCatalogRole(GRAVITINO_CATALOG_NAME);
+    metalakeClientWithAllPrivilege.setOwner(
+        MetadataObjects.of(
+            Arrays.asList(GRAVITINO_CATALOG_NAME, destNamespace), 
MetadataObject.Type.SCHEMA),
+        NORMAL_USER,
+        Owner.Type.USER);
+    grantUseSchemaRole(destNamespace);
+
+    Assertions.assertThrowsExactly(ForbiddenException.class, () -> sql("USE 
%s", sourceNamespace));
+    Assertions.assertThrowsExactly(
+        ForbiddenException.class,
+        () ->
+            sql(
+                "CALL rest.system.register_table(table => '%s.%s', 
metadata_file=> '%s')",
+                destNamespace, copyTable, metadataLocation));
+
+    grantUseSchemaRole(sourceNamespace);
+    grantSelectTableRole(sourceNamespace, sourceTable);
+    Assertions.assertDoesNotThrow(
+        () ->
+            sql(
+                "CALL rest.system.register_table(table => '%s.%s', 
metadata_file=> '%s')",
+                destNamespace, copyTable, metadataLocation));
+  }
+
   @Test
   void testComplexAuthorizations() {
     String namespace = "ns_complex_auth";
@@ -531,6 +586,22 @@ public class IcebergNamespaceAuthorizationIT extends 
IcebergAuthorizationIT {
     
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName), 
NORMAL_USER);
   }
 
+  /** Grants SELECT_TABLE privilege to the normal user for the specified 
table. */
+  private void grantSelectTableRole(String schemaName, String tableName) {
+    String roleName = "selectTable_" + UUID.randomUUID();
+    SecurableObject catalogObject =
+        SecurableObjects.ofCatalog(GRAVITINO_CATALOG_NAME, ImmutableList.of());
+    SecurableObject schemaObject =
+        SecurableObjects.ofSchema(catalogObject, schemaName, 
ImmutableList.of());
+    SecurableObject tableObject =
+        SecurableObjects.ofTable(
+            schemaObject, tableName, 
ImmutableList.of(Privileges.SelectTable.allow()));
+
+    metalakeClientWithAllPrivilege.createRole(
+        roleName, new HashMap<>(), ImmutableList.of(tableObject));
+    
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName), 
NORMAL_USER);
+  }
+
   /** Grants CREATE_SCHEMA privilege to the normal user for the specified 
catalog. */
   private void grantCreateSchemaRole(String catalogName) {
     String roleName = "createSchema_" + UUID.randomUUID();
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java
new file mode 100644
index 0000000000..aa43395844
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.iceberg.service.dispatcher;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchNamespaceException;
+import org.apache.iceberg.rest.responses.ListNamespacesResponse;
+import org.apache.iceberg.rest.responses.ListTablesResponse;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link RegisterTableSourceResolver}. */
+class TestRegisterTableSourceResolver {
+
+  private static final String META_LOC = 
"file:/warehouse/other/secret/metadata/v1.metadata.json";
+  private static final String TABLE_LOC = "file:/warehouse/other/secret";
+
+  @Test
+  void testFindsForeignSourceByMetadataLocation() {
+    IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class);
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    TableIdentifier target = TableIdentifier.of("mine", "copy");
+    stubCatalogWithSource(catalog, source);
+    
when(catalog.getTableMetadataLocation(source)).thenReturn(Optional.of(META_LOC));
+
+    List<TableIdentifier> found =
+        RegisterTableSourceResolver.findSourceTables(catalog, target, 
META_LOC, tableMetadata());
+
+    Assertions.assertEquals(List.of(source), found);
+  }
+
+  @Test
+  void testIgnoresTargetItself() {
+    IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class);
+    TableIdentifier target = TableIdentifier.of("mine", "t");
+    stubCatalogWithSource(catalog, target);
+    
when(catalog.getTableMetadataLocation(target)).thenReturn(Optional.of(META_LOC));
+
+    List<TableIdentifier> found =
+        RegisterTableSourceResolver.findSourceTables(catalog, target, 
META_LOC, tableMetadata());
+
+    Assertions.assertTrue(found.isEmpty());
+  }
+
+  @Test
+  void testSkipsNamespaceDroppedDuringScan() {
+    IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class);
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(catalog, source);
+    when(catalog.listNamespace(source.namespace()))
+        .thenThrow(new NoSuchNamespaceException("Namespace does not exist: 
other"));
+
+    List<TableIdentifier> found =
+        RegisterTableSourceResolver.findSourceTables(
+            catalog, TableIdentifier.of("mine", "copy"), META_LOC, 
tableMetadata());
+
+    Assertions.assertTrue(found.isEmpty());
+  }
+
+  @Test
+  void testUnreadableSourceMetadataPropagates() {
+    IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class);
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(catalog, source);
+    // The live table moved on to a corrupt current metadata file; the caller 
registers an older
+    // one.
+    when(catalog.getTableMetadataLocation(source))
+        
.thenReturn(Optional.of("file:/warehouse/other/secret/metadata/v2.metadata.json"));
+    when(catalog.loadTableMetadata(source)).thenThrow(new 
RuntimeException("corrupt metadata"));
+
+    Assertions.assertThrows(
+        RuntimeException.class,
+        () ->
+            RegisterTableSourceResolver.findSourceTables(
+                catalog, TableIdentifier.of("mine", "copy"), META_LOC, 
tableMetadata()));
+  }
+
+  @Test
+  void testNormalizePathUnifiesFileUriForms() {
+    Assertions.assertEquals(
+        "file:/warehouse/t", 
RegisterTableSourceResolver.normalizePath("file:///warehouse/t"));
+    Assertions.assertEquals(
+        "file:/warehouse/t", 
RegisterTableSourceResolver.normalizePath("file:/warehouse/t"));
+  }
+
+  private static void stubCatalogWithSource(IcebergCatalogWrapper catalog, 
TableIdentifier source) {
+    Namespace ns = source.namespace();
+    ListNamespacesResponse rootNs = mock(ListNamespacesResponse.class);
+    when(rootNs.namespaces()).thenReturn(List.of(ns));
+    ListNamespacesResponse childNs = mock(ListNamespacesResponse.class);
+    when(childNs.namespaces()).thenReturn(Collections.emptyList());
+    ListTablesResponse childTables = mock(ListTablesResponse.class);
+    when(childTables.identifiers()).thenReturn(List.of(source));
+
+    when(catalog.listNamespace(Namespace.empty())).thenReturn(rootNs);
+    when(catalog.listNamespace(ns)).thenReturn(childNs);
+    // Like Iceberg's HiveCatalog, the root namespace cannot list tables.
+    when(catalog.listTable(Namespace.empty()))
+        .thenThrow(new IllegalArgumentException("Missing database in 
namespace: "));
+    when(catalog.listTable(ns)).thenReturn(childTables);
+  }
+
+  private static TableMetadata tableMetadata() {
+    Schema schema = new Schema(Types.NestedField.optional(1, "id", 
Types.LongType.get()));
+    return TableMetadata.newTableMetadata(
+        schema, PartitionSpec.unpartitioned(), TABLE_LOC, 
Collections.emptyMap());
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index 63ade84bd6..2f38a0be18 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -279,7 +279,10 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
         new IcebergMetadataAuthorizationMethodInterceptor() {
           @Override
           protected Optional<AuthorizationHandler> createAuthorizationHandler(
-              Method method, Parameter[] parameters, Object[] args) {
+              Method method,
+              Parameter[] parameters,
+              Object[] args,
+              Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
             return Optional.of(
                 new AuthorizationHandler() {
                   @Override
@@ -292,6 +295,11 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
                   public boolean authorizationCompleted() {
                     return false;
                   }
+
+                  @Override
+                  public Entity.EntityType entityType() {
+                    return Entity.EntityType.TABLE;
+                  }
                 });
           }
         };
@@ -404,7 +412,10 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
         new IcebergMetadataAuthorizationMethodInterceptor() {
           @Override
           protected Optional<AuthorizationHandler> createAuthorizationHandler(
-              Method method, Parameter[] parameters, Object[] args) {
+              Method method,
+              Parameter[] parameters,
+              Object[] args,
+              Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
             return Optional.of(
                 new AuthorizationHandler() {
                   @Override
@@ -417,6 +428,11 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
                   public boolean authorizationCompleted() {
                     return false;
                   }
+
+                  @Override
+                  public Entity.EntityType entityType() {
+                    return Entity.EntityType.TABLE;
+                  }
                 });
           }
         };
@@ -482,7 +498,10 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
         new IcebergMetadataAuthorizationMethodInterceptor() {
           @Override
           protected Optional<AuthorizationHandler> createAuthorizationHandler(
-              Method method, Parameter[] parameters, Object[] args) {
+              Method method,
+              Parameter[] parameters,
+              Object[] args,
+              Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
             throw new RuntimeException("test");
           }
         };
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java
new file mode 100644
index 0000000000..926c7e38dd
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java
@@ -0,0 +1,339 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import javax.ws.rs.core.Response;
+import org.aopalliance.intercept.MethodInvocation;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations;
+import org.apache.gravitino.listener.EventBus;
+import org.apache.gravitino.listener.api.event.BaseEvent;
+import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.rest.responses.ListNamespacesResponse;
+import org.apache.iceberg.rest.responses.ListTablesResponse;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+/**
+ * Tests that Iceberg REST {@code registerTable} authz requires modify/owner 
for overwrite and
+ * load-table rights when adopting another live table's metadata.
+ */
+class TestRegisterTableAuthzHandler {
+
+  private static final String METALAKE = "test_metalake";
+  private static final String CATALOG = "test_catalog";
+  private static final String SCHEMA = "test_schema";
+  private static final String TABLE = "existing_table";
+  private static final String META_LOC = 
"file:/warehouse/other/secret/metadata/v1.metadata.json";
+  private static final String TABLE_LOC = "file:/warehouse/other/secret";
+  private static final String PROCEEDED = "PROCEEDED";
+
+  private CatalogWrapperForREST catalogWrapper;
+  private GravitinoAuthorizer authorizer;
+  private IcebergMetadataAuthorizationMethodInterceptor interceptor;
+  private MockedStatic<PrincipalUtils> principalUtils;
+  private MockedStatic<AuthorizationUtils> authorizationUtils;
+  private MockedStatic<GravitinoAuthorizerProvider> authorizerProvider;
+
+  @BeforeEach
+  void setUp() {
+    catalogWrapper = mock(CatalogWrapperForREST.class);
+    when(catalogWrapper.isRESTCatalog()).thenReturn(false);
+    stubEmptyCatalog();
+    
when(catalogWrapper.loadTableMetadataFromLocation(any())).thenReturn(tableMetadata());
+
+    IcebergCatalogWrapperManager wrapperManager = 
mock(IcebergCatalogWrapperManager.class);
+    when(wrapperManager.getCatalogWrapper(CATALOG)).thenReturn(catalogWrapper);
+
+    IcebergConfigProvider configProvider = mock(IcebergConfigProvider.class);
+    when(configProvider.getMetalakeName()).thenReturn(METALAKE);
+    when(configProvider.getDefaultCatalogName()).thenReturn(CATALOG);
+    IcebergRESTServerContext.create(configProvider, true, false, false, 
wrapperManager);
+
+    authorizer = mock(GravitinoAuthorizer.class);
+    GravitinoAuthorizerProvider provider = 
mock(GravitinoAuthorizerProvider.class);
+    principalUtils = mockStatic(PrincipalUtils.class);
+    authorizationUtils = mockStatic(AuthorizationUtils.class);
+    authorizerProvider = mockStatic(GravitinoAuthorizerProvider.class);
+
+    principalUtils
+        .when(PrincipalUtils::getCurrentPrincipal)
+        .thenReturn(new UserPrincipal("tester"));
+    
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+    
authorizerProvider.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+    when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+    when(authorizer.deny(any(), any(), any(), any(), any())).thenReturn(false);
+    when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(false);
+    when(authorizer.findUnheldRoles(any(), any(), any(), 
any())).thenReturn(Set.of());
+    interceptor = new IcebergMetadataAuthorizationMethodInterceptor();
+  }
+
+  @AfterEach
+  void tearDown() {
+    authorizerProvider.close();
+    authorizationUtils.close();
+    principalUtils.close();
+  }
+
+  @Test
+  void testCreateTableAloneCannotOverwrite() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+
+    MethodInvocation overwrite = registerInvocation(true, TABLE, META_LOC);
+    Object result = interceptor.invoke(overwrite);
+    assertForbidden(result);
+    verify(overwrite, never()).proceed();
+  }
+
+  @Test
+  void testModifyTableAllowsOverwrite() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.MODIFY_TABLE);
+
+    assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(true, TABLE, 
META_LOC)));
+  }
+
+  @Test
+  void testTableOwnerAllowsOverwriteWithoutModifyPrivilege() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA);
+    ownerOf(MetadataObject.Type.TABLE);
+
+    assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(true, TABLE, 
META_LOC)));
+  }
+
+  @Test
+  void testCreateTableStillAllowsPlainRegisterOfOrphanedMetadata() throws 
Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+
+    assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(false, 
TABLE, META_LOC)));
+  }
+
+  @Test
+  void testCreateTableAloneCannotAdoptTrackedSource() throws Throwable {
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(source);
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+
+    MethodInvocation invocation = registerInvocation(false, "copy", META_LOC);
+    Object result = interceptor.invoke(invocation);
+    assertForbidden(result);
+    verify(invocation, never()).proceed();
+  }
+
+  @Test
+  void testSelectTableAllowsAdoptTrackedSource() throws Throwable {
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(source);
+    allow(
+        Privilege.Name.USE_CATALOG,
+        Privilege.Name.USE_SCHEMA,
+        Privilege.Name.CREATE_TABLE,
+        Privilege.Name.SELECT_TABLE);
+
+    assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(false, 
"copy", META_LOC)));
+  }
+
+  @Test
+  void testOverwriteDenialChecksTableBeforeScanningSources() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+
+    MethodInvocation overwrite = registerInvocation(true, TABLE, META_LOC);
+    assertForbidden(interceptor.invoke(overwrite));
+    verify(catalogWrapper, never()).listNamespace(any());
+    verify(catalogWrapper, never()).loadTableMetadataFromLocation(any());
+  }
+
+  @Test
+  void testCreateDenialChecksDestinationBeforeReadingMetadata() throws 
Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA);
+
+    MethodInvocation create = registerInvocation(false, TABLE, META_LOC);
+    assertForbidden(interceptor.invoke(create));
+    verify(catalogWrapper, never()).loadTableMetadataFromLocation(any());
+    verify(catalogWrapper, never()).listNamespace(any());
+  }
+
+  @Test
+  void testOverwriteDenialDispatchesAuditAgainstTable() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+    EventBus eventBus = mock(EventBus.class);
+    try (MockedStatic<GravitinoEnv> env = mockGravitinoEnv(eventBus)) {
+      assertForbidden(interceptor.invoke(registerInvocation(true, TABLE, 
META_LOC)));
+
+      ArgumentCaptor<BaseEvent> event = 
ArgumentCaptor.forClass(BaseEvent.class);
+      verify(eventBus).dispatchEvent(event.capture());
+      assertEquals(
+          NameIdentifierUtil.ofTable(METALAKE, CATALOG, SCHEMA, TABLE),
+          assertInstanceOf(AuthorizationDenialFailureEvent.class, 
event.getValue()).identifier());
+    }
+  }
+
+  @Test
+  void testSourceDenialDispatchesAuditAgainstSchema() throws Throwable {
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(source);
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+    EventBus eventBus = mock(EventBus.class);
+    try (MockedStatic<GravitinoEnv> env = mockGravitinoEnv(eventBus)) {
+      assertForbidden(interceptor.invoke(registerInvocation(false, "copy", 
META_LOC)));
+
+      ArgumentCaptor<BaseEvent> event = 
ArgumentCaptor.forClass(BaseEvent.class);
+      verify(eventBus).dispatchEvent(event.capture());
+      assertEquals(
+          NameIdentifierUtil.ofSchema(METALAKE, CATALOG, SCHEMA),
+          assertInstanceOf(AuthorizationDenialFailureEvent.class, 
event.getValue()).identifier());
+    }
+  }
+
+  @Test
+  void testUnreadableSourceMetadataBlocksRegister() throws Throwable {
+    TableIdentifier source = TableIdentifier.of("other", "secret");
+    stubCatalogWithSource(source);
+    when(catalogWrapper.getTableMetadataLocation(source))
+        
.thenReturn(Optional.of("file:/warehouse/other/secret/metadata/v2.metadata.json"));
+    when(catalogWrapper.loadTableMetadata(source)).thenThrow(new 
RuntimeException("corrupt"));
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, 
Privilege.Name.CREATE_TABLE);
+
+    MethodInvocation invocation = registerInvocation(false, "copy", META_LOC);
+    interceptor.invoke(invocation);
+    verify(invocation, never()).proceed();
+  }
+
+  private MethodInvocation registerInvocation(
+      boolean overwrite, String tableName, String metadataLocation) throws 
Throwable {
+    Method method =
+        IcebergNamespaceOperations.class.getMethod(
+            "registerTable", String.class, String.class, 
RegisterTableRequest.class, String.class);
+    RegisterTableRequest request =
+        ImmutableRegisterTableRequest.builder()
+            .name(tableName)
+            .metadataLocation(metadataLocation)
+            .overwrite(overwrite)
+            .build();
+    MethodInvocation invocation = mock(MethodInvocation.class);
+    when(invocation.getMethod()).thenReturn(method);
+    when(invocation.getArguments()).thenReturn(new Object[] {CATALOG + "/", 
SCHEMA, request, null});
+    when(invocation.proceed()).thenReturn(PROCEEDED);
+    return invocation;
+  }
+
+  private void stubEmptyCatalog() {
+    ListNamespacesResponse emptyNs = mock(ListNamespacesResponse.class);
+    when(emptyNs.namespaces()).thenReturn(Collections.emptyList());
+    ListTablesResponse emptyTables = mock(ListTablesResponse.class);
+    when(emptyTables.identifiers()).thenReturn(Collections.emptyList());
+    when(catalogWrapper.listNamespace(any())).thenReturn(emptyNs);
+    when(catalogWrapper.listTable(any())).thenReturn(emptyTables);
+  }
+
+  private void stubCatalogWithSource(TableIdentifier source) {
+    Namespace ns = source.namespace();
+    ListNamespacesResponse rootNs = mock(ListNamespacesResponse.class);
+    when(rootNs.namespaces()).thenReturn(List.of(ns));
+    ListNamespacesResponse childNs = mock(ListNamespacesResponse.class);
+    when(childNs.namespaces()).thenReturn(Collections.emptyList());
+    ListTablesResponse childTables = mock(ListTablesResponse.class);
+    when(childTables.identifiers()).thenReturn(List.of(source));
+
+    when(catalogWrapper.listNamespace(Namespace.empty())).thenReturn(rootNs);
+    when(catalogWrapper.listNamespace(ns)).thenReturn(childNs);
+    // Like Iceberg's HiveCatalog, the root namespace cannot list tables.
+    when(catalogWrapper.listTable(Namespace.empty()))
+        .thenThrow(new IllegalArgumentException("Missing database in 
namespace: "));
+    when(catalogWrapper.listTable(ns)).thenReturn(childTables);
+    
when(catalogWrapper.getTableMetadataLocation(source)).thenReturn(Optional.of(META_LOC));
+    when(catalogWrapper.loadTableMetadata(source)).thenReturn(tableMetadata());
+  }
+
+  private static TableMetadata tableMetadata() {
+    Schema schema = new Schema(Types.NestedField.optional(1, "id", 
Types.LongType.get()));
+    return TableMetadata.newTableMetadata(
+        schema, PartitionSpec.unpartitioned(), TABLE_LOC, 
Collections.emptyMap());
+  }
+
+  private void ownerOf(MetadataObject.Type... types) {
+    Set<MetadataObject.Type> owned = Set.of(types);
+    doAnswer(
+            invocation -> {
+              MetadataObject metadataObject = invocation.getArgument(2);
+              return metadataObject != null && 
owned.contains(metadataObject.type());
+            })
+        .when(authorizer)
+        .isOwner(any(), any(), any(), any());
+  }
+
+  private void allow(Privilege.Name... privileges) {
+    Set<Privilege.Name> allowed = Set.of(privileges);
+    doAnswer(invocation -> allowed.contains(invocation.getArgument(3)))
+        .when(authorizer)
+        .authorize(any(), any(), any(), any(), any());
+  }
+
+  private static MockedStatic<GravitinoEnv> mockGravitinoEnv(EventBus 
eventBus) {
+    MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class);
+    GravitinoEnv env = mock(GravitinoEnv.class);
+    envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+    when(env.eventBus()).thenReturn(eventBus);
+    return envStatic;
+  }
+
+  private static void assertForbidden(Object result) {
+    Response response = assertInstanceOf(Response.class, result);
+    assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+    assertInstanceOf(ErrorResponse.class, response.getEntity());
+  }
+}
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
index 88b3d645ec..16f6def5c9 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
@@ -162,13 +162,29 @@ public class LanceMetadataAuthorizationMethodInterceptor
    */
   @Override
   protected Optional<AuthorizationHandler> createAuthorizationHandler(
-      Method method, Parameter[] parameters, Object[] args) {
+      Method method,
+      Parameter[] parameters,
+      Object[] args,
+      Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
     String overwriteExpression =
         isTableOperation(method)
             ? 
LanceAuthorizationExpressions.MODIFY_TABLE_AUTHORIZATION_EXPRESSION
             : 
LanceAuthorizationExpressions.MODIFY_NAMESPACE_AUTHORIZATION_EXPRESSION;
     return createMode(parameters, args)
-        .map(mode -> new OverwriteAuthzHandler(mode, overwriteExpression));
+        .map(
+            mode ->
+                new OverwriteAuthzHandler(
+                    mode, overwriteExpression, 
deepestEntityType(nameIdentifierMap)));
+  }
+
+  private static Entity.EntityType deepestEntityType(
+      Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
+    if (nameIdentifierMap.containsKey(Entity.EntityType.TABLE)) {
+      return Entity.EntityType.TABLE;
+    }
+    return nameIdentifierMap.containsKey(Entity.EntityType.SCHEMA)
+        ? Entity.EntityType.SCHEMA
+        : Entity.EntityType.CATALOG;
   }
 
   @Override
@@ -217,8 +233,10 @@ public class LanceMetadataAuthorizationMethodInterceptor
 
     private final boolean overwrite;
     private final String authorizationExpression;
+    private final Entity.EntityType entityType;
 
-    private OverwriteAuthzHandler(String mode, String authorizationExpression) 
{
+    private OverwriteAuthzHandler(
+        String mode, String authorizationExpression, Entity.EntityType 
entityType) {
       // Read the mode through the same normalization the create operation 
applies, so a token the
       // operation will act on as an overwrite cannot be authorized as a plain 
create. Comparing the
       // raw string here would leave a gap: " overwrite " reaches the 
operation as OVERWRITE but
@@ -226,6 +244,7 @@ public class LanceMetadataAuthorizationMethodInterceptor
       // by somebody else.
       this.overwrite = OVERWRITE_MODE.equals(CommonUtil.normalizeToken(mode));
       this.authorizationExpression = authorizationExpression;
+      this.entityType = entityType;
     }
 
     @Override
@@ -234,7 +253,6 @@ public class LanceMetadataAuthorizationMethodInterceptor
         return;
       }
 
-      Entity.EntityType entityType = deepestEntityType(nameIdentifierMap);
       boolean authorized =
           new AuthorizationExpressionEvaluator(authorizationExpression)
               .evaluate(
@@ -256,14 +274,9 @@ public class LanceMetadataAuthorizationMethodInterceptor
       return overwrite;
     }
 
-    private static Entity.EntityType deepestEntityType(
-        Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
-      if (nameIdentifierMap.containsKey(Entity.EntityType.TABLE)) {
-        return Entity.EntityType.TABLE;
-      }
-      return nameIdentifierMap.containsKey(Entity.EntityType.SCHEMA)
-          ? Entity.EntityType.SCHEMA
-          : Entity.EntityType.CATALOG;
+    @Override
+    public Entity.EntityType entityType() {
+      return entityType;
     }
   }
 
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
index 05a6b32bf4..cb7b4e27e4 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
@@ -53,14 +53,16 @@ public @interface AuthorizationExpression {
   String errorMessage() default "";
 
   /**
-   * The secondary expression to evaluate for authorization, which represents 
multiple privileges.
+   * The secondary expression to evaluate for authorization when {@link
+   * #secondaryExpressionCondition()} matches. Typical uses are stricter load 
checks (for example
+   * MODIFY_TABLE privileges) and catalog connection-test paths that carry 
proposed changes.
    *
    * @return the secondary expression to evaluate for authorization.
    */
   String secondaryExpression() default "";
 
   /**
-   * The condition under which the secondary expression is evaluated.
+   * The condition under which {@link #secondaryExpression()} replaces {@link 
#expression()}.
    *
    * @return the condition for evaluating the secondary expression.
    */
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
 
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index 528d62b528..a3b5efb86d 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -119,6 +119,14 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
      *     standard expression-based authorization
      */
     boolean authorizationCompleted();
+
+    /**
+     * Entity type used to attribute denials and audit events for this 
handler. Called before {@link
+     * #process}.
+     *
+     * @return the access entity type this handler authorizes against
+     */
+    Entity.EntityType entityType();
   }
 
   /** Invokes the protocol operation after authorization succeeds. */
@@ -167,11 +175,16 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
    * @param method REST method being invoked
    * @param parameters Method parameters
    * @param args Method arguments
+   * @param nameIdentifierMap identifiers resolved for this request; handlers 
may add to it before
+   *     {@link AuthorizationHandler#process} for denial attribution
    * @return Optional handler for custom authorization processing, or empty if 
standard
    *     authorization is sufficient
    */
   protected Optional<AuthorizationHandler> createAuthorizationHandler(
-      Method method, Parameter[] parameters, Object[] args) {
+      Method method,
+      Parameter[] parameters,
+      Object[] args,
+      Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
     return Optional.empty();
   }
 
@@ -294,11 +307,11 @@ public abstract class 
BaseMetadataAuthorizationMethodInterceptor {
           }
         }
 
-        if (nameIdentifierMap.get(target.entityType()) != null) {
-          deniedObject = nameIdentifierMap.get(target.entityType());
-        }
         Optional<AuthorizationHandler> handler =
-            createAuthorizationHandler(method, parameters, args);
+            createAuthorizationHandler(method, parameters, args, 
nameIdentifierMap);
+        Entity.EntityType entityType =
+            handler.isPresent() ? handler.get().entityType() : 
target.entityType();
+        deniedObject = nameIdentifierMap.get(entityType);
 
         if (!skipStandardCheck && handler.isPresent()) {
           AuthorizationHandler authzHandler = handler.get();
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
index 54d24d4890..73ad0be954 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
@@ -246,6 +246,11 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
               public boolean authorizationCompleted() {
                 return true;
               }
+
+              @Override
+              public Entity.EntityType entityType() {
+                return Entity.EntityType.SCHEMA;
+              }
             });
     TestInvocation invocation = invocation("alwaysDenied", "authorized by 
handler");
 
@@ -305,6 +310,11 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
               public boolean authorizationCompleted() {
                 return false;
               }
+
+              @Override
+              public Entity.EntityType entityType() {
+                return Entity.EntityType.SCHEMA;
+              }
             });
     TestInvocation invocation = invocation("canAccessMetadata", "authorized");
 
@@ -355,6 +365,174 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
     verify(invocation).proceed();
   }
 
+<<<<<<< HEAD
+=======
+  @Test
+  public void testHandlerDenialIsRecordedAgainstHandlerTarget() throws 
Throwable {
+    NameIdentifier table = NameIdentifierUtil.ofTable("metalake", "catalog", 
"schema", "table");
+    EventBus eventBus = mock(EventBus.class);
+    try (MockedStatic<GravitinoEnv> envStatic = mockGravitinoEnv(eventBus)) {
+      TestInterceptor interceptor =
+          new TestInterceptor(Entity.EntityType.SCHEMA) {
+            @Override
+            protected AuthorizationTarget resolveAuthorizationTarget(
+                Method method,
+                AuthorizationExpression annotation,
+                Parameter[] parameters,
+                Object[] args) {
+              AuthorizationTarget target =
+                  super.resolveAuthorizationTarget(method, annotation, 
parameters, args);
+              // Bound before process so denial attribution can use 
handler.entityType().
+              target.nameIdentifiers().put(Entity.EntityType.TABLE, table);
+              return target;
+            }
+          };
+      interceptor.denialEventSource = 
Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER);
+      interceptor.handler =
+          Optional.of(
+              new 
BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler() {
+                @Override
+                public void process(Map<Entity.EntityType, NameIdentifier> 
nameIdentifierMap) {
+                  throw new ForbiddenException("denied by handler");
+                }
+
+                @Override
+                public boolean authorizationCompleted() {
+                  return true;
+                }
+
+                @Override
+                public Entity.EntityType entityType() {
+                  return Entity.EntityType.TABLE;
+                }
+              });
+
+      assertInstanceOf(
+          ForbiddenException.class,
+          interceptor.invoke(invocation("canAccessMetadata", "authorized")));
+
+      ArgumentCaptor<BaseEvent> event = 
ArgumentCaptor.forClass(BaseEvent.class);
+      verify(eventBus).dispatchEvent(event.capture());
+      assertEquals(
+          table,
+          assertInstanceOf(AuthorizationDenialFailureEvent.class, 
event.getValue()).identifier());
+    }
+  }
+
+  /** Entry authorization, parent checks and worker filtering share one user 
lookup. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testReadListReusesEntryContext(boolean hasDeny) throws Throwable 
{
+    AtomicInteger userLoads = new AtomicInteger();
+    Set<AuthorizationRequestContext> contexts = ConcurrentHashMap.newKeySet();
+    AtomicBoolean perObjectLoopRan = new AtomicBoolean();
+    AtomicReference<AuthorizationRequestContext> entryContext = new 
AtomicReference<>();
+    Function<String, Optional<UserUpdatedAt>> loadUser =
+        key -> {
+          userLoads.incrementAndGet();
+          return Optional.of(new UserUpdatedAt(1L, 1L));
+        };
+    try (MockedStatic<GravitinoEnv> envStatic = 
mockStatic(GravitinoEnv.class)) {
+      GravitinoEnv env = mock(GravitinoEnv.class);
+      Config config = mock(Config.class);
+      envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+      when(env.config()).thenReturn(config);
+      when(config.get(Configs.ENABLE_AUTHORIZATION)).thenReturn(true);
+      
when(config.get(Configs.GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE)).thenReturn(2);
+      principalUtils.when(() -> PrincipalUtils.doAs(any(), 
any())).thenCallRealMethod();
+      authorizationUtils
+          .when(() -> AuthorizationUtils.checkCurrentUser(any(), any(), any()))
+          .thenAnswer(
+              invocation -> {
+                AuthorizationRequestContext context = 
invocation.getArgument(2);
+                entryContext.set(context);
+                context.computeUserInfoIfAbsent("metalake::tester", loadUser);
+                return null;
+              });
+      when(authorizer.authorize(any(), any(), any(), any(), any()))
+          .thenAnswer(
+              invocation -> {
+                AuthorizationRequestContext context = 
invocation.getArgument(4);
+                contexts.add(context);
+                context.computeUserInfoIfAbsent("metalake::tester", loadUser);
+                return true;
+              });
+      when(authorizer.hasDenyPolicy(any(), any(), any(), any()))
+          .thenAnswer(
+              invocation -> {
+                contexts.add(invocation.getArgument(3));
+                return hasDeny;
+              });
+      when(authorizer.deny(any(), any(), any(), any(), any()))
+          .thenAnswer(
+              invocation -> {
+                MetadataObject object = invocation.getArgument(2);
+                contexts.add(invocation.getArgument(4));
+                if (object.type() == MetadataObject.Type.TABLE) {
+                  perObjectLoopRan.set(true);
+                }
+                return hasDeny && object.name().equals("hidden");
+              });
+      NameIdentifier visible = NameIdentifier.of("metalake", "catalog", 
"schema", "visible");
+      NameIdentifier hidden = NameIdentifier.of("metalake", "catalog", 
"schema", "hidden");
+      TestInvocation invocation = invocation("listTables", null);
+      when(invocation.proceed())
+          .thenAnswer(
+              unused ->
+                  MetadataAuthzHelper.filterByExpression(
+                      "metalake",
+                      
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+                      Entity.EntityType.TABLE,
+                      new NameIdentifier[] {visible, hidden}));
+
+      Object result = new 
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation);
+
+      assertArrayEquals(
+          hasDeny ? new NameIdentifier[] {visible} : new NameIdentifier[] 
{visible, hidden},
+          (NameIdentifier[]) result);
+      assertEquals(Set.of(entryContext.get()), contexts);
+      assertEquals(1, userLoads.get());
+      assertEquals(hasDeny, perObjectLoopRan.get());
+      assertNotSame(entryContext.get(), 
AuthorizationRequestScope.getOrCreate("metalake"));
+    }
+  }
+
+  /** An endpoint failure must not retain a request's permission cache on a 
reused thread. */
+  @Test
+  public void testReadContextIsClearedAfterOperationFailure() throws Throwable 
{
+    when(authorizer.authorize(any(), any(), any(), any(), 
any())).thenReturn(true);
+    AtomicReference<AuthorizationRequestContext> context = new 
AtomicReference<>();
+    TestInvocation invocation = invocation("listTables", null);
+    IllegalStateException failure = new IllegalStateException("list failed");
+    when(invocation.proceed())
+        .thenAnswer(
+            unused -> {
+              context.set(AuthorizationRequestScope.getOrCreate("metalake"));
+              throw failure;
+            });
+    assertSame(failure, new 
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation));
+    assertNotSame(context.get(), 
AuthorizationRequestScope.getOrCreate("metalake"));
+  }
+
+  /** Write operations must not expose pre-mutation authorization decisions to 
later filtering. */
+  @Test
+  public void testWriteDoesNotReuseEntryContext() throws Throwable {
+    AtomicReference<AuthorizationRequestContext> entryContext = new 
AtomicReference<>();
+    when(authorizer.authorize(any(), any(), any(), any(), any()))
+        .thenAnswer(
+            invocation -> {
+              entryContext.set(invocation.getArgument(4));
+              return true;
+            });
+    TestInvocation invocation = invocation("writeTables", null);
+    when(invocation.proceed())
+        .thenAnswer(unused -> 
AuthorizationRequestScope.getOrCreate("metalake"));
+    Object result = new 
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation);
+    assertInstanceOf(AuthorizationRequestContext.class, result);
+    assertNotSame(entryContext.get(), result);
+  }
+
+>>>>>>> f1a13e7ce ([#14242] fix(iceberg-rest): Authorize registerTable 
overwrite and foreign metadata sources (#14482))
   private static TestInvocation invocation(String methodName, Object result) 
throws Throwable {
     Method method = TestOperations.class.getDeclaredMethod(methodName);
     TestInvocation invocation = mock(TestInvocation.class);
@@ -413,10 +591,13 @@ public class 
TestBaseMetadataAuthorizationMethodInterceptor {
 
     @Override
     protected Optional<AuthorizationHandler> createAuthorizationHandler(
-        Method method, Parameter[] parameters, Object[] args) {
+        Method method,
+        Parameter[] parameters,
+        Object[] args,
+        Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) {
       return handler.isPresent()
           ? handler
-          : super.createAuthorizationHandler(method, parameters, args);
+          : super.createAuthorizationHandler(method, parameters, args, 
nameIdentifierMap);
     }
 
     @Override

Reply via email to