diff --git a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
index b7bd371dfe1c..815119c21436 100644
--- a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
+++ b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/HiveCatalog.java
@@ -241,6 +241,10 @@ public String name() {
return name;
}
+ public FileIO io() {
+ return fileIO;
+ }
+
@Override
public boolean dropTable(TableIdentifier identifier, boolean purge) {
if (!isValidIdentifier(identifier)) {
@@ -271,7 +275,7 @@ public boolean dropTable(TableIdentifier identifier, boolean purge) {
});
if (purge && lastMetadata != null) {
- CatalogUtil.dropTableData(ops.io(), lastMetadata);
+ CatalogUtil.dropTableData(new ScopedDeleteFileIO(ops.io(), lastMetadata.location()), lastMetadata);
}
LOG.info("Dropped table: {}", identifier);
diff --git a/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/ScopedDeleteFileIO.java b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/ScopedDeleteFileIO.java
new file mode 100644
index 000000000000..718c7f1274d4
--- /dev/null
+++ b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/ScopedDeleteFileIO.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iceberg.hive;
+
+import java.util.Map;
+import org.apache.hadoop.fs.Path;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link FileIO} decorator used by {@link HiveCatalog#dropTable(org.apache.iceberg.catalog.TableIdentifier,
+ * boolean)} to fence purge deletions to files under a table's own location, regardless of what a table's
+ * metadata or manifests actually reference.
+ *
+ *
This does not implement {@link org.apache.iceberg.io.SupportsBulkOperations} or
+ * {@link org.apache.iceberg.io.SupportsPrefixOperations} even when the delegate does, so that
+ * {@code CatalogUtil.dropTableData} is forced to route every deletion through {@link #deleteFile(String)}.
+ */
+class ScopedDeleteFileIO implements FileIO {
+ private static final Logger LOG = LoggerFactory.getLogger(ScopedDeleteFileIO.class);
+
+ private final FileIO delegate;
+ private final String location;
+
+ ScopedDeleteFileIO(FileIO delegate, String location) {
+ this.delegate = delegate;
+ this.location = normalize(location);
+ }
+
+ @Override
+ public InputFile newInputFile(String path) {
+ return delegate.newInputFile(path);
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ return delegate.newOutputFile(path);
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ if (!isContained(location, normalize(path))) {
+ LOG.warn("Skipping delete outside table location {}: {}", location, path);
+ return;
+ }
+ delegate.deleteFile(path);
+ }
+
+ @Override
+ public Map properties() {
+ return delegate.properties();
+ }
+
+ @Override
+ public void initialize(Map properties) {
+ delegate.initialize(properties);
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+
+ private static boolean isContained(String root, String candidate) {
+ return candidate.equals(root) || candidate.startsWith(root.endsWith("/") ? root : root + "/");
+ }
+
+ private static String normalize(String location) {
+ return new Path(location).toUri().normalize().toString();
+ }
+}
diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
index 885e30063528..33cbc4c1d5ea 100644
--- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
+++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
@@ -52,6 +52,8 @@
import org.apache.iceberg.exceptions.NotFoundException;
import org.apache.iceberg.exceptions.UnprocessableEntityException;
import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.hive.HiveCatalog;
+import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.relocated.com.google.common.base.Splitter;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
@@ -298,10 +300,13 @@ private LoadTableResponse createTable(Map vars, Object body) {
}
private RESTResponse dropTable(Map vars) {
+ TableIdentifier ident = identFromPathVars(vars);
if (PropertyUtil.propertyAsBoolean(vars, "purgeRequested", false)) {
- CatalogHandlers.purgeTable(catalog, identFromPathVars(vars));
+ String location = catalog.loadTable(ident).location();
+ icebergAuthorizer.validateDropTablePurge(catalogName, ident, location);
+ CatalogHandlers.purgeTable(catalog, ident);
} else {
- CatalogHandlers.dropTable(catalog, identFromPathVars(vars));
+ CatalogHandlers.dropTable(catalog, ident);
}
return null;
}
@@ -320,6 +325,10 @@ private LoadTableResponse loadTable(Map vars) {
private LoadTableResponse registerTable(Map vars, Object body) {
Namespace namespace = namespaceFromPathVars(vars);
RegisterTableRequest request = castRequest(RegisterTableRequest.class, body);
+ request.validate();
+ Map namespaceMetadata = asNamespaceCatalog.loadNamespaceMetadata(namespace);
+ FileIO io = ((HiveCatalog) catalog).io();
+ icebergAuthorizer.validateRegisterTable(catalogName, namespace, namespaceMetadata, request, io);
return castResponse(LoadTableResponse.class, CatalogHandlers.registerTable(catalog, namespace, request));
}
diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java
index 2df051105b77..d08aa0ce4071 100644
--- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java
+++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java
@@ -30,6 +30,7 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
import org.apache.hadoop.hive.conf.HiveConf;
import org.apache.hadoop.hive.metastore.api.PrincipalType;
import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
@@ -44,10 +45,14 @@
import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveOperationType;
import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject;
import org.apache.hadoop.hive.ql.security.authorization.plugin.metastore.HiveMetaStoreAuthorizer;
+import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.hive.HiveHadoopUtil;
+import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -64,7 +69,10 @@ class IcebergAuthorizer {
@VisibleForTesting
final Supplier authorizerSupplier;
+ private final Configuration conf;
+
IcebergAuthorizer(Configuration conf) {
+ this.conf = conf;
final var classes = MetastoreConf.getTrimmedStringsVar(conf, MetastoreConf.ConfVars.PRE_EVENT_LISTENERS);
if (classes.length == 0) {
LOG.info("No pre-event listeners configured, skipping authorization checks");
@@ -100,7 +108,13 @@ class IcebergAuthorizer {
@VisibleForTesting
IcebergAuthorizer(Supplier authorizerSupplier) {
+ this(authorizerSupplier, new Configuration(false));
+ }
+
+ @VisibleForTesting
+ IcebergAuthorizer(Supplier authorizerSupplier, Configuration conf) {
this.authorizerSupplier = authorizerSupplier;
+ this.conf = conf;
}
/**
@@ -161,4 +175,133 @@ void validateStageCreateTable(String catalogName, Namespace namespace, MapWhen no {@code HiveAuthorizer} is configured, falls back to requiring both locations to be contained in
+ * the namespace's external or managed root, since there is no policy to otherwise decide whether the caller may
+ * read an arbitrary location with service credentials.
+ *
+ * @param catalogName the Hive catalog name
+ * @param namespace the Iceberg namespace
+ * @param namespaceMetadata the Iceberg namespace metadata
+ * @param request the register table request
+ * @param io the {@link FileIO} used to read the metadata file
+ * @throws ForbiddenException if a location is not authorized, or not contained in the namespace
+ * @throws IllegalStateException if the authorization plugin fails
+ */
+ void validateRegisterTable(String catalogName, Namespace namespace, Map namespaceMetadata,
+ RegisterTableRequest request, FileIO io) {
+ Preconditions.checkArgument(namespace.levels().length == 1, "Hive does not support multi-level namespaces");
+ var databaseName = namespace.level(0);
+ var commandString = "register table " + request.name();
+ checkLocationAuthorized(catalogName, databaseName, namespaceMetadata, request.metadataLocation(), commandString);
+
+ var metadata = TableMetadataParser.read(io, request.metadataLocation());
+ checkLocationAuthorized(catalogName, databaseName, namespaceMetadata, metadata.location(), commandString);
+ }
+
+ /**
+ * Enforces authorization for DROP_TABLE with {@code purge=true}. Purge deletes every file referenced by the
+ * table's current metadata using the catalog's shared, service-level {@link FileIO}, so the location must be
+ * authorized like any other DFS_URI access.
+ *
+ * Unlike {@link #validateRegisterTable}, there is no namespace-containment fallback here: the structural
+ * fence in {@code HiveCatalog.dropTable} already restricts purge deletions to files under the table's own
+ * location regardless of whether a {@code HiveAuthorizer} is configured, so a deployment without one relies on
+ * that fence rather than this check.
+ *
+ * @param catalogName the Hive catalog name
+ * @param identifier the table identifier being dropped
+ * @param location the table's current location
+ * @throws ForbiddenException if the location is not authorized
+ * @throws IllegalStateException if the authorization plugin fails
+ */
+ void validateDropTablePurge(String catalogName, TableIdentifier identifier, String location) {
+ var authorizer = authorizerSupplier.get();
+ if (authorizer == null) {
+ LOG.info("No pre-event listener is configured for catalog {}, skipping drop-table-purge authorization for {}",
+ catalogName, identifier);
+ return;
+ }
+
+ var inputs = Collections.singletonList(
+ new HivePrivilegeObject(HivePrivilegeObject.HivePrivilegeObjectType.DFS_URI, location));
+ var builder = new HiveAuthzContext.Builder();
+ builder.setCommandString("drop table " + identifier.name());
+ try {
+ authorizer.checkPrivileges(HiveOperationType.DROPTABLE, inputs, Collections.emptyList(), builder.build());
+ } catch (HiveAccessControlException e) {
+ throw new ForbiddenException(e, e.getMessage());
+ } catch (HiveAuthzPluginException e) {
+ throw new IllegalStateException("Failed to check privileges drop-table-purge", e);
+ }
+ }
+
+ private void checkLocationAuthorized(String catalogName, String databaseName,
+ Map namespaceMetadata, String location, String commandString) {
+ var authorizer = authorizerSupplier.get();
+ if (authorizer == null) {
+ LOG.info("No pre-event listener is configured for catalog {}, falling back to namespace containment for {}",
+ catalogName, location);
+ checkContainedInNamespace(databaseName, namespaceMetadata, location);
+ return;
+ }
+
+ var inputs = Collections.singletonList(
+ new HivePrivilegeObject(HivePrivilegeObject.HivePrivilegeObjectType.DFS_URI, location));
+ var builder = new HiveAuthzContext.Builder();
+ builder.setCommandString(commandString);
+ try {
+ authorizer.checkPrivileges(HiveOperationType.CREATETABLE, inputs, Collections.emptyList(), builder.build());
+ } catch (HiveAccessControlException e) {
+ throw new ForbiddenException(e, e.getMessage());
+ } catch (HiveAuthzPluginException e) {
+ throw new IllegalStateException("Failed to check privileges for " + commandString, e);
+ }
+ }
+
+ private void checkContainedInNamespace(String databaseName, Map namespaceMetadata,
+ String location) {
+ var externalRoot = namespaceMetadata.get("location");
+ if (externalRoot != null && isContained(externalRoot, location)) {
+ return;
+ }
+ if (isContained(managedNamespaceLocation(databaseName), location)) {
+ return;
+ }
+ throw new ForbiddenException(
+ "Location %s is not authorized and is not contained in namespace %s", location, databaseName);
+ }
+
+ private String managedNamespaceLocation(String databaseName) {
+ var warehouseLocation = conf.get(HiveConf.ConfVars.METASTORE_WAREHOUSE.varname);
+ Preconditions.checkNotNull(warehouseLocation, "Warehouse location is not set: hive.metastore.warehouse.dir=null");
+ if (warehouseLocation.endsWith("/")) {
+ warehouseLocation = warehouseLocation.substring(0, warehouseLocation.length() - 1);
+ }
+ return String.format("%s/%s.db", warehouseLocation, databaseName);
+ }
+
+ /**
+ * Checks whether {@code candidate} resolves under {@code root}. Both are resolved via {@link Path#toUri()} and
+ * {@link java.net.URI#normalize()}, which -- unlike {@link Path}'s own normalization -- actually collapses
+ * {@code .}/{@code ..} segments; a plain string-prefix comparison on unnormalized paths would let a location
+ * like {@code root/../../elsewhere} pass a naive check while actually resolving outside {@code root}.
+ */
+ private static boolean isContained(String root, String candidate) {
+ var normalizedRoot = normalize(root);
+ var normalizedCandidate = normalize(candidate);
+ return normalizedCandidate.equals(normalizedRoot)
+ || normalizedCandidate.startsWith(normalizedRoot.endsWith("/") ? normalizedRoot : normalizedRoot + "/");
+ }
+
+ private static String normalize(String location) {
+ return new Path(location).toUri().normalize().toString();
+ }
}
diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java
index 206d72c9cb1b..8c02449e74be 100644
--- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java
+++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java
@@ -20,15 +20,21 @@
package org.apache.iceberg.rest;
import java.io.IOException;
+import java.nio.file.Files;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import org.apache.hadoop.conf.Configuration;
import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.FileFormat;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.CatalogTests;
@@ -37,6 +43,7 @@
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.hadoop.HadoopFileIO;
import org.apache.iceberg.rest.extension.MockHiveAuthorizer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
@@ -277,4 +284,56 @@ void testStageCreateTableWithDeniedLocation() {
Assertions.assertThrows(ForbiddenException.class, builder::createTransaction);
Assertions.assertThrows(NoSuchTableException.class, () -> catalog.loadTable(tableIdentifier));
}
+
+ private static String writeMetadataFile(String directory, String tableLocation) throws IOException {
+ var metadataLocation = directory + "/v1.metadata.json";
+ Files.deleteIfExists(java.nio.file.Path.of(metadataLocation));
+ var io = new HadoopFileIO(new Configuration(false));
+ var metadata = TableMetadata.newTableMetadata(new Schema(), PartitionSpec.unpartitioned(), tableLocation,
+ Collections.emptyMap());
+ TableMetadataParser.write(metadata, io.newOutputFile(metadataLocation));
+ return metadataLocation;
+ }
+
+ @Test
+ void testRegisterTableWithDeniedLocation() {
+ var tableIdentifier = TableIdentifier.of("default", "register-table-denied");
+ var metadataLocation = MockHiveAuthorizer.DENIED_PREFIX + "/register-table-denied/v1.metadata.json";
+ Assertions.assertThrows(ForbiddenException.class, () -> catalog.registerTable(tableIdentifier, metadataLocation));
+ Assertions.assertThrows(NoSuchTableException.class, () -> catalog.loadTable(tableIdentifier));
+ }
+
+ @Test
+ void testRegisterTableWithDeniedEmbeddedLocation() throws IOException {
+ var tableIdentifier = TableIdentifier.of("default", "register-table-embedded-denied");
+ var tableLocation = MockHiveAuthorizer.DENIED_PREFIX + "/register-table-embedded-denied";
+ var metadataLocation = writeMetadataFile(
+ MockHiveAuthorizer.ALLOWED_PREFIX + "/register-table-embedded-denied", tableLocation);
+ Assertions.assertThrows(ForbiddenException.class, () -> catalog.registerTable(tableIdentifier, metadataLocation));
+ Assertions.assertThrows(NoSuchTableException.class, () -> catalog.loadTable(tableIdentifier));
+ }
+
+ @Test
+ void testDropTablePurgeDoesNotDeleteFilesOutsideTableLocation() throws IOException {
+ var victimDirectory = java.nio.file.Path.of(MockHiveAuthorizer.ALLOWED_PREFIX, "structural-fence-victim");
+ Files.createDirectories(victimDirectory);
+ var victimFile = victimDirectory.resolve("victim-data.txt");
+ Files.writeString(victimFile, "victim data");
+
+ var tableIdentifier = TableIdentifier.of("default", "structural-fence-attacker");
+ var tableLocation = MockHiveAuthorizer.ALLOWED_PREFIX + "/structural-fence-attacker";
+ Table table = catalog.buildTable(tableIdentifier, new Schema()).withLocation(tableLocation).create();
+
+ DataFile dataFile = DataFiles.builder(table.spec())
+ .withPath(victimFile.toUri().toString())
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(Files.size(victimFile))
+ .withRecordCount(1)
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+
+ Assertions.assertTrue(catalog.dropTable(tableIdentifier, true));
+ Assertions.assertThrows(NoSuchTableException.class, () -> catalog.loadTable(tableIdentifier));
+ Assertions.assertTrue(Files.exists(victimFile), "purge must not delete files outside the table location");
+ }
}
diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java
index 0d13414a0074..658f6df4bd9b 100644
--- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java
+++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java
@@ -27,12 +27,15 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import java.util.List;
import java.util.Map;
import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.conf.HiveConf;
import org.apache.hadoop.hive.metastore.api.PrincipalType;
import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAccessControlException;
@@ -43,14 +46,22 @@
import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject;
import org.apache.hadoop.hive.ql.security.authorization.plugin.metastore.HiveMetaStoreAuthorizer;
import org.apache.hadoop.security.UserGroupInformation;
+import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.iceberg.hadoop.HadoopFileIO;
+import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.rest.extension.MockHiveAuthorizer;
import org.apache.iceberg.rest.extension.MockHiveAuthorizerFactory;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
@@ -265,4 +276,180 @@ void testTranslateAuthorizationPluginException() throws Exception {
Assertions.assertEquals("Failed to check privileges stage-create", exception.getMessage());
Assertions.assertSame(failure, exception.getCause());
}
+
+ private static String writeMetadataFile(FileIO io, java.nio.file.Path dir, String tableLocation) {
+ var metadata = TableMetadata.newTableMetadata(new Schema(), PartitionSpec.unpartitioned(), tableLocation, Map.of());
+ var metadataLocation = "file:" + dir + "/v1.metadata.json";
+ TableMetadataParser.write(metadata, io.newOutputFile(metadataLocation));
+ return metadataLocation;
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testValidateRegisterTableAuthorized(@TempDir java.nio.file.Path tempDir) throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var io = new HadoopFileIO(new Configuration(false));
+
+ var tableLocation = "file:" + tempDir + "/table";
+ var metadataLocation = writeMetadataFile(io, tempDir, tableLocation);
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(metadataLocation).build();
+
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, Map.of(), request, io);
+
+ var operation = ArgumentCaptor.forClass(HiveOperationType.class);
+ var inputs = ArgumentCaptor.forClass(List.class);
+ var context = ArgumentCaptor.forClass(HiveAuthzContext.class);
+ verify(hiveAuthorizer, Mockito.times(2))
+ .checkPrivileges(operation.capture(), inputs.capture(), anyList(), context.capture());
+
+ for (var value : operation.getAllValues()) {
+ Assertions.assertEquals(HiveOperationType.CREATETABLE, value);
+ }
+ for (var value : context.getAllValues()) {
+ Assertions.assertEquals("register table " + TABLE_NAME, value.getCommandString());
+ }
+
+ var firstCheckedLocation = (HivePrivilegeObject) inputs.getAllValues().get(0).getFirst();
+ assertThat(firstCheckedLocation.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(firstCheckedLocation.getObjectName()).isEqualTo(metadataLocation);
+
+ var secondCheckedLocation = (HivePrivilegeObject) inputs.getAllValues().get(1).getFirst();
+ assertThat(secondCheckedLocation.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(secondCheckedLocation.getObjectName()).isEqualTo(tableLocation);
+ }
+
+ @Test
+ void testValidateRegisterTableDeniedMetadataLocation() throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var failure = new HiveAccessControlException("access denied");
+ doThrow(failure).when(hiveAuthorizer).checkPrivileges(any(), anyList(), anyList(), any());
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var io = mock(FileIO.class);
+
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(LOCATION).build();
+ var exception = Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, Map.of(), request, io));
+ Assertions.assertEquals("access denied", exception.getMessage());
+ verifyNoInteractions(io);
+ }
+
+ @Test
+ void testValidateRegisterTableDeniedEmbeddedLocation(@TempDir java.nio.file.Path tempDir) throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var failure = new HiveAccessControlException("access denied");
+ doNothing().doThrow(failure).when(hiveAuthorizer).checkPrivileges(any(), anyList(), anyList(), any());
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var io = new HadoopFileIO(new Configuration(false));
+
+ var metadataLocation = writeMetadataFile(io, tempDir, LOCATION);
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(metadataLocation).build();
+
+ var exception = Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, Map.of(), request, io));
+ Assertions.assertEquals("access denied", exception.getMessage());
+ verify(hiveAuthorizer, Mockito.times(2)).checkPrivileges(any(), anyList(), anyList(), any());
+ }
+
+ @Test
+ void testValidateRegisterTableWithMultiLevelNamespace() {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var io = mock(FileIO.class);
+ var nestedNamespace = Namespace.of("db", "nested");
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(LOCATION).build();
+
+ var exception = Assertions.assertThrows(IllegalArgumentException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, nestedNamespace, Map.of(), request, io));
+ Assertions.assertEquals("Hive does not support multi-level namespaces", exception.getMessage());
+ Mockito.verifyNoInteractions(hiveAuthorizer);
+ verifyNoInteractions(io);
+ }
+
+ @Test
+ void testValidateRegisterTableNoAuthorizerFallbackAllowed(@TempDir java.nio.file.Path tempDir) throws Exception {
+ var conf = new Configuration(false);
+ conf.set(HiveConf.ConfVars.METASTORE_WAREHOUSE.varname, "file:/unrelated-warehouse");
+ var icebergAuthorizer = new IcebergAuthorizer(() -> null, conf);
+ var io = new HadoopFileIO(new Configuration(false));
+
+ var externalRoot = "file:" + tempDir;
+ var namespaceMetadata = Map.of("location", externalRoot);
+ var tableLocation = externalRoot + "/table";
+ var metadataLocation = writeMetadataFile(io, tempDir, tableLocation);
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(metadataLocation).build();
+
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, namespaceMetadata, request, io);
+ }
+
+ @Test
+ void testValidateRegisterTableNoAuthorizerFallbackDenied() throws Exception {
+ var conf = new Configuration(false);
+ conf.set(HiveConf.ConfVars.METASTORE_WAREHOUSE.varname, "file:/unrelated-warehouse");
+ var icebergAuthorizer = new IcebergAuthorizer(() -> null, conf);
+ var io = mock(FileIO.class);
+
+ var request = ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(LOCATION).build();
+ Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, Map.of(), request, io));
+ verifyNoInteractions(io);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testValidateDropTablePurgeAuthorized() throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var identifier = TableIdentifier.of(NAMESPACE, TABLE_NAME);
+
+ icebergAuthorizer.validateDropTablePurge(CATALOG_NAME, identifier, LOCATION);
+
+ var operation = ArgumentCaptor.forClass(HiveOperationType.class);
+ var inputs = ArgumentCaptor.forClass(List.class);
+ var outputs = ArgumentCaptor.forClass(List.class);
+ var context = ArgumentCaptor.forClass(HiveAuthzContext.class);
+ verify(hiveAuthorizer).checkPrivileges(operation.capture(), inputs.capture(), outputs.capture(), context.capture());
+
+ Assertions.assertEquals(HiveOperationType.DROPTABLE, operation.getValue());
+ Assertions.assertEquals(1, inputs.getValue().size());
+ var location = (HivePrivilegeObject) inputs.getValue().getFirst();
+ assertThat(location.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(location.getObjectName()).isEqualTo(LOCATION);
+ Assertions.assertEquals(List.of(), outputs.getValue());
+ Assertions.assertEquals("drop table " + TABLE_NAME, context.getValue().getCommandString());
+ }
+
+ @Test
+ void testValidateDropTablePurgeDenied() throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var failure = new HiveAccessControlException("access denied");
+ doThrow(failure).when(hiveAuthorizer).checkPrivileges(any(), anyList(), anyList(), any());
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var identifier = TableIdentifier.of(NAMESPACE, TABLE_NAME);
+
+ var exception = Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateDropTablePurge(CATALOG_NAME, identifier, LOCATION));
+ Assertions.assertEquals("access denied", exception.getMessage());
+ Assertions.assertSame(failure, exception.getCause());
+ }
+
+ @Test
+ void testValidateDropTablePurgeTranslatesPluginException() throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var failure = new HiveAuthzPluginException("plugin failure");
+ doThrow(failure).when(hiveAuthorizer).checkPrivileges(any(), anyList(), anyList(), any());
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ var identifier = TableIdentifier.of(NAMESPACE, TABLE_NAME);
+
+ var exception = Assertions.assertThrows(IllegalStateException.class, () ->
+ icebergAuthorizer.validateDropTablePurge(CATALOG_NAME, identifier, LOCATION));
+ Assertions.assertEquals("Failed to check privileges drop-table-purge", exception.getMessage());
+ Assertions.assertSame(failure, exception.getCause());
+ }
+
+ @Test
+ void testValidateDropTablePurgeWithoutAuthorizer() {
+ var icebergAuthorizer = new IcebergAuthorizer(() -> null);
+ icebergAuthorizer.validateDropTablePurge(CATALOG_NAME, TableIdentifier.of(NAMESPACE, TABLE_NAME), LOCATION);
+ }
}