Skip to content

Commit d846f3b

Browse files
authored
Merge pull request #3 from Automattic/feat/spark-drop-permission-check-1.11.0
Spark: block DROP TABLE when the user cannot delete the table data on HDFS (1.11.0)
2 parents de0a710 + 4947430 commit d846f3b

12 files changed

Lines changed: 1884 additions & 14 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.iceberg.spark;
20+
21+
import java.io.FileNotFoundException;
22+
import java.io.IOException;
23+
import java.io.UncheckedIOException;
24+
import java.util.Set;
25+
import org.apache.hadoop.conf.Configuration;
26+
import org.apache.hadoop.fs.FileStatus;
27+
import org.apache.hadoop.fs.FileSystem;
28+
import org.apache.hadoop.fs.Path;
29+
import org.apache.hadoop.fs.permission.FsAction;
30+
import org.apache.hadoop.security.AccessControlException;
31+
import org.apache.hadoop.security.UserGroupInformation;
32+
import org.apache.iceberg.exceptions.ValidationException;
33+
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
34+
import org.apache.spark.sql.connector.catalog.Identifier;
35+
36+
/**
37+
* Verifies, before a table is dropped, that the current user has the filesystem permissions
38+
* required to delete the table's files.
39+
*
40+
* <p>With a Hive catalog, {@code DROP TABLE} removes the metastore entry without deleting data, and
41+
* {@code DROP TABLE PURGE} removes the metastore entry <em>before</em> deleting files client-side.
42+
* In both cases a user without delete permission on the table location ends up with orphaned data
43+
* that is no longer reachable through any catalog. Failing the drop up front keeps the catalog
44+
* entry and the data consistent.
45+
*
46+
* <p>The check calls {@link FileSystem#access(Path, FsAction)}, which on HDFS is a {@code
47+
* checkAccess} RPC evaluated by the NameNode against the caller's UGI (including groups and ACLs).
48+
* Deleting a directory tree requires WRITE and EXECUTE on the directory itself and on its parent,
49+
* so both are checked. Sticky-bit protection is evaluated the way the NameNode's {@code
50+
* FSPermissionChecker} does on delete: when a directory has the sticky bit set, an entry can only
51+
* be removed by the entry's owner or the directory's owner. That rule is applied to the parent
52+
* directory (unlinking the table directory) and to the table directory's immediate children
53+
* (purging its contents). HDFS superusers bypass sticky-bit checks server-side but cannot be
54+
* detected here, so they may be blocked spuriously.
55+
*
56+
* <p>Only {@code hdfs} locations are enforced: other filesystems fall back to a client-side
57+
* mode-bit guess that is not authoritative. The check inspects only the top level, not the full
58+
* tree; Iceberg-written trees have uniform ownership in practice.
59+
*
60+
* <p>Locations that are null, empty, or missing pass through so that dangling metastore entries can
61+
* still be cleaned up. Service accounts (usernames starting with {@code bot-}) are exempt from the
62+
* check entirely.
63+
*/
64+
final class DropTablePermissionValidator {
65+
66+
private static final Set<String> ENFORCED_SCHEMES = ImmutableSet.of("hdfs");
67+
private static final String EXEMPT_USER_PREFIX = "bot-";
68+
69+
private DropTablePermissionValidator() {}
70+
71+
static void validate(Identifier ident, String location, Configuration conf) {
72+
if (location == null || location.isEmpty()) {
73+
return;
74+
}
75+
76+
Path path = new Path(location);
77+
FileSystem fs;
78+
try {
79+
fs = path.getFileSystem(conf);
80+
} catch (IOException e) {
81+
throw new UncheckedIOException(
82+
String.format("Cannot drop table %s: failed to access filesystem for %s", ident, path),
83+
e);
84+
}
85+
86+
validate(ident, path, fs);
87+
}
88+
89+
static void validate(Identifier ident, Path path, FileSystem fs) {
90+
if (!ENFORCED_SCHEMES.contains(fs.getUri().getScheme())) {
91+
return;
92+
}
93+
94+
if (currentUser().startsWith(EXEMPT_USER_PREFIX)) {
95+
return;
96+
}
97+
98+
try {
99+
FileStatus tableDir;
100+
try {
101+
tableDir = fs.getFileStatus(path);
102+
} catch (FileNotFoundException e) {
103+
return;
104+
}
105+
106+
fs.access(path, FsAction.WRITE_EXECUTE);
107+
108+
Path parent = path.getParent();
109+
if (parent != null) {
110+
fs.access(parent, FsAction.WRITE_EXECUTE);
111+
checkStickyBit(ident, fs.getFileStatus(parent), tableDir);
112+
}
113+
114+
checkStickyBitOnChildren(ident, fs, tableDir);
115+
} catch (FileNotFoundException e) {
116+
// the location disappeared concurrently; nothing left to protect
117+
} catch (AccessControlException e) {
118+
throw new ValidationException(
119+
e,
120+
"Cannot drop table %s: user %s has no permission to delete data at %s",
121+
ident,
122+
currentUser(),
123+
path);
124+
} catch (IOException e) {
125+
throw new UncheckedIOException(
126+
String.format(
127+
"Cannot drop table %s: failed to check delete permission on %s", ident, path),
128+
e);
129+
}
130+
}
131+
132+
/**
133+
* Mirrors the NameNode's sticky-bit rule for deleting {@code entry} from {@code dir}: allowed
134+
* only for the owner of the entry or the owner of the directory.
135+
*/
136+
private static void checkStickyBit(Identifier ident, FileStatus dir, FileStatus entry) {
137+
if (!dir.getPermission().getStickyBit()) {
138+
return;
139+
}
140+
141+
String user = currentUser();
142+
if (user.equals(dir.getOwner()) || user.equals(entry.getOwner())) {
143+
return;
144+
}
145+
146+
throw new ValidationException(
147+
"Cannot drop table %s: sticky bit on %s prevents user %s from deleting %s (owned by %s)",
148+
ident, dir.getPath(), user, entry.getPath(), entry.getOwner());
149+
}
150+
151+
private static void checkStickyBitOnChildren(Identifier ident, FileSystem fs, FileStatus tableDir)
152+
throws IOException {
153+
if (!tableDir.getPermission().getStickyBit()) {
154+
return;
155+
}
156+
157+
String user = currentUser();
158+
if (user.equals(tableDir.getOwner())) {
159+
return;
160+
}
161+
162+
for (FileStatus child : fs.listStatus(tableDir.getPath())) {
163+
if (!user.equals(child.getOwner())) {
164+
throw new ValidationException(
165+
"Cannot drop table %s: sticky bit on %s prevents user %s from deleting %s (owned by %s)",
166+
ident, tableDir.getPath(), user, child.getPath(), child.getOwner());
167+
}
168+
}
169+
}
170+
171+
private static String currentUser() {
172+
try {
173+
return UserGroupInformation.getCurrentUser().getShortUserName();
174+
} catch (IOException e) {
175+
return "<unknown>";
176+
}
177+
}
178+
}

spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ public class SparkCatalog extends BaseCatalog {
137137
private ViewCatalog asViewCatalog = null;
138138
private String[] defaultNamespace = null;
139139
private HadoopTables tables;
140+
private Configuration hadoopConf;
140141

141142
/**
142143
* Build an Iceberg {@link Catalog} to be used by this Spark catalog adapter.
@@ -400,12 +401,29 @@ public boolean purgeTable(Identifier ident) {
400401

401402
private boolean dropTableWithoutPurging(Identifier ident) {
402403
if (isPathIdentifier(ident)) {
403-
return tables.dropTable(((PathIdentifier) ident).location(), false /* don't purge data */);
404+
String location = ((PathIdentifier) ident).location();
405+
DropTablePermissionValidator.validate(ident, location, hadoopConf);
406+
return tables.dropTable(location, false /* don't purge data */);
404407
} else {
408+
validateDeletePermission(ident);
405409
return icebergCatalog.dropTable(buildIdentifier(ident), false /* don't purge data */);
406410
}
407411
}
408412

413+
private void validateDeletePermission(Identifier ident) {
414+
org.apache.iceberg.Table table;
415+
try {
416+
table = icebergCatalog.loadTable(buildIdentifier(ident));
417+
} catch (org.apache.iceberg.exceptions.NoSuchTableException
418+
| org.apache.iceberg.exceptions.NotFoundException
419+
| org.apache.iceberg.exceptions.RuntimeIOException e) {
420+
// missing table or unreadable metadata: no data to protect, let the drop proceed
421+
return;
422+
}
423+
424+
DropTablePermissionValidator.validate(ident, table.location(), hadoopConf);
425+
}
426+
409427
@Override
410428
public void renameTable(Identifier from, Identifier to)
411429
throws NoSuchTableException, TableAlreadyExistsException {
@@ -778,8 +796,8 @@ public final void initialize(String name, CaseInsensitiveStringMap options) {
778796

779797
this.catalogName = name;
780798
SparkSession sparkSession = SparkSession.active();
781-
this.tables =
782-
new HadoopTables(SparkUtil.hadoopConfCatalogOverrides(SparkSession.active(), name));
799+
this.hadoopConf = SparkUtil.hadoopConfCatalogOverrides(sparkSession, name);
800+
this.tables = new HadoopTables(hadoopConf);
783801
this.icebergCatalog =
784802
cacheEnabled
785803
? CachingCatalog.wrap(catalog, cacheCaseSensitive, cacheExpirationIntervalMs)

0 commit comments

Comments
 (0)