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

yufei pushed a commit to branch feature/iceberg-1.11
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/feature/iceberg-1.11 by this 
push:
     new 445ffee623 feat: add RegisterTable overwrite support (#3719)
445ffee623 is described below

commit 445ffee623b26ca630e050dda8eea8f75fe596c6
Author: Rishi <[email protected]>
AuthorDate: Mon Mar 9 11:37:32 2026 -0500

    feat: add RegisterTable overwrite support (#3719)
---
 CHANGELOG.md                                       |   2 +
 .../it/test/PolarisRestCatalogIntegrationBase.java |  56 ++++++++
 .../core/auth/PolarisAuthorizableOperation.java    |   2 +
 .../service/catalog/iceberg/IcebergCatalog.java    |  89 ++++++++++++-
 .../catalog/iceberg/IcebergCatalogHandler.java     |  39 +++++-
 .../AbstractIcebergCatalogHandlerAuthzTest.java    | 142 +++++++++++++++++++++
 .../iceberg/AbstractIcebergCatalogTest.java        |  79 ++++++++++++
 .../federation/iceberg-rest-federation.md          |   4 +
 8 files changed, 409 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f408a39b41..729721e8f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,8 @@ request adding CHANGELOG notes for breaking (!) changes and 
possibly other secti
 - Added support for S3-compatible storage that does not have KMS (use 
`kmsUavailable: true` in catalog storage configuration)
 - Added support for storage-scoped AWS credentials, allowing different AWS 
credentials to be configured per named storage. Enable with the 
`RESOLVE_CREDENTIALS_BY_STORAGE_NAME` feature flag (default: false). Storage 
names can be set explicitly via the `storageName` field on storage 
configuration, or inferred from the first allowed location's host.
 - Added support for persisting Iceberg metrics (ScanReport, CommitReport) to 
the database. Enable by setting 
`polaris.iceberg-metrics.reporting.type=persisting` in configuration. Metrics 
tables are included in the main JDBC schema.
+- Added support for `register table` overwrite semantics in the Iceberg REST 
catalog flow (`overwrite=true`) for internal Polaris catalogs. With overwrite 
enabled, existing table pointers can be updated to a new metadata location 
while preserving default behavior for `overwrite=false`.
+- Added `REGISTER_TABLE_OVERWRITE` authorization operation mapped to 
`TABLE_FULL_METADATA` for deterministic overwrite authorization.
 
 ### Changes
 
diff --git 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
index 92c68b3c73..30ff27f852 100644
--- 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
+++ 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
@@ -900,6 +900,62 @@ public abstract class PolarisRestCatalogIntegrationBase 
extends CatalogTests<RES
     }
   }
 
+  /**
+   * Build an Iceberg table, then invoke a subsequent register table call that 
overwrites the
+   * existing table metadata. Verifies that the overwrite is applied and the 
catalog reflects the
+   * updated metadata.
+   */
+  @Test
+  public void testRegisterTableOverwriteViaRest() {
+    Namespace ns = Namespace.of("ns_overwrite_rest");
+    restCatalog.createNamespace(ns);
+
+    // Create a source table and capture its metadata location
+    Table source = restCatalog.buildTable(TableIdentifier.of(ns, 
"source_table"), SCHEMA).create();
+    String currentMetadataLocation =
+        ((BaseTable) source).operations().current().metadataFileLocation();
+    String metadataDir =
+        currentMetadataLocation.substring(0, 
currentMetadataLocation.lastIndexOf('/') + 1);
+    String newMetadataLocation = metadataDir + "overwrite-v1.metadata.json";
+
+    try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+      initializeClientFileIO(resolvingFileIO);
+      resolvingFileIO.setConf(new Configuration());
+
+      // Write a new metadata file (re-using the current metadata contents)
+      TableMetadataParser.write(
+          ((BaseTable) source).operations().current(),
+          resolvingFileIO.newOutputFile(newMetadataLocation));
+
+      // Invoke REST register with overwrite=true
+      Invocation registerInvocation =
+          catalogApi
+              .request("v1/" + currentCatalogName + 
"/namespaces/ns_overwrite_rest/register")
+              .buildPost(
+                  Entity.json(
+                      Map.of(
+                          "name",
+                          "source_table",
+                          "metadata-location",
+                          newMetadataLocation,
+                          "overwrite",
+                          true)));
+
+      try (Response registerResponse = registerInvocation.invoke()) {
+        
assertThat(registerResponse.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
+      }
+
+      // Reload the table via REST client and verify metadata-location updated
+      Table loaded = restCatalog.loadTable(TableIdentifier.of(ns, 
"source_table"));
+      assertThat(((BaseTable) 
loaded).operations().current().metadataFileLocation())
+          .isEqualTo(newMetadataLocation);
+
+      // Clean up the test metadata files
+      resolvingFileIO.deleteFile(currentMetadataLocation);
+      resolvingFileIO.deleteFile(newMetadataLocation);
+    }
+  }
+
   @Test
   public void testCreateAndLoadTableWithReturnedEtag() {
     Namespace ns1 = Namespace.of("ns1");
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java
index ee373a2325..12276bdbd1 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java
@@ -80,6 +80,7 @@ import static 
org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ATTACH_POLIC
 import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_CREATE;
 import static 
org.apache.polaris.core.entity.PolarisPrivilege.TABLE_DETACH_POLICY;
 import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_DROP;
+import static 
org.apache.polaris.core.entity.PolarisPrivilege.TABLE_FULL_METADATA;
 import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_LIST;
 import static 
org.apache.polaris.core.entity.PolarisPrivilege.TABLE_LIST_GRANTS;
 import static 
org.apache.polaris.core.entity.PolarisPrivilege.TABLE_MANAGE_GRANTS_ON_SECURABLE;
@@ -127,6 +128,7 @@ public enum PolarisAuthorizableOperation {
   CREATE_TABLE_STAGED(TABLE_CREATE),
   CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION(EnumSet.of(TABLE_CREATE, 
TABLE_WRITE_DATA)),
   REGISTER_TABLE(TABLE_CREATE),
+  REGISTER_TABLE_OVERWRITE(TABLE_FULL_METADATA),
   LOAD_TABLE(TABLE_READ_PROPERTIES),
   LOAD_TABLE_WITH_READ_DELEGATION(TABLE_READ_DATA),
   LOAD_TABLE_WITH_WRITE_DELEGATION(TABLE_WRITE_DATA),
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
index 776a0a8b4a..4a4d0b3b26 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalog.java
@@ -292,6 +292,26 @@ public class IcebergCatalog extends 
BaseMetastoreViewCatalog
 
   @Override
   public Table registerTable(TableIdentifier identifier, String 
metadataFileLocation) {
+    return registerTable(identifier, metadataFileLocation, false);
+  }
+
+  /**
+   * Register a table with optional overwrite semantics.
+   *
+   * <p>When {@code overwrite} is false (the default) this behaves like a 
normal register and will
+   * fail if the table already exists. When {@code overwrite} is true and the 
named table already
+   * exists, this method updates the table's stored metadata-location to point 
at the provided
+   * metadata file. The overwrite path performs additional validation to 
ensure the supplied
+   * metadata file and its location are consistent with the table's resolved 
storage configuration.
+   *
+   * @param identifier the table identifier
+   * @param metadataFileLocation the metadata file location
+   * @param overwrite if true, update existing table metadata; if false, throw 
exception if table
+   *     exists
+   * @return the registered table
+   */
+  public Table registerTable(
+      TableIdentifier identifier, String metadataFileLocation, boolean 
overwrite) {
     Preconditions.checkArgument(
         identifier != null && isValidIdentifier(identifier), "Invalid 
identifier: %s", identifier);
     Preconditions.checkArgument(
@@ -305,13 +325,22 @@ public class IcebergCatalog extends 
BaseMetastoreViewCatalog
         metadataFileLocation);
 
     // Throw an exception if this table already exists in the catalog.
-    if (tableExists(identifier)) {
+    boolean tableExists = tableExists(identifier);
+    if (!overwrite && tableExists) {
       throw alreadyExistsExceptionForTableLikeEntity(
           identifier, PolarisEntitySubType.ICEBERG_TABLE);
     }
 
     String locationDir = metadataFileLocation.substring(0, lastSlashIndex);
+    if (tableExists) {
+      overwriteRegisteredTable(identifier, metadataFileLocation, locationDir);
+    } else {
+      registerNewTable(identifier, metadataFileLocation, locationDir);
+    }
+  }
 
+  private Table registerNewTable(
+      TableIdentifier identifier, String metadataFileLocation, String 
locationDir) {
     TableOperations ops = newTableOps(identifier);
 
     PolarisResolvedPathWrapper resolvedParent =
@@ -336,6 +365,64 @@ public class IcebergCatalog extends 
BaseMetastoreViewCatalog
     return new BaseTable(ops, fullTableName(name(), identifier), 
metricsReporter());
   }
 
+  private Table overwriteRegisteredTable(
+      TableIdentifier identifier, String metadataFileLocation, String 
locationDir) {
+    PolarisResolvedPathWrapper resolvedPath =
+        resolvedEntityView.getPassthroughResolvedPath(
+            identifier, PolarisEntityType.TABLE_LIKE, 
PolarisEntitySubType.ANY_SUBTYPE);
+    if (resolvedPath == null || resolvedPath.getRawLeafEntity() == null) {
+      throw new NoSuchTableException("Table does not exist: %s", identifier);
+    }
+
+    validateLocationForTableLike(identifier, metadataFileLocation, 
resolvedPath);
+
+    FileIO fileIO =
+        loadFileIOForTableLike(
+            identifier,
+            Set.of(locationDir),
+            resolvedPath,
+            new HashMap<>(tableDefaultProperties),
+            Set.of(PolarisStorageActions.READ, PolarisStorageActions.LIST));
+
+    TableMetadata metadata = TableMetadataParser.read(fileIO, 
metadataFileLocation);
+    validateMetadataFileInTableDir(identifier, metadata);
+
+    List<PolarisEntity> resolvedNamespace = resolvedPath.getRawParentPath();
+    var tableLocations = StorageUtil.getLocationsUsedByTable(metadata);
+    CatalogUtils.validateLocationsForTableLike(
+        realmConfig, identifier, tableLocations, resolvedPath);
+    tableLocations.forEach(
+        location ->
+            validateNoLocationOverlap(
+                catalogEntity,
+                identifier,
+                resolvedNamespace,
+                location,
+                resolvedPath.getRawLeafEntity()));
+
+    PolarisEntity rawEntity = resolvedPath.getRawLeafEntity();
+    if (rawEntity.getSubType() != PolarisEntitySubType.ICEBERG_TABLE) {
+      throw alreadyExistsExceptionForTableLikeEntity(identifier, 
rawEntity.getSubType());
+    }
+
+    IcebergTableLikeEntity existingEntity = 
IcebergTableLikeEntity.of(rawEntity);
+    if (existingEntity == null) {
+      throw new NoSuchTableException("Table does not exist: %s", identifier);
+    }
+
+    Map<String, String> storedProperties = 
buildTableMetadataPropertiesMap(metadata);
+    IcebergTableLikeEntity updatedEntity =
+        new IcebergTableLikeEntity.Builder(existingEntity)
+            .setInternalProperties(storedProperties)
+            .setMetadataLocation(metadataFileLocation)
+            .build();
+
+    updateTableLike(identifier, updatedEntity);
+
+    TableOperations ops = newTableOps(identifier);
+    return new BaseTable(ops, fullTableName(name(), identifier), 
metricsReporter());
+  }
+
   @Override
   public TableBuilder buildTable(TableIdentifier identifier, Schema schema) {
     return new PolarisIcebergCatalogTableBuilder(identifier, schema);
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
index 88bcb595b0..07bb355098 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
@@ -602,13 +602,46 @@ public abstract class IcebergCatalogHandler extends 
CatalogHandler implements Au
    * @return ETagged {@link LoadTableResponse} to uniquely identify the table 
metadata
    */
   public LoadTableResponse registerTable(Namespace namespace, 
RegisterTableRequest request) {
-    PolarisAuthorizableOperation op = 
PolarisAuthorizableOperation.REGISTER_TABLE;
-    authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
-        op, TableIdentifier.of(namespace, request.name()));
+    TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
+    boolean overwrite = request.overwrite();
+
+    if (overwrite) {
+      authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
+          PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE, identifier);
+      return registerTableWithOverwrite(identifier, request);
+    }
 
+    // Creating new table requires REGISTER_TABLE privilege
+    PolarisAuthorizableOperation op = 
PolarisAuthorizableOperation.REGISTER_TABLE;
+    authorizeCreateTableLikeUnderNamespaceOperationOrThrow(op, identifier);
     return catalogHandlerUtils().registerTable(baseCatalog, namespace, 
request);
   }
 
+  private LoadTableResponse registerTableWithOverwrite(
+      TableIdentifier identifier, RegisterTableRequest request) {
+    // Handle Polaris-specific overwrite logic.
+    //
+    // NOTE: Register-table overwrite is currently only implemented for 
Polaris's
+    // IcebergCatalog. Federated catalogs are initialized as external catalog
+    // implementations (for example RESTCatalog) and do not expose this 
Polaris-
+    // specific overwrite contract, so overwrite requests are rejected below.
+    if (baseCatalog instanceof IcebergCatalog icebergCatalog) {
+      // Use the overwrite-capable registration path for IcebergCatalog
+      Table table = icebergCatalog.registerTable(identifier, 
request.metadataLocation(), true);
+      if (table instanceof BaseTable baseTable) {
+        TableMetadata metadata = baseTable.operations().current();
+        return LoadTableResponse.builder().withTableMetadata(metadata).build();
+      }
+      throw new IllegalStateException("Cannot wrap catalog that does not 
produce BaseTable");
+    }
+
+    // For non-Polaris/federated catalogs, reject overwrite until this is
+    // supported by a common catalog contract.
+    throw new BadRequestException(
+        "Register table overwrite is only supported for internal Polaris 
catalogs; unsupported catalog type: %s",
+        baseCatalog.getClass().getName());
+  }
+
   public boolean sendNotification(TableIdentifier identifier, 
NotificationRequest request) {
     PolarisAuthorizableOperation op = 
PolarisAuthorizableOperation.SEND_NOTIFICATIONS;
 
diff --git 
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogHandlerAuthzTest.java
 
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogHandlerAuthzTest.java
index 9a21cc0c86..838ec4c269 100644
--- 
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogHandlerAuthzTest.java
+++ 
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogHandlerAuthzTest.java
@@ -521,6 +521,78 @@ public abstract class 
AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
         .createTests();
   }
 
+  @TestFactory
+  Stream<DynamicNode> testRegisterTableInsufficientPermissions() {
+    assertSuccess(
+        adminService.grantPrivilegeOnCatalogToRole(
+            CATALOG_NAME, CATALOG_ROLE2, 
PolarisPrivilege.TABLE_READ_PROPERTIES));
+    assertSuccess(
+        adminService.grantPrivilegeOnCatalogToRole(
+            CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_CREATE));
+    assertSuccess(
+        adminService.grantPrivilegeOnCatalogToRole(
+            CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
+
+    final TableIdentifier sourceTable =
+        TableIdentifier.of(NS2, "register_table_insufficient_source");
+    final TableIdentifier targetTable =
+        TableIdentifier.of(NS2, "register_table_insufficient_target");
+
+    // Prepare a source metadata file under NS2, then drop the source table to 
avoid location
+    // overlap.
+    try {
+      newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(sourceTable);
+    } catch (RuntimeException ignored) {
+      // Table may not exist yet; cleanup pre-step is best-effort.
+    }
+    CreateTableRequest createSourceRequest =
+        
CreateTableRequest.builder().withName(sourceTable.name()).withSchema(SCHEMA).build();
+    newHandler(Set.of(PRINCIPAL_ROLE2)).createTableDirect(NS2, 
createSourceRequest);
+
+    final String metadataLocation =
+        newHandler(Set.of(PRINCIPAL_ROLE2)).loadTable(sourceTable, 
"all").metadataLocation();
+    newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(sourceTable);
+
+    final RegisterTableRequest registerRequest =
+        new RegisterTableRequest() {
+          @Override
+          public String name() {
+            return targetTable.name();
+          }
+
+          @Override
+          public String metadataLocation() {
+            return metadataLocation;
+          }
+        };
+
+    // Registering a new table requires TABLE_CREATE (or broader privileges).
+    return authzTestsBuilder("registerTable (insufficient)")
+        .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS2, 
registerRequest))
+        .cleanupAction(
+            () -> {
+              IcebergCatalogHandler cleanup = 
newHandler(Set.of(PRINCIPAL_ROLE2));
+              try {
+                cleanup.dropTableWithoutPurge(targetTable);
+              } catch (RuntimeException ignored) {
+                // Target table may not have been created; cleanup is 
best-effort.
+              }
+            })
+        .shouldPassWith(PolarisPrivilege.TABLE_CREATE)
+        .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
+        .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+        .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA)
+        .shouldFailWith(PolarisPrivilege.NAMESPACE_FULL_METADATA)
+        .shouldFailWith(PolarisPrivilege.VIEW_FULL_METADATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_DROP)
+        .shouldFailWith(PolarisPrivilege.TABLE_READ_PROPERTIES)
+        .shouldFailWith(PolarisPrivilege.TABLE_WRITE_PROPERTIES)
+        .shouldFailWith(PolarisPrivilege.TABLE_READ_DATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_WRITE_DATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_LIST)
+        .createTests();
+  }
+
   @TestFactory
   Stream<DynamicNode> testLoadTablePrivileges() {
     return authzTestsBuilder("loadTable")
@@ -533,6 +605,76 @@ public abstract class 
AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
         .createTests();
   }
 
+  @TestFactory
+  Stream<DynamicNode> testRegisterTableOverwriteSufficientPrivileges() {
+    // For overwrite, the caller needs TABLE_FULL_METADATA or higher 
privileges on the target
+    // table. This is stricter than UPDATE_TABLE, which only requires 
TABLE_WRITE_PROPERTIES,
+    // because overwriting involves both dropping the old table pointer and 
creating a new one.
+    // In Polaris's privilege model, TABLE_FULL_METADATA or a catalog-wide 
CATALOG_MANAGE_CONTENT
+    // are sufficient. This test verifies that each of those privileges, 
granted at the catalog
+    // level to the test role, is sufficient to perform a registerTable with 
overwrite=true.
+
+    assertSuccess(
+        adminService.grantPrivilegeOnCatalogToRole(
+            CATALOG_NAME, CATALOG_ROLE2, 
PolarisPrivilege.TABLE_READ_PROPERTIES));
+    final String metadataLocation =
+        newHandler(Set.of(PRINCIPAL_ROLE2)).loadTable(TABLE_NS1_1, 
"all").metadataLocation();
+
+    // Mock RegisterTableRequest with overwrite=true
+    final RegisterTableRequest registerRequest = 
Mockito.mock(RegisterTableRequest.class);
+    Mockito.when(registerRequest.name()).thenReturn(TABLE_NS1_1.name());
+    
Mockito.when(registerRequest.metadataLocation()).thenReturn(metadataLocation);
+    Mockito.when(registerRequest.overwrite()).thenReturn(true);
+
+    return authzTestsBuilder("registerTableOverwrite")
+        .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, 
registerRequest))
+        .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
+        .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+        .createTests();
+  }
+
+  @TestFactory
+  Stream<DynamicNode> testRegisterTableOverwriteInsufficientPermissions() {
+    /*
+     * Verifies that a variety of privileges are insufficient to authorize a 
registerTable operation
+     * with overwrite enabled. Grants only TABLE_READ_PROPERTIES to the 
cleanup role, mocks a
+     * RegisterTableRequest for overwriting an existing table, and asserts 
that privileges such as
+     * TABLE_WRITE_PROPERTIES (insufficient, only covers metadata updates), 
TABLE_CREATE, TABLE_DROP,
+     * NAMESPACE_FULL_METADATA, VIEW_FULL_METADATA, TABLE_READ_PROPERTIES, 
TABLE_READ_DATA, and
+     * TABLE_LIST do not permit the overwrite operation. Note that 
TABLE_CREATE and TABLE_DROP
+     * individually are insufficient - TABLE_FULL_METADATA (which combines all 
metadata operations)
+     * is the minimum required.
+     */
+
+    assertSuccess(
+        adminService.grantPrivilegeOnCatalogToRole(
+            CATALOG_NAME, CATALOG_ROLE2, 
PolarisPrivilege.TABLE_READ_PROPERTIES));
+    final String metadataLocation =
+        newHandler(Set.of(PRINCIPAL_ROLE2)).loadTable(TABLE_NS1_1, 
"all").metadataLocation();
+
+    // Mock RegisterTableRequest with overwrite=true
+    final RegisterTableRequest registerRequest = 
Mockito.mock(RegisterTableRequest.class);
+    Mockito.when(registerRequest.name()).thenReturn(TABLE_NS1_1.name());
+    
Mockito.when(registerRequest.metadataLocation()).thenReturn(metadataLocation);
+    Mockito.when(registerRequest.overwrite()).thenReturn(true);
+
+    return authzTestsBuilder("registerTableOverwrite (insufficient)")
+        .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, 
registerRequest))
+        .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
+        .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+        .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_WRITE_PROPERTIES)
+        .shouldFailWith(PolarisPrivilege.TABLE_WRITE_DATA)
+        .shouldFailWith(PolarisPrivilege.NAMESPACE_FULL_METADATA)
+        .shouldFailWith(PolarisPrivilege.VIEW_FULL_METADATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_CREATE)
+        .shouldFailWith(PolarisPrivilege.TABLE_DROP)
+        .shouldFailWith(PolarisPrivilege.TABLE_READ_PROPERTIES)
+        .shouldFailWith(PolarisPrivilege.TABLE_READ_DATA)
+        .shouldFailWith(PolarisPrivilege.TABLE_LIST)
+        .createTests();
+  }
+
   @TestFactory
   Stream<DynamicNode> testLoadTableIfStalePrivileges() {
     return authzTestsBuilder("loadTableIfStale")
diff --git 
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
 
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
index 857e833abf..00af417388 100644
--- 
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
+++ 
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
@@ -2193,6 +2193,85 @@ public abstract class AbstractIcebergCatalogTest extends 
CatalogTests<IcebergCat
         .hasMessageContaining("Invalid metadata file location");
   }
 
+  @Test
+  public void testRegisterTableOverwriteUpdatesMetadataLocation() {
+    IcebergCatalog catalog = catalog();
+    Namespace namespace = Namespace.of("register_overwrite_update");
+    TableIdentifier table = TableIdentifier.of(namespace, "table");
+    if (requiresNamespaceCreate()) {
+      catalog.createNamespace(namespace);
+    }
+
+    // Create initial table
+    Table created = catalog.buildTable(table, SCHEMA).create();
+    TableMetadata currentMetadata = ((BaseTable) 
created).operations().current();
+    String currentMetadataLocation = currentMetadata.metadataFileLocation();
+    String oldTableUuid = currentMetadata.uuid();
+    String newTableUuid = UUID.randomUUID().toString();
+    String metadataDir =
+        currentMetadataLocation.substring(0, 
currentMetadataLocation.lastIndexOf('/') + 1);
+    String newMetadataLocation = metadataDir + "overwrite-v1.metadata.json";
+    String updatedMetadataJson =
+        TableMetadataParser.toJson(currentMetadata).replace(oldTableUuid, 
newTableUuid);
+    fileIO.addFile(newMetadataLocation, updatedMetadataJson.getBytes(UTF_8));
+
+    // Register with overwrite=true should update the metadata location
+    Table overwritten = catalog.registerTable(table, newMetadataLocation, 
true);
+
+    Assertions.assertThat(((BaseTable) 
overwritten).operations().current().metadataFileLocation())
+        .isEqualTo(newMetadataLocation);
+    Assertions.assertThat(((BaseTable) 
overwritten).operations().current().uuid())
+        .isEqualTo(newTableUuid);
+    Assertions.assertThat(
+            ((BaseTable) 
catalog.loadTable(table)).operations().current().metadataFileLocation())
+        .isEqualTo(newMetadataLocation);
+    Assertions.assertThat(((BaseTable) 
catalog.loadTable(table)).operations().current().uuid())
+        .isEqualTo(newTableUuid);
+  }
+
+  @Test
+  public void testRegisterTableOverwriteCreatesWhenMissing() {
+    IcebergCatalog catalog = catalog();
+    Namespace namespace = Namespace.of("register_overwrite_create");
+    TableIdentifier sourceTable = TableIdentifier.of(namespace, 
"source_table");
+    TableIdentifier targetTable = TableIdentifier.of(namespace, 
"target_table");
+    if (requiresNamespaceCreate()) {
+      catalog.createNamespace(namespace);
+    }
+
+    // Create source table and get its metadata location
+    Table source = catalog.buildTable(sourceTable, SCHEMA).create();
+    String metadataLocation = ((BaseTable) 
source).operations().current().metadataFileLocation();
+
+    // Register target table with overwrite=true when it doesn't exist should 
create it
+    Table registered = catalog.registerTable(targetTable, metadataLocation, 
true);
+
+    Assertions.assertThat(registered).isInstanceOf(BaseTable.class);
+    Assertions.assertThat(
+            ((BaseTable) catalog.loadTable(targetTable))
+                .operations()
+                .current()
+                .metadataFileLocation())
+        .isEqualTo(metadataLocation);
+  }
+
+  @Test
+  public void testRegisterTableOverwriteFalseRejectsExistingTable() {
+    IcebergCatalog catalog = catalog();
+    Namespace namespace = Namespace.of("register_overwrite_conflict");
+    TableIdentifier table = TableIdentifier.of(namespace, "table");
+    if (requiresNamespaceCreate()) {
+      catalog.createNamespace(namespace);
+    }
+
+    // Create table and try to register with overwrite=false should throw
+    catalog.buildTable(table, SCHEMA).create();
+
+    Assertions.assertThatThrownBy(() -> catalog.registerTable(table, 
"s3://bucket/path", false))
+        .isInstanceOf(AlreadyExistsException.class)
+        .hasMessageContaining("Table already exists");
+  }
+
   @Test
   public void 
testConcurrencyConflictCreateTableUpdatedDuringFinalTransaction() {
     Assumptions.assumeTrue(
diff --git 
a/site/content/in-dev/unreleased/federation/iceberg-rest-federation.md 
b/site/content/in-dev/unreleased/federation/iceberg-rest-federation.md
index 23e7ca4b0e..c04abacc45 100644
--- a/site/content/in-dev/unreleased/federation/iceberg-rest-federation.md
+++ b/site/content/in-dev/unreleased/federation/iceberg-rest-federation.md
@@ -104,5 +104,9 @@ Connection config properties (URI and authentication) take 
precedence if the sam
   the REST endpoint is unreachable or authentication is rejected.
 - **Feature parity:** Federation exposes whatever table/namespace operations 
the remote service
   implements. Unsupported features return the remote error directly to callers.
+- **Register table overwrite:** `POST 
/v1/{catalog}/namespaces/{namespace}/register` with
+  `overwrite=true` is currently supported only for internal Polaris catalogs.
+  For federated external catalogs, Polaris rejects
+  overwrite registration requests.
 - **Generic tables:** The REST federation path currently surfaces Iceberg 
tables only; generic table
   federation is not implemented.

Reply via email to