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.