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); + } }