You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by ad...@apache.org on 2026/06/05 11:41:31 UTC
(polaris) branch main updated: Support access delegation in registerTable (#3734)
This is an automated email from the ASF dual-hosted git repository.
adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 4c6bffbc19 Support access delegation in registerTable (#3734)
4c6bffbc19 is described below
commit 4c6bffbc19afc70d5dd6e3940d949a06c2c5b140
Author: Alexandre Dutra <ad...@apache.org>
AuthorDate: Fri Jun 5 13:41:26 2026 +0200
Support access delegation in registerTable (#3734)
This PR introduces support for access delegation in the `registerTable` endpoint.
Four new authorizable operations are introduced:
- `REGISTER_TABLE_WITH_READ_DELEGATION`: requires `TABLE_CREATE` + `TABLE_READ_DATA`
- `REGISTER_TABLE_WITH_WRITE_DELEGATION`: requires `TABLE_CREATE` + `TABLE_WRITE_DATA`
- `REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION`: requires `TABLE_FULL_METADATA`+ `TABLE_READ_DATA`
- `REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION`: requires `TABLE_FULL_METADATA` + `TABLE_WRITE_DATA`
The implementation is inspired by the `loadTable` endpoint; the authorization checks are very similar.
---
.../ranger/RangerPolarisOperationSemantics.java | 16 +
.../authz_tests/tests_authz_namespace.json | 8 +
.../resources/authz_tests/tests_authz_table.json | 8 +
.../impl/src/test/resources/logback-test.xml | 32 ++
.../apache/polaris/service/it/env/CatalogApi.java | 60 ++++
.../it/test/PolarisRestCatalogIntegrationBase.java | 339 +++++++++++++++-----
.../core/auth/PolarisAuthorizableOperation.java | 4 +
.../polaris/core/auth/RbacOperationSemantics.java | 12 +
.../service/it/PolarisRestCatalogMinIOIT.java | 12 -
.../service/catalog/common/CatalogHandler.java | 13 +-
.../catalog/iceberg/IcebergCatalogAdapter.java | 12 +-
.../catalog/iceberg/IcebergCatalogHandler.java | 113 +++++--
.../AbstractIcebergCatalogHandlerAuthzTest.java | 345 +++++++++++++++++----
13 files changed, 787 insertions(+), 187 deletions(-)
diff --git a/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java b/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
index 30362bf5cd..f8436d9024 100644
--- a/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
+++ b/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
@@ -170,6 +170,22 @@ public record RangerPolarisOperationSemantics(
PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE,
new RangerPolarisOperationSemantics(
toSet(TABLE_METADATA_FULL), null, ResolvedPathRooting.ROOT));
+ RBAC_SEMANTICS_BY_OPERATION.put(
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_READ_DELEGATION,
+ new RangerPolarisOperationSemantics(
+ toSet(TABLE_CREATE, TABLE_READ_DATA), null, ResolvedPathRooting.ROOT));
+ RBAC_SEMANTICS_BY_OPERATION.put(
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_WRITE_DELEGATION,
+ new RangerPolarisOperationSemantics(
+ toSet(TABLE_CREATE, TABLE_WRITE_DATA), null, ResolvedPathRooting.ROOT));
+ RBAC_SEMANTICS_BY_OPERATION.put(
+ PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION,
+ new RangerPolarisOperationSemantics(
+ toSet(TABLE_METADATA_FULL, TABLE_READ_DATA), null, ResolvedPathRooting.ROOT));
+ RBAC_SEMANTICS_BY_OPERATION.put(
+ PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION,
+ new RangerPolarisOperationSemantics(
+ toSet(TABLE_METADATA_FULL, TABLE_WRITE_DATA), null, ResolvedPathRooting.ROOT));
RBAC_SEMANTICS_BY_OPERATION.put(
PolarisAuthorizableOperation.LOAD_TABLE,
new RangerPolarisOperationSemantics(
diff --git a/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_namespace.json b/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_namespace.json
index 7ceba2389c..ea21e43e67 100644
--- a/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_namespace.json
+++ b/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_namespace.json
@@ -44,6 +44,14 @@
"request": { "authzOp": "REGISTER_TABLE", "principal": { "name": "admin1" }, "target": "NAMESPACE:POLARIS/catalog1/namespace1" },
"result": { "isAllowed": true }
},
+ {
+ "request": { "authzOp": "REGISTER_TABLE_WITH_READ_DELEGATION", "principal": { "name": "admin1" }, "target": "NAMESPACE:POLARIS/catalog1/namespace1" },
+ "result": { "isAllowed": true }
+ },
+ {
+ "request": { "authzOp": "REGISTER_TABLE_WITH_WRITE_DELEGATION", "principal": { "name": "admin1" }, "target": "NAMESPACE:POLARIS/catalog1/namespace1" },
+ "result": { "isAllowed": true }
+ },
{
"request": { "authzOp": "UPDATE_TABLE_FOR_STAGED_CREATE", "principal": { "name": "admin1" }, "target": "NAMESPACE:POLARIS/catalog1/namespace1" },
"result": { "isAllowed": true }
diff --git a/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_table.json b/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_table.json
index cf6e353236..90ebaa2b0b 100644
--- a/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_table.json
+++ b/extensions/auth/ranger/impl/src/test/resources/authz_tests/tests_authz_table.json
@@ -120,6 +120,14 @@
"request": { "authzOp": "REGISTER_TABLE_OVERWRITE", "principal": { "name": "admin1" }, "target": "TABLE_LIKE:POLARIS/catalog1/namespace1/table1" },
"result": { "isAllowed": true }
},
+ {
+ "request": { "authzOp": "REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION", "principal": { "name": "admin1" }, "target": "TABLE_LIKE:POLARIS/catalog1/namespace1/table1" },
+ "result": { "isAllowed": true }
+ },
+ {
+ "request": { "authzOp": "REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION", "principal": { "name": "admin1" }, "target": "TABLE_LIKE:POLARIS/catalog1/namespace1/table1" },
+ "result": { "isAllowed": true }
+ },
{
"request": { "authzOp": "DROP_VIEW", "principal": { "name": "admin1" }, "target": "TABLE_LIKE:POLARIS/catalog1/namespace1/view1" },
"result": { "isAllowed": true }
diff --git a/extensions/auth/ranger/impl/src/test/resources/logback-test.xml b/extensions/auth/ranger/impl/src/test/resources/logback-test.xml
new file mode 100644
index 0000000000..b7e97bb826
--- /dev/null
+++ b/extensions/auth/ranger/impl/src/test/resources/logback-test.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ 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.
+
+-->
+<configuration debug="false">
+ <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"/>
+ <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+ <root level="${test.log.level:-ERROR}">
+ <appender-ref ref="console"/>
+ </root>
+</configuration>
diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/env/CatalogApi.java b/integration-tests/src/main/java/org/apache/polaris/service/it/env/CatalogApi.java
index 6546798be6..d589e0d889 100644
--- a/integration-tests/src/main/java/org/apache/polaris/service/it/env/CatalogApi.java
+++ b/integration-tests/src/main/java/org/apache/polaris/service/it/env/CatalogApi.java
@@ -178,6 +178,66 @@ public class CatalogApi extends PolarisRestApi {
}
}
+ public LoadTableResponse registerTable(
+ String catalog,
+ Namespace namespace,
+ String tableName,
+ String metadataLocation,
+ boolean overwrite) {
+ return registerTable(catalog, namespace, tableName, metadataLocation, overwrite, Map.of());
+ }
+
+ public LoadTableResponse registerTableWithAccessDelegation(
+ String catalog,
+ Namespace namespace,
+ String tableName,
+ String metadataLocation,
+ boolean overwrite) {
+ return registerTable(
+ catalog,
+ namespace,
+ tableName,
+ metadataLocation,
+ overwrite,
+ Map.of("X-Iceberg-Access-Delegation", "vended-credentials"));
+ }
+
+ public LoadTableResponse registerTable(
+ String catalog,
+ Namespace namespace,
+ String tableName,
+ String metadataLocation,
+ boolean overwrite,
+ Map<String, String> headers) {
+ HashMap<String, String> allHeaders = new HashMap<>(defaultHeaders());
+ allHeaders.putAll(headers);
+
+ String ns = NamespaceUtils.joinNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+ try (Response res =
+ request(
+ "v1/{cat}/namespaces/{ns}/register",
+ Map.of("cat", catalog, "ns", ns),
+ Map.of(),
+ allHeaders)
+ .post(
+ Entity.json(
+ Map.of(
+ "name",
+ tableName,
+ "metadata-location",
+ metadataLocation,
+ "overwrite",
+ overwrite)))) {
+ if (res.getStatus() == Response.Status.OK.getStatusCode()) {
+ return res.readEntity(LoadTableResponse.class);
+ }
+ throw new RESTException(
+ "Unhandled error: %s",
+ ((ErrorHandler) ErrorHandlers.defaultErrorHandler())
+ .parseResponse(res.getStatus(), res.readEntity(String.class)));
+ }
+ }
+
public LoadTableResponse loadTable(String catalog, TableIdentifier id, String snapshots) {
return loadTable(catalog, id, snapshots, Map.of());
}
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 6895d087c2..ee99b07684 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
@@ -60,6 +60,7 @@ import org.apache.iceberg.catalog.CatalogTests;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableCommit;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.exceptions.RESTException;
@@ -746,7 +747,9 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
resolvingFileIO.setConf(new Configuration());
String fileLocation = externalCatalogBaseLocation + "/ns1/my_table/metadata/v1.metadata.json";
TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
- restCatalog.registerTable(TableIdentifier.of(ns1, "my_table"), fileLocation);
+ // Use the REST API directly to bypass requesting vended credentials, otherwise the
+ // register-table operation would fail like the load-table one below.
+ catalogApi.registerTable(currentCatalogName, ns1, "my_table", fileLocation, false);
try {
Assertions.assertThatThrownBy(
() -> restCatalog.loadTable(TableIdentifier.of(ns1, "my_table")))
@@ -941,13 +944,15 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
* existing table metadata. Verifies that the overwrite is applied and the catalog reflects the
* updated metadata.
*/
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", ""})
@Test
- public void testRegisterTableOverwriteViaRest() {
+ public void testRegisterTableOverwrite() {
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();
+ TableIdentifier ident = TableIdentifier.of(ns, "source_table");
+ Table source = restCatalog.buildTable(ident, SCHEMA).create();
String currentMetadataLocation =
((BaseTable) source).operations().current().metadataFileLocation();
String metadataDir =
@@ -963,31 +968,18 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
((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 {
+ // register with overwrite=true
+ restCatalog.registerTable(ident, newMetadataLocation, 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(ident);
+ assertThat(((BaseTable) loaded).operations().current().metadataFileLocation())
+ .isEqualTo(newMetadataLocation);
+ } finally {
+ // Clean up the old metadata file
+ resolvingFileIO.deleteFile(currentMetadataLocation);
}
-
- // 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 old metadata file
- resolvingFileIO.deleteFile(currentMetadataLocation);
}
}
@@ -997,6 +989,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
* must receive a 400 Bad Request.
*/
@CatalogConfig(Catalog.TypeEnum.EXTERNAL)
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", ""})
@Test
public void testRegisterTableOverwriteRejectedForExternalCatalog() {
Namespace ns = Namespace.of("ns_overwrite_ext");
@@ -1014,28 +1007,234 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
String fileLocation =
externalCatalogBaseLocation + "/ns_overwrite_ext/my_table/metadata/v1.metadata.json";
+ try {
+ TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
+
+ // Register the table without overwrite first (must succeed)
+ restCatalog.registerTable(TableIdentifier.of(ns, "my_table"), fileLocation);
+
+ // Now attempt to register again with overwrite=true — must be rejected with 400
+ assertThatThrownBy(
+ () ->
+ restCatalog.registerTable(
+ TableIdentifier.of(ns, "my_table"), fileLocation, true))
+ .isInstanceOf(BadRequestException.class)
+ .hasMessageContaining(
+ "Register table overwrite is only supported for internal Polaris catalogs");
+ } finally {
+ resolvingFileIO.deleteFile(fileLocation);
+ }
+ }
+ }
+
+ /**
+ * Create an INTERNAL catalog. Register a table WITH access delegation and verify that the
+ * registerTable response contains vended credentials (when supported by the storage type).
+ */
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
+ @Test
+ public void testRegisterTableWithAccessDelegation() {
+ Namespace ns1 = Namespace.of("ns1");
+ restCatalog.createNamespace(ns1);
+ TableMetadata tableMetadata =
+ TableMetadata.newTableMetadata(
+ new Schema(List.of(Types.NestedField.required(1, "col1", new Types.StringType()))),
+ PartitionSpec.unpartitioned(),
+ catalogBaseLocation + "/ns1/my_table",
+ Map.of());
+ try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+ initializeClientFileIO(resolvingFileIO);
+ resolvingFileIO.setConf(new Configuration());
+ String fileLocation = catalogBaseLocation + "/ns1/my_table/metadata/v1.metadata.json";
TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
+ try {
+ LoadTableResponse registerResponse =
+ catalogApi.registerTableWithAccessDelegation(
+ currentCatalogName, ns1, "my_table", fileLocation, false);
+
+ assertThat(registerResponse).isNotNull();
+ assertThat(registerResponse.tableMetadata()).isNotNull();
+ assertThat(registerResponse.tableMetadata().location()).isEqualTo(tableMetadata.location());
+ assertThat(registerResponse.metadataLocation()).isEqualTo(fileLocation);
+
+ if (getStorageConfigInfo().getStorageType() != StorageConfigInfo.StorageTypeEnum.FILE) {
+ assertThat(registerResponse.credentials())
+ .as("Cloud storage should vend credentials")
+ .isNotEmpty();
+ }
+ } finally {
+ resolvingFileIO.deleteFile(fileLocation);
+ }
+ }
+ }
- // Register the table without overwrite first (must succeed)
- restCatalog.registerTable(TableIdentifier.of(ns, "my_table"), fileLocation);
+ /**
+ * Create an EXTERNAL catalog with credential vending enabled. Register a table WITH access
+ * delegation and verify that the registerTable response contains vended credentials (when
+ * supported by the storage type).
+ */
+ @CatalogConfig(
+ value = Catalog.TypeEnum.EXTERNAL,
+ properties = {"polaris.config.enable.credential.vending", "true"})
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
+ @Test
+ public void testRegisterTableWithAccessDelegationForExternalCatalog() {
+ Namespace ns1 = Namespace.of("ns1");
+ restCatalog.createNamespace(ns1);
+ TableMetadata tableMetadata =
+ TableMetadata.newTableMetadata(
+ new Schema(List.of(Types.NestedField.required(1, "col1", new Types.StringType()))),
+ PartitionSpec.unpartitioned(),
+ externalCatalogBaseLocation + "/ns1/my_table",
+ Map.of());
+ try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+ initializeClientFileIO(resolvingFileIO);
+ resolvingFileIO.setConf(new Configuration());
+ String fileLocation = externalCatalogBaseLocation + "/ns1/my_table/metadata/v1.metadata.json";
+ TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
+ try {
+ LoadTableResponse registerResponse =
+ catalogApi.registerTableWithAccessDelegation(
+ currentCatalogName, ns1, "my_table", fileLocation, false);
+
+ assertThat(registerResponse).isNotNull();
+ assertThat(registerResponse.tableMetadata()).isNotNull();
+ assertThat(registerResponse.tableMetadata().location()).isEqualTo(tableMetadata.location());
+ assertThat(registerResponse.metadataLocation()).isEqualTo(fileLocation);
+
+ if (getStorageConfigInfo().getStorageType() != StorageConfigInfo.StorageTypeEnum.FILE) {
+ assertThat(registerResponse.credentials())
+ .as("Cloud storage should vend credentials")
+ .isNotEmpty();
+ }
+ } finally {
+ resolvingFileIO.deleteFile(fileLocation);
+ }
+ }
+ }
- // Now attempt to register again with overwrite=true — must be rejected with 400
- Invocation overwriteInvocation =
- catalogApi
- .request("v1/" + currentCatalogName + "/namespaces/ns_overwrite_ext/register")
- .buildPost(
- Entity.json(
- Map.of(
- "name",
- "my_table",
- "metadata-location",
- fileLocation,
- "overwrite",
- true)));
-
- try (Response overwriteResponse = overwriteInvocation.invoke()) {
- assertThat(overwriteResponse.getStatus())
- .isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ /**
+ * Create an EXTERNAL catalog. The test configuration, by default, disables access delegation for
+ * EXTERNAL catalogs, so try to register a table with the REST client configured to try to fetch
+ * vended credentials. Expect an IllegalArgumentException (HTTP 400 Bad Request).
+ */
+ @CatalogConfig(Catalog.TypeEnum.EXTERNAL)
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
+ @Test
+ public void testRegisterTableWithAccessDelegationForExternalCatalogWithConfigDisabled() {
+ Namespace ns1 = Namespace.of("ns1");
+ restCatalog.createNamespace(ns1);
+ TableMetadata tableMetadata =
+ TableMetadata.newTableMetadata(
+ new Schema(List.of(Types.NestedField.required(1, "col1", new Types.StringType()))),
+ PartitionSpec.unpartitioned(),
+ externalCatalogBaseLocation + "/ns1/my_table",
+ Map.of());
+ try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+ initializeClientFileIO(resolvingFileIO);
+ resolvingFileIO.setConf(new Configuration());
+ String fileLocation = externalCatalogBaseLocation + "/ns1/my_table/metadata/v1.metadata.json";
+ TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
+ try {
+ assertThatThrownBy(
+ () ->
+ restCatalog.registerTable(
+ TableIdentifier.of(ns1, "my_table"), fileLocation, false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("is not enabled for this external catalog")
+ .hasMessageContaining(
+ FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig());
+ } finally {
+ resolvingFileIO.deleteFile(fileLocation);
+ }
+ }
+ }
+
+ /**
+ * Create an INTERNAL catalog. Register a table WITH access delegation + overwrite and verify that
+ * the registerTable response contains vended credentials (when supported by the storage type).
+ */
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
+ @Test
+ public void testRegisterTableOverwriteWithAccessDelegation() {
+ Namespace ns1 = Namespace.of("ns1");
+ restCatalog.createNamespace(ns1);
+
+ // Create a source table and capture its metadata location
+ TableIdentifier tableIdentifier = TableIdentifier.of(ns1, "tbl1");
+ Table source = restCatalog.createTable(tableIdentifier, SCHEMA);
+ assertThat(source.location()).isNotNull();
+ TableMetadata tableMetadata = ((BaseTable) source).operations().current();
+ 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));
+
+ try {
+
+ LoadTableResponse registerResponse =
+ catalogApi.registerTableWithAccessDelegation(
+ currentCatalogName, ns1, "tbl1", tableMetadata.metadataFileLocation(), true);
+
+ assertThat(registerResponse).isNotNull();
+ assertThat(registerResponse.tableMetadata()).isNotNull();
+ assertThat(registerResponse.tableMetadata().location()).isEqualTo(tableMetadata.location());
+ assertThat(registerResponse.metadataLocation())
+ .isEqualTo(tableMetadata.metadataFileLocation());
+
+ if (getStorageConfigInfo().getStorageType() != StorageConfigInfo.StorageTypeEnum.FILE) {
+ assertThat(registerResponse.credentials())
+ .as("Cloud storage should vend credentials")
+ .isNotEmpty();
+ }
+
+ } finally {
+ // Clean up the old metadata file
+ resolvingFileIO.deleteFile(currentMetadataLocation);
+ }
+ }
+ }
+
+ /**
+ * Create an EXTERNAL catalog. Try to register with overwrite a table and request fetch vended
+ * credentials. Expect an IllegalArgumentException (HTTP 400 Bad Request) because overwrite is not
+ * enabled for external catalogs.
+ */
+ @CatalogConfig(Catalog.TypeEnum.EXTERNAL)
+ @RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
+ @Test
+ public void testRegisterTableOverwriteWithAccessDelegationForExternalCatalogForbidden() {
+ Namespace ns1 = Namespace.of("ns1");
+ restCatalog.createNamespace(ns1);
+ TableMetadata tableMetadata =
+ TableMetadata.newTableMetadata(
+ new Schema(List.of(Types.NestedField.required(1, "col1", new Types.StringType()))),
+ PartitionSpec.unpartitioned(),
+ externalCatalogBaseLocation + "/ns1/my_table",
+ Map.of());
+ try (ResolvingFileIO resolvingFileIO = new ResolvingFileIO()) {
+ initializeClientFileIO(resolvingFileIO);
+ resolvingFileIO.setConf(new Configuration());
+ String fileLocation = externalCatalogBaseLocation + "/ns1/my_table/metadata/v1.metadata.json";
+ TableMetadataParser.write(tableMetadata, resolvingFileIO.newOutputFile(fileLocation));
+ try {
+ assertThatThrownBy(
+ () ->
+ restCatalog.registerTable(
+ TableIdentifier.of(ns1, "my_table"), fileLocation, true))
+ .isInstanceOf(BadRequestException.class)
+ .hasMessageContaining(
+ "Register table overwrite is only supported for internal Polaris catalogs");
} finally {
resolvingFileIO.deleteFile(fileLocation);
}
@@ -1600,7 +1799,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
"v1/{cat}/namespaces/{ns}/views/{view}",
Map.of("cat", currentCatalogName, "ns", namespace.toString(), "view", viewName))
.head()) {
- assertThat(response).returns(Response.Status.NOT_FOUND.getStatusCode(), Response::getStatus);
+ assertThat(response).returns(NOT_FOUND.getStatusCode(), Response::getStatus);
}
// New view should exist
@@ -1643,7 +1842,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
GenericTable createResponse =
genericTableApi.createGenericTable(
currentCatalogName, tableIdentifier, "format", Map.of());
- Assertions.assertThat(createResponse.getFormat()).isEqualTo("format");
+ assertThat(createResponse.getFormat()).isEqualTo("format");
} finally {
genericTableApi.purge(currentCatalogName, namespace);
}
@@ -1660,7 +1859,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
GenericTable loadResponse =
genericTableApi.getGenericTable(currentCatalogName, tableIdentifier);
- Assertions.assertThat(loadResponse.getFormat()).isEqualTo("format");
+ assertThat(loadResponse.getFormat()).isEqualTo("format");
} finally {
genericTableApi.purge(currentCatalogName, namespace);
@@ -1681,9 +1880,8 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
List<TableIdentifier> identifiers =
genericTableApi.listGenericTables(currentCatalogName, namespace);
- Assertions.assertThat(identifiers).hasSize(2);
- Assertions.assertThat(identifiers)
- .containsExactlyInAnyOrder(tableIdentifier1, tableIdentifier2);
+ assertThat(identifiers).hasSize(2);
+ assertThat(identifiers).containsExactlyInAnyOrder(tableIdentifier1, tableIdentifier2);
} finally {
genericTableApi.purge(currentCatalogName, namespace);
}
@@ -1700,7 +1898,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
GenericTable loadResponse =
genericTableApi.getGenericTable(currentCatalogName, tableIdentifier);
- Assertions.assertThat(loadResponse.getFormat()).isEqualTo("format");
+ assertThat(loadResponse.getFormat()).isEqualTo("format");
genericTableApi.dropGenericTable(currentCatalogName, tableIdentifier);
@@ -1875,8 +2073,8 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
.setDoc("doc")
.setProperties(Map.of("polaris.reserved", "true"))
.build()))) {
- Assertions.assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
- Assertions.assertThat(res.readEntity(String.class)).contains("reserved prefix");
+ assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ assertThat(res.readEntity(String.class)).contains("reserved prefix");
}
genericTableApi.purge(currentCatalogName, namespace);
@@ -1889,7 +2087,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
() -> {
restCatalog.createNamespace(namespace, ImmutableMap.of("polaris.reserved", "true"));
})
- .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class)
+ .isInstanceOf(BadRequestException.class)
.hasMessageContaining("reserved prefix");
}
@@ -1898,11 +2096,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
Namespace namespace = Namespace.of("ns1");
restCatalog.createNamespace(namespace, ImmutableMap.of("a", "b"));
restCatalog.setProperties(namespace, ImmutableMap.of("c", "d"));
- Assertions.assertThatCode(
+ assertThatCode(
() -> {
restCatalog.setProperties(namespace, ImmutableMap.of("polaris.reserved", "true"));
})
- .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class)
+ .isInstanceOf(BadRequestException.class)
.hasMessageContaining("reserved prefix");
genericTableApi.purge(currentCatalogName, namespace);
}
@@ -1912,11 +2110,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
Namespace namespace = Namespace.of("ns1");
restCatalog.createNamespace(namespace, ImmutableMap.of("a", "b"));
restCatalog.removeProperties(namespace, Sets.newHashSet("a"));
- Assertions.assertThatCode(
+ assertThatCode(
() -> {
restCatalog.removeProperties(namespace, Sets.newHashSet("polaris.reserved"));
})
- .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class)
+ .isInstanceOf(BadRequestException.class)
.hasMessageContaining("reserved prefix");
genericTableApi.purge(currentCatalogName, namespace);
}
@@ -1926,7 +2124,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
Namespace namespace = Namespace.of("ns1");
restCatalog.createNamespace(namespace);
TableIdentifier identifier = TableIdentifier.of(namespace, "t1");
- Assertions.assertThatCode(
+ assertThatCode(
() -> {
restCatalog.createTable(
identifier,
@@ -1945,7 +2143,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
restCatalog.createNamespace(namespace);
TableIdentifier identifier = TableIdentifier.of(namespace, "t1");
restCatalog.createTable(identifier, SCHEMA);
- Assertions.assertThatCode(
+ assertThatCode(
() -> {
var txn =
restCatalog.newReplaceTableTransaction(
@@ -2072,7 +2270,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
try (Response response = invocation.invoke()) {
// Should return 404 Not Found regardless of If-None-Match header
- assertThat(response.getStatus()).isEqualTo(Response.Status.NOT_FOUND.getStatusCode());
+ assertThat(response.getStatus()).isEqualTo(NOT_FOUND.getStatusCode());
}
}
@@ -2440,8 +2638,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
}
try {
- Assertions.assertThat(catalogApi.listNamespaces(currentCatalogName, Namespace.empty()))
- .hasSize(20);
+ assertThat(catalogApi.listNamespaces(currentCatalogName, Namespace.empty())).hasSize(20);
for (var pageSize : List.of(1, 2, 3, 9, 10, 11, 19, 20, 21, 2000)) {
int total = 0;
String pageToken = null;
@@ -2449,13 +2646,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
ListNamespacesResponse response =
catalogApi.listNamespaces(
currentCatalogName, Namespace.empty(), pageToken, String.valueOf(pageSize));
- Assertions.assertThat(response.namespaces().size()).isLessThanOrEqualTo(pageSize);
+ assertThat(response.namespaces().size()).isLessThanOrEqualTo(pageSize);
total += response.namespaces().size();
pageToken = response.nextPageToken();
} while (pageToken != null);
- Assertions.assertThat(total)
- .as("Total paginated results for pageSize = " + pageSize)
- .isEqualTo(20);
+ assertThat(total).as("Total paginated results for pageSize = " + pageSize).isEqualTo(20);
}
} finally {
for (int i = 0; i < 20; i++) {
@@ -2475,7 +2670,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
}
try {
- Assertions.assertThat(catalogApi.listTables(currentCatalogName, namespace)).hasSize(20);
+ assertThat(catalogApi.listTables(currentCatalogName, namespace)).hasSize(20);
for (var pageSize : List.of(1, 2, 3, 9, 10, 11, 19, 20, 21, 2000)) {
int total = 0;
String pageToken = null;
@@ -2483,13 +2678,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
ListTablesResponse response =
catalogApi.listTables(
currentCatalogName, namespace, pageToken, String.valueOf(pageSize));
- Assertions.assertThat(response.identifiers().size()).isLessThanOrEqualTo(pageSize);
+ assertThat(response.identifiers().size()).isLessThanOrEqualTo(pageSize);
total += response.identifiers().size();
pageToken = response.nextPageToken();
} while (pageToken != null);
- Assertions.assertThat(total)
- .as("Total paginated results for pageSize = " + pageSize)
- .isEqualTo(20);
+ assertThat(total).as("Total paginated results for pageSize = " + pageSize).isEqualTo(20);
}
} finally {
for (int i = 0; i < 20; i++) {
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 8f6dedb13f..c016749459 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
@@ -33,6 +33,10 @@ public enum PolarisAuthorizableOperation {
CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION,
REGISTER_TABLE,
REGISTER_TABLE_OVERWRITE,
+ REGISTER_TABLE_WITH_READ_DELEGATION,
+ REGISTER_TABLE_WITH_WRITE_DELEGATION,
+ REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION,
+ REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION,
LOAD_TABLE,
LOAD_TABLE_WITH_READ_DELEGATION,
LOAD_TABLE_WITH_WRITE_DELEGATION,
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/RbacOperationSemantics.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/RbacOperationSemantics.java
index b0c66efbc2..f63999959d 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/auth/RbacOperationSemantics.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/RbacOperationSemantics.java
@@ -87,6 +87,10 @@ import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.LOAD_VIE
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.NAMESPACE_EXISTS;
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE;
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE;
+import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION;
+import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION;
+import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE_WITH_READ_DELEGATION;
+import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_TABLE_WITH_WRITE_DELEGATION;
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REGISTER_VIEW;
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REMOVE_TABLE_PARTITION_SPECS;
import static org.apache.polaris.core.auth.PolarisAuthorizableOperation.REMOVE_TABLE_PROPERTIES;
@@ -290,6 +294,14 @@ record RbacOperationSemantics(
register(CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION, EnumSet.of(TABLE_CREATE, TABLE_WRITE_DATA));
register(REGISTER_TABLE, TABLE_CREATE);
register(REGISTER_TABLE_OVERWRITE, TABLE_FULL_METADATA);
+ register(REGISTER_TABLE_WITH_READ_DELEGATION, EnumSet.of(TABLE_CREATE, TABLE_READ_DATA));
+ register(REGISTER_TABLE_WITH_WRITE_DELEGATION, EnumSet.of(TABLE_CREATE, TABLE_WRITE_DATA));
+ register(
+ REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION,
+ EnumSet.of(TABLE_FULL_METADATA, TABLE_READ_DATA));
+ register(
+ REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION,
+ EnumSet.of(TABLE_FULL_METADATA, TABLE_WRITE_DATA));
register(LOAD_TABLE, TABLE_READ_PROPERTIES);
register(LOAD_TABLE_WITH_READ_DELEGATION, TABLE_READ_DATA);
register(LOAD_TABLE_WITH_WRITE_DELEGATION, TABLE_WRITE_DATA);
diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java
index 76c95dac11..fc61854b6b 100644
--- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java
+++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/PolarisRestCatalogMinIOIT.java
@@ -35,7 +35,6 @@ import org.apache.polaris.test.minio.Minio;
import org.apache.polaris.test.minio.MinioAccess;
import org.apache.polaris.test.minio.MinioExtension;
import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.ExtendWith;
@QuarkusIntegrationTest
@@ -95,15 +94,4 @@ public class PolarisRestCatalogMinIOIT extends PolarisRestCatalogIntegrationBase
return storageConfig.build();
}
-
- @Override
- protected Map<String, String> extraCatalogProperties(TestInfo testInfo) {
- if (testInfo.getTestMethod().orElseThrow().getName().equals("testRegisterTable")) {
- // This test registers a table – operation that doesn't support access delegation –
- // then attempts to use the table's FileIO. This can only work if the client has its
- // own S3 credentials.
- return s3Properties;
- }
- return Map.of();
- }
}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java
index fbdfa549a3..c9cf763bd5 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java
@@ -218,10 +218,13 @@ public abstract class CatalogHandler {
/**
* Authorizes a register-table-with-overwrite operation. If the table already exists, {@code
- * REGISTER_TABLE_OVERWRITE} is authorized against the table entity. If the table does not exist,
- * {@code REGISTER_TABLE} is authorized against the parent namespace.
+ * overwriteOp} is authorized against the table entity. If the table does not exist, {@code
+ * fallbackOp} is authorized against the parent namespace.
*/
- protected void authorizeRegisterTableOverwriteOrThrow(TableIdentifier identifier) {
+ protected void authorizeRegisterTableOverwriteOrThrow(
+ PolarisAuthorizableOperation overwriteOp,
+ PolarisAuthorizableOperation fallbackOp,
+ TableIdentifier identifier) {
Namespace namespace = identifier.namespace();
resolutionManifest = newResolutionManifest();
resolutionManifest.addPath(
@@ -252,7 +255,7 @@ public abstract class CatalogHandler {
.authorizeOrThrow(
polarisPrincipal(),
resolutionManifest.getAllActivatedCatalogRoleAndPrincipalRoles(),
- PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE,
+ overwriteOp,
tableTarget,
null /* secondary */);
} else {
@@ -265,7 +268,7 @@ public abstract class CatalogHandler {
.authorizeOrThrow(
polarisPrincipal(),
resolutionManifest.getAllActivatedCatalogRoleAndPrincipalRoles(),
- PolarisAuthorizableOperation.REGISTER_TABLE, // normal register-table operation
+ fallbackOp, // normal register-table operation
namespaceTarget,
null /* secondary */);
}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
index 4a6ceb1c1d..bff88f9639 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
@@ -433,12 +433,20 @@ public class IcebergCatalogAdapter
registerTableRequest.validate();
Namespace ns =
NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
- EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, registerTableRequest.name()));
+ TableIdentifier tableIdentifier = TableIdentifier.of(ns, registerTableRequest.name());
+ EntityNameValidator.validateIdentifier(tableIdentifier);
+ EnumSet<AccessDelegationMode> delegationModes =
+ parseAccessDelegationModes(accessDelegationMode);
return withCatalog(
securityContext,
prefix,
catalog -> {
- LoadTableResponse response = catalog.registerTable(ns, registerTableRequest);
+ LoadTableResponse response =
+ catalog.registerTable(
+ ns,
+ registerTableRequest,
+ delegationModes,
+ getRefreshCredentialsEndpoint(delegationModes, prefix, tableIdentifier));
return tryInsertETagHeader(
Response.ok(response), response, namespace, registerTableRequest.name())
.build();
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 80edc5ca1a..7e778c08fd 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
@@ -613,46 +613,48 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
*
* @param namespace The namespace to register the table in
* @param request the register table request
+ * @param delegationModes the access delegation modes to use
+ * @param refreshCredentialsEndpoint the refresh credentials endpoint to use
* @return ETagged {@link LoadTableResponse} to uniquely identify the table metadata
*/
- public LoadTableResponse registerTable(Namespace namespace, RegisterTableRequest request) {
+ public LoadTableResponse registerTable(
+ Namespace namespace,
+ RegisterTableRequest request,
+ EnumSet<AccessDelegationMode> delegationModes,
+ Optional<String> refreshCredentialsEndpoint) {
+
+ request.validate();
TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
+ boolean overwrite = request.overwrite();
- if (request.overwrite()) {
- authorizeRegisterTableOverwriteOrThrow(identifier);
- return registerTableWithOverwrite(identifier, request);
- }
+ Set<PolarisStorageActions> actionsRequested =
+ authorizeRegisterTable(identifier, delegationModes, overwrite);
- authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
- PolarisAuthorizableOperation.REGISTER_TABLE, identifier);
- return catalogHandlerUtils().registerTable(baseCatalog, namespace, request);
- }
+ if (overwrite) {
+ // For non-Polaris/federated catalogs, reject overwrite until this is
+ // supported by a common catalog contract.
+ CatalogEntity catalogEntity = getResolvedCatalogEntity();
+ if (catalogEntity.isExternal()) {
+ throw new BadRequestException(
+ "Register table overwrite is only supported for internal Polaris catalogs");
+ }
+ }
- private LoadTableResponse registerTableWithOverwrite(
- TableIdentifier identifier, RegisterTableRequest request) {
+ // Resolve the mode before registering the table to avoid registering a table and then failing
+ // to return credentials if the mode is invalid
+ Optional<AccessDelegationMode> resolvedMode = resolveAccessDelegationModes(delegationModes);
- // For non-Polaris/federated catalogs, reject overwrite until this is
- // supported by a common catalog contract.
- CatalogEntity catalogEntity = getResolvedCatalogEntity();
- if (catalogEntity.isExternal()) {
- throw new BadRequestException(
- "Register table overwrite is only supported for internal Polaris catalogs");
- }
+ Table table = baseCatalog.registerTable(identifier, request.metadataLocation(), overwrite);
- // Handle Polaris-specific overwrite logic.
- 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");
+ if (table instanceof BaseTable baseTable) {
+ TableMetadata tableMetadata = baseTable.operations().current();
+ return buildLoadTableResponseWithDelegationCredentials(
+ identifier, tableMetadata, resolvedMode, actionsRequested, refreshCredentialsEndpoint)
+ .build();
}
- throw new BadRequestException(
- "Register table overwrite is only supported for internal Polaris catalogs; unsupported catalog type: %s",
- baseCatalog.getClass().getName());
+ throw new IllegalStateException(
+ "Cannot register table %s: unknown table format".formatted(identifier));
}
public boolean sendNotification(TableIdentifier identifier, NotificationRequest request) {
@@ -889,6 +891,57 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
return actionsRequested;
}
+ private Set<PolarisStorageActions> authorizeRegisterTable(
+ TableIdentifier tableIdentifier,
+ EnumSet<AccessDelegationMode> delegationModes,
+ boolean overwrite) {
+
+ if (delegationModes.isEmpty()) {
+
+ if (overwrite) {
+ authorizeRegisterTableOverwriteOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE,
+ PolarisAuthorizableOperation.REGISTER_TABLE,
+ tableIdentifier);
+ } else {
+ authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE, tableIdentifier);
+ }
+
+ return Set.of();
+
+ } else {
+
+ Set<PolarisStorageActions> actionsRequested =
+ EnumSet.of(PolarisStorageActions.READ, PolarisStorageActions.LIST);
+
+ try {
+ if (overwrite) {
+ authorizeRegisterTableOverwriteOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_WRITE_DELEGATION,
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_WRITE_DELEGATION,
+ tableIdentifier);
+ } else {
+ authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_WRITE_DELEGATION, tableIdentifier);
+ }
+ actionsRequested.add(PolarisStorageActions.WRITE);
+ } catch (ForbiddenException e) {
+ if (overwrite) {
+ authorizeRegisterTableOverwriteOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE_OVERWRITE_WITH_READ_DELEGATION,
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_READ_DELEGATION,
+ tableIdentifier);
+ } else {
+ authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
+ PolarisAuthorizableOperation.REGISTER_TABLE_WITH_READ_DELEGATION, tableIdentifier);
+ }
+ }
+
+ return actionsRequested;
+ }
+ }
+
public Optional<LoadTableResponse> loadTable(
TableIdentifier tableIdentifier,
String snapshots,
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 550e62f6a6..ee598f4f56 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
@@ -21,6 +21,7 @@ package org.apache.polaris.service.catalog.iceberg;
import com.google.common.collect.ImmutableMap;
import jakarta.inject.Inject;
import java.time.Instant;
+import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -52,6 +53,7 @@ import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.CreateViewRequest;
import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.apache.iceberg.rest.requests.ReportMetricsRequest;
@@ -76,6 +78,7 @@ import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.persistence.dao.entity.CreatePrincipalResult;
import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifest;
import org.apache.polaris.service.admin.PolarisAuthzTestBase;
+import org.apache.polaris.service.catalog.AccessDelegationMode;
import org.apache.polaris.service.context.catalog.PolarisLocalCatalogFactory;
import org.apache.polaris.service.http.IfNoneMatch;
import org.apache.polaris.service.types.NotificationRequest;
@@ -535,25 +538,25 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
// To get a handy metadata file we can use one from another table.
// to avoid overlapping directories, drop the original table and recreate it via registerTable
- final String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1);
- final RegisterTableRequest registerRequest =
- new RegisterTableRequest() {
- @Override
- public String name() {
- return TABLE_NS1_1.name();
- }
-
- @Override
- public String metadataLocation() {
- return metadataLocation;
- }
- };
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .build();
// Use PRINCIPAL_ROLE1 for privilege-testing, PRINCIPAL_ROLE2 for cleanup.
return authzTestsBuilder("registerTable")
- .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, registerRequest))
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.noneOf(AccessDelegationMode.class),
+ Optional.empty()))
.cleanupAction(() -> newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1))
.shouldPassWith(PolarisPrivilege.TABLE_CREATE)
.shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
@@ -609,7 +612,14 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
// Registering a new table requires TABLE_CREATE (or broader privileges).
return authzTestsBuilder("registerTable (insufficient)")
- .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS2, registerRequest))
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS2,
+ registerRequest,
+ EnumSet.noneOf(AccessDelegationMode.class),
+ Optional.empty()))
.cleanupAction(
() -> {
IcebergCatalogHandler cleanup = newHandler(Set.of(PRINCIPAL_ROLE2));
@@ -635,21 +645,7 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
}
@TestFactory
- Stream<DynamicNode> testLoadTablePrivileges() {
- return authzTestsBuilder("loadTable")
- .action(() -> newHandler().loadTable(TABLE_NS1A_2, "all"))
- .shouldPassWith(PolarisPrivilege.TABLE_READ_PROPERTIES)
- .shouldPassWith(PolarisPrivilege.TABLE_WRITE_PROPERTIES)
- .shouldPassWith(PolarisPrivilege.TABLE_READ_DATA)
- .shouldPassWith(PolarisPrivilege.TABLE_WRITE_DATA)
- .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
- .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
- .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA)
- .createTests();
- }
-
- @TestFactory
- Stream<DynamicNode> testRegisterTableOverwriteSufficientPrivileges() {
+ Stream<DynamicNode> testRegisterTableOverwritePrivileges() {
// 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.
@@ -670,15 +666,85 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
Mockito.when(registerRequest.overwrite()).thenReturn(true);
return authzTestsBuilder("registerTableOverwrite")
- .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, registerRequest))
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.noneOf(AccessDelegationMode.class),
+ Optional.empty()))
+ .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> testRegisterTableOverwriteNonExistentTable() {
+ // When overwrite is specified and the table does not exist, the authorization should
+ // fall back to a non-overwrite authorization level instead.
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_READ_PROPERTIES));
+
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+ newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1);
+
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .overwrite(true)
+ .build();
+
+ return authzTestsBuilder("registerTableOverwriteNonExistentTable")
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.noneOf(AccessDelegationMode.class),
+ Optional.empty()))
+ .cleanupAction(
+ () -> {
+ IcebergCatalogHandler cleanup = newHandler(Set.of(PRINCIPAL_ROLE2));
+ try {
+ cleanup.dropTableWithoutPurge(TABLE_NS1_1);
+ } catch (RuntimeException ignored) {
+ // Target table may not have been created; cleanup is best-effort.
+ }
+ })
.shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
.shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
.shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_CREATE) // Must pass since the table does not exist
+ .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> testRegisterTableOverwriteTableLevelSufficientPrivileges() {
+ Stream<DynamicNode> testRegisterTableOverwriteTableLevel() {
// REGISTER_TABLE_OVERWRITE authorizes against the table entity, so TABLE_FULL_METADATA
// granted directly on the target table (not at catalog/namespace level) must suffice.
assertSuccess(
@@ -692,7 +758,7 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
Mockito.when(registerRequest.metadataLocation()).thenReturn(metadataLocation);
Mockito.when(registerRequest.overwrite()).thenReturn(true);
- return authzTestsBuilder("registerTableOverwrite (table-level grant)")
+ return authzTestsBuilder("registerTableOverwriteTableLevel")
.grantAction(
privilege ->
adminService.grantPrivilegeOnTableToRole(
@@ -701,52 +767,201 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
privilege ->
adminService.revokePrivilegeOnTableFromRole(
CATALOG_NAME, CATALOG_ROLE1, TABLE_NS1_1, privilege))
- .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, registerRequest))
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.noneOf(AccessDelegationMode.class),
+ Optional.empty()))
.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> testRegisterTableWithWriteDelegation() {
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_READ_PROPERTIES));
+
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+ newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1);
+
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .build();
+
+ return authzTestsBuilder("registerTableWithWriteDelegation")
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.of(AccessDelegationMode.VENDED_CREDENTIALS),
+ Optional.empty()))
+ .cleanupAction(() -> newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1))
+ .shouldPassWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .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.
- */
+ Stream<DynamicNode> testRegisterTableOverwriteWithWriteDelegation() {
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_READ_PROPERTIES));
+
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .overwrite(true)
+ .build();
+ return authzTestsBuilder("registerTableOverwriteWithWriteDelegation")
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.of(AccessDelegationMode.VENDED_CREDENTIALS),
+ Optional.empty()))
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+ // should not pass with these since the table exists
+ .shouldFailWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldFailWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_WRITE_DATA)
+ .createTests();
+ }
+
+ @TestFactory
+ Stream<DynamicNode> testRegisterTableOverwriteWithWriteDelegationNonExistentTable() {
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
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);
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+ newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1);
+
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .overwrite(true)
+ .build();
+
+ return authzTestsBuilder("registerTableOverwriteWithWriteDelegationNonExistentTable")
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.of(AccessDelegationMode.VENDED_CREDENTIALS),
+ Optional.empty()))
+ .cleanupAction(() -> newHandler(Set.of(PRINCIPAL_ROLE2)).dropTableWithoutPurge(TABLE_NS1_1))
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+ // should pass with these since the table does not exist
+ .shouldPassWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_WRITE_DATA)
+ .createTests();
+ }
- return authzTestsBuilder("registerTableOverwrite (insufficient)")
- .action(() -> newHandler(Set.of(PRINCIPAL_ROLE1)).registerTable(NS1, registerRequest))
+ @TestFactory
+ Stream<DynamicNode> testRegisterTableOverwriteWithWriteDelegationTableLevel() {
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP));
+ assertSuccess(
+ adminService.grantPrivilegeOnCatalogToRole(
+ CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_READ_PROPERTIES));
+
+ String metadataLocation = newHandler().loadTable(TABLE_NS1_1, "all").metadataLocation();
+
+ RegisterTableRequest registerRequest =
+ ImmutableRegisterTableRequest.builder()
+ .name(TABLE_NS1_1.name())
+ .metadataLocation(metadataLocation)
+ .overwrite(true)
+ .build();
+
+ return authzTestsBuilder("registerTableOverwriteWithWriteDelegationTableLevel")
+ .grantAction(
+ privilege ->
+ adminService.grantPrivilegeOnTableToRole(
+ CATALOG_NAME, CATALOG_ROLE1, TABLE_NS1_1, privilege))
+ .revokeAction(
+ privilege ->
+ adminService.revokePrivilegeOnTableFromRole(
+ CATALOG_NAME, CATALOG_ROLE1, TABLE_NS1_1, privilege))
+ .action(
+ () ->
+ newHandler(Set.of(PRINCIPAL_ROLE1))
+ .registerTable(
+ NS1,
+ registerRequest,
+ EnumSet.of(AccessDelegationMode.VENDED_CREDENTIALS),
+ Optional.empty()))
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_METADATA, PolarisPrivilege.TABLE_WRITE_DATA)
+ .shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_CONTENT)
+ .shouldFailWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_READ_DATA)
+ .shouldFailWith(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_WRITE_DATA)
+ .createTests();
+ }
+
+ @TestFactory
+ Stream<DynamicNode> testLoadTablePrivileges() {
+ return authzTestsBuilder("loadTable")
+ .action(() -> newHandler().loadTable(TABLE_NS1A_2, "all"))
+ .shouldPassWith(PolarisPrivilege.TABLE_READ_PROPERTIES)
+ .shouldPassWith(PolarisPrivilege.TABLE_WRITE_PROPERTIES)
+ .shouldPassWith(PolarisPrivilege.TABLE_READ_DATA)
+ .shouldPassWith(PolarisPrivilege.TABLE_WRITE_DATA)
.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();
}
@@ -877,7 +1092,7 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
// With fine-grained authorization disabled, TABLE_WRITE_PROPERTIES should work
// even for operations that would require specific privileges when enabled
return authzTestsBuilder("updateTable (coarse-grained fallback)")
- .action(() -> newWrapperWithFineGrainedAuthzDisabled().updateTable(TABLE_NS1A_2, request))
+ .action(() -> newHandlerWithFineGrainedAuthzDisabled().updateTable(TABLE_NS1A_2, request))
.shouldPassWith(PolarisPrivilege.TABLE_WRITE_PROPERTIES)
.shouldPassWith(PolarisPrivilege.TABLE_WRITE_DATA)
.shouldPassWith(PolarisPrivilege.TABLE_FULL_METADATA)
@@ -890,7 +1105,7 @@ public abstract class AbstractIcebergCatalogHandlerAuthzTest extends PolarisAuth
* Creates a wrapper with fine-grained authorization explicitly disabled for testing the fallback
* behavior to coarse-grained authorization.
*/
- private IcebergCatalogHandler newWrapperWithFineGrainedAuthzDisabled() {
+ private IcebergCatalogHandler newHandlerWithFineGrainedAuthzDisabled() {
PolarisPrincipal authenticatedPrincipal = PolarisPrincipal.of(principalEntity, Set.of());
// Create a custom CallContext that returns a custom RealmConfig