This is an automated email from the ASF dual-hosted git repository.
okumin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git
The following commit(s) were added to refs/heads/master by this push:
new 99b60fdd4ed HIVE-30002 : Restrict Iceberg REST catalog table
registration and drop-purge to authorized/contained locations (#6812)
99b60fdd4ed is described below
commit 99b60fdd4edd7f5f91784c7473e14f273cc76e65
Author: Henrib <[email protected]>
AuthorDate: Fri Oct 9 10:37:41 2026 +0200
HIVE-30002 : Restrict Iceberg REST catalog table registration and
drop-purge to authorized/contained locations (#6812)
* HIVE-30004: Restrict Iceberg REST catalog table registration and
drop-purge to authorized/contained locations
Reject REGISTER_TABLE requests whose metadata location or embedded
table location is not authorized or contained within the namespace.
Authorize DROP_TABLE purge against the table's location, and fence
purge deletions to the table's own location as defense in depth.
Co-Authored-By: Claude Code <[email protected]>
* HIVE-30002 : Address review feedback on Iceberg REST catalog location
checks
- Replace hand-rolled string-prefix containment in IcebergAuthorizer and
ScopedDeleteFileIO with the shared, depth-based
FileUtils.isPathWithinSubtree (metastore-common)
- Drop the redundant embedded-metadata-location check in
validateRegisterTable; it's already covered by HMS's own CREATE_TABLE
authorization. This removes the need for HiveCatalog#io() and the
ClassCastException risk against HMSCachingCatalog
- Rely on namespaceMetadata's own "location" instead of re-deriving it
with a hand-rolled DB-location convention that mishandles exceptions
(e.g. the default database)
- Downgrade the skipped-delete log line from WARN to DEBUG
- Strengthen the purge test to also assert a nested file is deleted,
not just that an outside file survives
Co-Authored-By: Claude Code <[email protected]>
* HIVE-30002 : Qualify scheme-less purge paths against the default FS, add
FileUtils test coverage
Address okumin's review on #6812: ScopedDeleteFileIO now qualifies
scheme-less paths against the cluster's default filesystem
(fs.defaultFS) instead of borrowing a scheme from the other side of
the comparison, and metastore-common's FileUtils.isPathWithinSubtree
gets the test coverage ported from common's original implementation.
Co-Authored-By: Claude Code <[email protected]>
* HIVE-30002: Require WRITE on drop-table-purge location
Pass the table location as both input and output to checkPrivileges in
validateDropTablePurge, so authorizers that only enforce write/delete
privileges on outputs (e.g. Ranger) require WRITE rather than READ alone,
matching that purge deletes files there.
Co-Authored-By: Claude Code <[email protected]>
---------
Co-authored-by: Claude Code <[email protected]>
---
.../java/org/apache/iceberg/hive/HiveCatalog.java | 2 +-
.../apache/iceberg/hive/ScopedDeleteFileIO.java | 105 ++++++++++++++++
.../hadoop/hive/metastore/utils/FileUtils.java | 24 ++++
.../hadoop/hive/metastore/utils/TestFileUtils.java | 58 +++++++++
.../org/apache/iceberg/rest/HMSCatalogAdapter.java | 10 +-
.../org/apache/iceberg/rest/IcebergAuthorizer.java | 105 ++++++++++++++++
.../apache/iceberg/rest/BaseRESTCatalogTests.java | 71 +++++++++++
.../apache/iceberg/rest/TestIcebergAuthorizer.java | 134 +++++++++++++++++++++
8 files changed, 506 insertions(+), 3 deletions(-)
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 b7bd371dfe1..95749370c82 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
@@ -271,7 +271,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(), conf), 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 00000000000..1a3e584c2e5
--- /dev/null
+++
b/iceberg/iceberg-catalog/src/main/java/org/apache/iceberg/hive/ScopedDeleteFileIO.java
@@ -0,0 +1,105 @@
+/*
+ * 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.net.URI;
+import java.util.Map;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hive.metastore.utils.FileUtils;
+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.
+ *
+ * <p>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 URI defaultUri;
+ private final Path location;
+
+ ScopedDeleteFileIO(FileIO delegate, String location, Configuration conf) {
+ this.delegate = delegate;
+ this.defaultUri = FileSystem.getDefaultUri(conf);
+ this.location = qualify(new Path(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) {
+ var candidate = qualify(new Path(path));
+ if (!FileUtils.isPathWithinSubtree(candidate, location)) {
+ LOG.debug("Skipping delete outside table location {}: {}", location,
path);
+ return;
+ }
+ delegate.deleteFile(path);
+ }
+
+ /**
+ * Manifests can hold data file paths written without a scheme, and a
table's own location can
+ * equally be scheme-less (e.g. when a caller supplies one explicitly).
{@link Path#equals} is
+ * scheme-sensitive, so without this a schemeless path would never match its
scheme-qualified
+ * counterpart in {@link FileUtils#isPathWithinSubtree} even when it is
nested directly under it.
+ * A scheme-less path refers to a location on the cluster's default
filesystem (the one
+ * configured via {@code fs.defaultFS} in {@code core-site.xml}), so that is
what it is qualified
+ * against here, rather than against whatever scheme the other side of the
comparison happens to
+ * carry. A path that already carries a scheme is returned unchanged, so a
genuine mismatch (e.g.
+ * a different scheme or authority) is still correctly treated as outside
the table location.
+ */
+ private Path qualify(Path path) {
+ return path.makeQualified(defaultUri, new Path(Path.SEPARATOR));
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return delegate.properties();
+ }
+
+ @Override
+ public void initialize(Map<String, String> properties) {
+ delegate.initialize(properties);
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+}
diff --git
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/utils/FileUtils.java
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/utils/FileUtils.java
index 7b045ce2065..4bbbbf99fa4 100644
---
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/utils/FileUtils.java
+++
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/utils/FileUtils.java
@@ -658,4 +658,28 @@ void findNext() throws IOException {
nextFile = null;
}
}
+
+ /**
+ * Checks whether {@code path} is {@code subtree} itself or nested under it,
by walking up
+ * {@code path}'s ancestors looking for a match. Depth-based rather than a
string-prefix
+ * comparison, so it isn't fooled by sibling paths that merely share a
prefix (e.g. {@code /a/bc}
+ * is not within {@code /a/b}).
+ *
+ * @param path the path to check
+ * @param subtree the candidate ancestor, or exact match
+ * @return true if {@code path} equals {@code subtree} or is one of its
descendants
+ */
+ public static boolean isPathWithinSubtree(final Path path, final Path
subtree) {
+ final int subtreeDepth = subtree.depth();
+ int pathDepth = path.depth();
+ if (pathDepth < subtreeDepth) {
+ return false;
+ }
+ Path subpath = path;
+ while (pathDepth > subtreeDepth) {
+ subpath = subpath.getParent();
+ pathDepth -= 1;
+ }
+ return subtree.equals(subpath);
+ }
}
diff --git
a/standalone-metastore/metastore-common/src/test/java/org/apache/hadoop/hive/metastore/utils/TestFileUtils.java
b/standalone-metastore/metastore-common/src/test/java/org/apache/hadoop/hive/metastore/utils/TestFileUtils.java
new file mode 100644
index 00000000000..b42e814247e
--- /dev/null
+++
b/standalone-metastore/metastore-common/src/test/java/org/apache/hadoop/hive/metastore/utils/TestFileUtils.java
@@ -0,0 +1,58 @@
+/*
+ * 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.hadoop.hive.metastore.utils;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.hadoop.fs.Path;
+import org.junit.Test;
+
+public class TestFileUtils {
+
+ @Test
+ public void isPathWithinSubtree_samePrefix() {
+ Path path = new Path("/somedir1");
+ Path subtree = new Path("/somedir");
+
+ assertFalse(FileUtils.isPathWithinSubtree(path, subtree));
+ }
+
+ @Test
+ public void isPathWithinSubtree_rootIsInside() {
+ Path path = new Path("/foo");
+ Path subtree = new Path("/foo");
+ assertTrue(FileUtils.isPathWithinSubtree(path, subtree));
+ }
+
+ @Test
+ public void isPathWithinSubtree_descendantInside() {
+ Path path = new Path("/foo/bar");
+ Path subtree = new Path("/foo");
+ assertTrue(FileUtils.isPathWithinSubtree(path, subtree));
+ }
+
+ @Test
+ public void isPathWithinSubtree_relativeWalk() {
+ Path path = new Path("foo/../../bar");
+ Path subtree = new Path("../bar");
+ assertTrue(FileUtils.isPathWithinSubtree(path, subtree));
+ }
+}
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 aa3813e533f..859a8ebe601 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
@@ -359,10 +359,13 @@ private LoadTableResponse createTable(Map<String, String>
vars, Object body) {
}
private RESTResponse dropTable(Map<String, String> 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;
}
@@ -381,6 +384,9 @@ private LoadTableResponse loadTable(Map<String, String>
vars) {
private LoadTableResponse registerTable(Map<String, String> vars, Object
body) {
Namespace namespace = namespaceFromPathVars(vars);
RegisterTableRequest request = castRequest(RegisterTableRequest.class,
body);
+ request.validate();
+ Map<String, String> namespaceMetadata =
asNamespaceCatalog.loadNamespaceMetadata(namespace);
+ icebergAuthorizer.validateRegisterTable(catalogName, namespace,
namespaceMetadata, request);
return 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 2df051105b7..38069fea81c 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,9 +30,11 @@
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;
+import org.apache.hadoop.hive.metastore.utils.FileUtils;
import org.apache.hadoop.hive.ql.metadata.HiveException;
import org.apache.hadoop.hive.ql.metadata.HiveUtils;
import
org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAccessControlException;
@@ -45,9 +47,11 @@
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.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.hive.HiveHadoopUtil;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -161,4 +165,105 @@ void validateStageCreateTable(String catalogName,
Namespace namespace, Map<Strin
throw new IllegalStateException("Failed to check privileges
stage-create", e);
}
}
+
+ /**
+ * Enforces authorization for REGISTER_TABLE. The request's {@code
metadataLocation} must be authorized, since
+ * REGISTER_TABLE is otherwise an arbitrary-file-read primitive that returns
any metadata file's contents to the
+ * caller. The {@code location()} embedded in that metadata file (which
becomes the table's HMS
+ * {@code StorageDescriptor.location}) is not checked here: it is authorized
transitively by HMS's own
+ * CREATE_TABLE authorization when {@code CatalogHandlers.registerTable}
creates the metastore table.
+ *
+ * <p>When no {@code HiveAuthorizer} is configured, falls back to requiring
the metadata location to be
+ * contained in the namespace's external 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
+ * @throws ForbiddenException if the location is not authorized, or not
contained in the namespace
+ * @throws IllegalStateException if the authorization plugin fails
+ */
+ void validateRegisterTable(String catalogName, Namespace namespace,
Map<String, String> namespaceMetadata,
+ RegisterTableRequest request) {
+ 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);
+ }
+
+ /**
+ * 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 {@code
FileIO}, so the location must be
+ * authorized like any other DFS_URI access. The location is passed as both
an input and an output so that
+ * authorizers which only enforce write/delete privileges on outputs (e.g.
Ranger, where a DFS_URI input is
+ * READ-checked and a DFS_URI output is WRITE-checked) require WRITE on the
location rather than READ alone,
+ * matching the fact that purge deletes files there.
+ *
+ * <p>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 outputs = 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, outputs,
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<String, String> 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<String,
String> namespaceMetadata,
+ String location) {
+ var externalRoot = namespaceMetadata.get("location");
+ if (externalRoot != null && FileUtils.isPathWithinSubtree(new
Path(location), new Path(externalRoot))) {
+ return;
+ }
+ throw new ForbiddenException(
+ "Location %s is not authorized and is not contained in namespace %s",
location, databaseName);
+ }
}
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 206d72c9cb1..b4cd3c15b83 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,68 @@ 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();
+
+ var ownedDirectory =
java.nio.file.Path.of(MockHiveAuthorizer.ALLOWED_PREFIX,
"structural-fence-attacker", "data");
+ Files.createDirectories(ownedDirectory);
+ var ownedFile = ownedDirectory.resolve("owned-data.txt");
+ Files.writeString(ownedFile, "owned data");
+
+ DataFile victimDataFile = DataFiles.builder(table.spec())
+ .withPath(victimFile.toUri().toString())
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(Files.size(victimFile))
+ .withRecordCount(1)
+ .build();
+ DataFile ownedDataFile = DataFiles.builder(table.spec())
+ .withPath(ownedFile.toUri().toString())
+ .withFormat(FileFormat.PARQUET)
+ .withFileSizeInBytes(Files.size(ownedFile))
+ .withRecordCount(1)
+ .build();
+
table.newAppend().appendFile(victimDataFile).appendFile(ownedDataFile).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");
+ Assertions.assertFalse(Files.exists(ownedFile), "purge must delete files
nested inside 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 0d13414a007..bf71bfe7b74 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
@@ -45,10 +45,12 @@
import org.apache.hadoop.security.UserGroupInformation;
import org.apache.iceberg.Schema;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.ForbiddenException;
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.mockito.ArgumentCaptor;
@@ -265,4 +267,136 @@ void testTranslateAuthorizationPluginException() throws
Exception {
Assertions.assertEquals("Failed to check privileges stage-create",
exception.getMessage());
Assertions.assertSame(failure, exception.getCause());
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testValidateRegisterTableAuthorized() throws Exception {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+
+ var metadataLocation = LOCATION + "/metadata/v1.metadata.json";
+ var request =
ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(metadataLocation).build();
+
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE, Map.of(),
request);
+
+ var operation = ArgumentCaptor.forClass(HiveOperationType.class);
+ var inputs = ArgumentCaptor.forClass(List.class);
+ var context = ArgumentCaptor.forClass(HiveAuthzContext.class);
+ verify(hiveAuthorizer).checkPrivileges(operation.capture(),
inputs.capture(), anyList(), context.capture());
+
+ Assertions.assertEquals(HiveOperationType.CREATETABLE,
operation.getValue());
+ Assertions.assertEquals("register table " + TABLE_NAME,
context.getValue().getCommandString());
+
+ var checkedLocation = (HivePrivilegeObject) inputs.getValue().getFirst();
+
assertThat(checkedLocation.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(checkedLocation.getObjectName()).isEqualTo(metadataLocation);
+ }
+
+ @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 request =
ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(LOCATION).build();
+ var exception = Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE,
Map.of(), request));
+ Assertions.assertEquals("access denied", exception.getMessage());
+ }
+
+ @Test
+ void testValidateRegisterTableWithMultiLevelNamespace() {
+ var hiveAuthorizer = mock(HiveAuthorizer.class);
+ var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer);
+ 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));
+ Assertions.assertEquals("Hive does not support multi-level namespaces",
exception.getMessage());
+ Mockito.verifyNoInteractions(hiveAuthorizer);
+ }
+
+ @Test
+ void testValidateRegisterTableNoAuthorizerFallbackAllowed() {
+ var icebergAuthorizer = new IcebergAuthorizer(() -> null);
+
+ var externalRoot = "file:/warehouse/external";
+ var namespaceMetadata = Map.of("location", externalRoot);
+ var metadataLocation = externalRoot + "/table/metadata/v1.metadata.json";
+ var request =
ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(metadataLocation).build();
+
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE,
namespaceMetadata, request);
+ }
+
+ @Test
+ void testValidateRegisterTableNoAuthorizerFallbackDenied() {
+ var icebergAuthorizer = new IcebergAuthorizer(() -> null);
+
+ var request =
ImmutableRegisterTableRequest.builder().name(TABLE_NAME).metadataLocation(LOCATION).build();
+ Assertions.assertThrows(ForbiddenException.class, () ->
+ icebergAuthorizer.validateRegisterTable(CATALOG_NAME, NAMESPACE,
Map.of(), request));
+ }
+
+ @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 inputLocation = (HivePrivilegeObject) inputs.getValue().getFirst();
+
assertThat(inputLocation.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(inputLocation.getObjectName()).isEqualTo(LOCATION);
+ Assertions.assertEquals(1, outputs.getValue().size());
+ var outputLocation = (HivePrivilegeObject) outputs.getValue().getFirst();
+
assertThat(outputLocation.getType()).isEqualTo(HivePrivilegeObjectType.DFS_URI);
+ assertThat(outputLocation.getObjectName()).isEqualTo(LOCATION);
+ 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);
+ }
}