This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 4b9ad2ceae [#10198] feat(spark): Report required table privileges
(#11627)
4b9ad2ceae is described below
commit 4b9ad2ceae704ecc765e21605da5be569e3acaaa
Author: roryqi <[email protected]>
AuthorDate: Tue Jun 30 19:34:03 2026 +0800
[#10198] feat(spark): Report required table privileges (#11627)
### What changes were proposed in this pull request?
- Pass explicit required privileges when Spark loads Gravitino tables.
- Defer table authorization failures until Spark analysis completes.
- Aggregate and report all denied tables and required privileges.
- Register the authorization check through Spark session extensions.
### Why are the changes needed?
Spark currently fails on the first table without sufficient privileges
during relation resolution. This prevents users from seeing all
privileges required by a query.
Fix: #10198
### Does this PR introduce _any_ user-facing change?
Yes. Spark queries now report all inaccessible tables and their required
Gravitino privileges in one authorization error.
### How was this patch tested?
- `./gradlew :spark-connector:spark-common:test`
- Compiled production and test sources for Spark 3.3, 3.4, and 3.5.
- Added tests for privilege propagation, proxy delegation, aggregated
reporting, and extension registration.
---
.../authorization/AuthorizationTable.java | 193 +++++++++++++++++++++
...avitinoAuthorizationSparkSessionExtensions.java | 96 ++++++++++
.../authorization/RequiredPrivilegesCheck.java | 46 +++++
.../spark/connector/catalog/BaseCatalog.java | 35 +++-
.../connector/plugin/GravitinoDriverPlugin.java | 8 +-
.../authorization/TestAuthorizationTable.java | 101 +++++++++++
.../authorization/TestRequiredPrivilegesCheck.java | 75 ++++++++
.../TestRequiredPrivilegesSparkResolution.java | 184 ++++++++++++++++++++
.../catalog/TestBaseCatalogAuthorization.java | 177 +++++++++++++++++++
.../plugin/TestGravitinoDriverPlugin.java | 27 +++
10 files changed, 938 insertions(+), 4 deletions(-)
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/AuthorizationTable.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/AuthorizationTable.java
new file mode 100644
index 0000000000..73f94706e6
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/AuthorizationTable.java
@@ -0,0 +1,193 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.spark.sql.connector.catalog.SupportsRead;
+import org.apache.spark.sql.connector.catalog.SupportsWrite;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableCapability;
+import org.apache.spark.sql.connector.expressions.Transform;
+import org.apache.spark.sql.connector.read.ScanBuilder;
+import org.apache.spark.sql.connector.write.LogicalWriteInfo;
+import org.apache.spark.sql.connector.write.WriteBuilder;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+
+/**
+ * A placeholder Spark table returned when the caller lacks the Gravitino
privileges to load a
+ * table.
+ *
+ * <p>The connector does not load the real Spark table for a denied table,
because that load would
+ * bypass the very authorization the caller is missing. Instead {@link #deny}
records the table and
+ * its required privileges in a per-thread collector and returns this
placeholder so Spark's {@code
+ * ResolveRelations} can finish resolving every relation in the query. {@link
+ * RequiredPrivilegesCheck} then drains the collector once resolution
completes and reports all
+ * denied tables together, before analysis fails on anything else.
+ *
+ * <p>The metadata methods ({@link #name}, {@link #schema}, ...) return
harmless placeholders so the
+ * relation can be built during resolution, while the data-access methods
({@link #newScanBuilder},
+ * {@link #newWriteBuilder}) fail closed: should the authorization check ever
be bypassed, the table
+ * still cannot be read or written.
+ */
+public class AuthorizationTable implements Table, SupportsRead, SupportsWrite {
+
+ // Denied tables discovered while resolving the relations of a single query,
keyed by the fully
+ // qualified table identifier. Held per-thread because Spark analyzes one
query per thread.
+ private static final ThreadLocal<DeniedTables> DENIED_TABLES =
+ ThreadLocal.withInitial(DeniedTables::new);
+
+ private static final StructType EMPTY_SCHEMA = new StructType();
+ private static final Transform[] EMPTY_PARTITIONING = new Transform[0];
+ private static final Set<TableCapability> CAPABILITIES =
+ ImmutableSet.of(
+ TableCapability.BATCH_READ,
+ TableCapability.BATCH_WRITE,
+ TableCapability.TRUNCATE,
+ TableCapability.OVERWRITE_BY_FILTER,
+ TableCapability.OVERWRITE_DYNAMIC);
+
+ private final String name;
+ private final ForbiddenException forbiddenException;
+
+ private AuthorizationTable(String name, ForbiddenException
forbiddenException) {
+ this.name = name;
+ this.forbiddenException = forbiddenException;
+ }
+
+ /**
+ * Records a denied table for the current thread and returns a placeholder
table to keep in the
+ * analyzed plan.
+ *
+ * @param name the simple table name surfaced to Spark
+ * @param tableIdentifier the fully qualified Gravitino table identifier
+ * @param requiredPrivileges the privileges required to use the table
+ * @param forbiddenException the original authorization failure
+ * @return a placeholder table carrying the authorization failure
+ */
+ public static Table deny(
+ String name,
+ String tableIdentifier,
+ Set<Privilege.Name> requiredPrivileges,
+ ForbiddenException forbiddenException) {
+ DENIED_TABLES.get().record(tableIdentifier, requiredPrivileges,
forbiddenException);
+ return new AuthorizationTable(name, forbiddenException);
+ }
+
+ /**
+ * Returns an aggregated authorization failure for every denied table
collected on the current
+ * thread, then clears the collector. Returns {@link Optional#empty()} when
no table was denied.
+ *
+ * @return the aggregated failure, or empty if there is none
+ */
+ public static Optional<ForbiddenException> drainFailure() {
+ try {
+ return DENIED_TABLES.get().failure();
+ } finally {
+ clear();
+ }
+ }
+
+ /** Clears the denied tables collected on the current thread. */
+ public static void clear() {
+ DENIED_TABLES.remove();
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public StructType schema() {
+ return EMPTY_SCHEMA;
+ }
+
+ @Override
+ public Transform[] partitioning() {
+ return EMPTY_PARTITIONING;
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Set<TableCapability> capabilities() {
+ return CAPABILITIES;
+ }
+
+ @Override
+ public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) {
+ throw forbiddenException;
+ }
+
+ @Override
+ public WriteBuilder newWriteBuilder(LogicalWriteInfo info) {
+ throw forbiddenException;
+ }
+
+ private static class DeniedTables {
+ private final Map<String, Set<Privilege.Name>> tables = new TreeMap<>();
+ private ForbiddenException firstFailure;
+
+ private void record(
+ String tableIdentifier,
+ Set<Privilege.Name> requiredPrivileges,
+ ForbiddenException forbiddenException) {
+ tables
+ .computeIfAbsent(tableIdentifier, ignored -> new TreeSet<>())
+ .addAll(requiredPrivileges);
+ if (firstFailure == null) {
+ firstFailure = forbiddenException;
+ }
+ }
+
+ private Optional<ForbiddenException> failure() {
+ if (tables.isEmpty()) {
+ return Optional.empty();
+ }
+
+ String requirements =
+ tables.entrySet().stream()
+ .map(
+ entry ->
+ entry.getKey()
+ + ": "
+ + entry.getValue().stream()
+ .map(Privilege.Name::name)
+ .collect(Collectors.joining(", ")))
+ .collect(Collectors.joining("; "));
+ return Optional.of(
+ new ForbiddenException(
+ firstFailure, "Missing required privileges for Spark tables:
[%s]", requirements));
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/GravitinoAuthorizationSparkSessionExtensions.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/GravitinoAuthorizationSparkSessionExtensions.java
new file mode 100644
index 0000000000..fbdaeb9ce1
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/GravitinoAuthorizationSparkSessionExtensions.java
@@ -0,0 +1,96 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import org.apache.spark.sql.SparkSessionExtensions;
+import org.apache.spark.sql.catalyst.FunctionIdentifier;
+import org.apache.spark.sql.catalyst.TableIdentifier;
+import org.apache.spark.sql.catalyst.expressions.Expression;
+import org.apache.spark.sql.catalyst.parser.ParseException;
+import org.apache.spark.sql.catalyst.parser.ParserInterface;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.StructType;
+import scala.Function1;
+import scala.collection.immutable.Seq;
+
+/** Registers Gravitino authorization checks with a Spark session. */
+public class GravitinoAuthorizationSparkSessionExtensions
+ implements Function1<SparkSessionExtensions, Void> {
+
+ @Override
+ public Void apply(SparkSessionExtensions extensions) {
+ // Post-hoc resolution runs after every relation is resolved but before
checkAnalysis, so all
+ // denied tables are reported together rather than failing on the first
resolution error.
+ extensions.injectPostHocResolutionRule(session -> new
RequiredPrivilegesCheck());
+ extensions.injectParser((session, parser) -> new
AuthorizationParser(parser));
+ return null;
+ }
+
+ private static class AuthorizationParser implements ParserInterface {
+ private final ParserInterface delegate;
+
+ private AuthorizationParser(ParserInterface delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public LogicalPlan parsePlan(String sqlText) throws ParseException {
+ AuthorizationTable.clear();
+ return delegate.parsePlan(sqlText);
+ }
+
+ @Override
+ public Expression parseExpression(String sqlText) throws ParseException {
+ return delegate.parseExpression(sqlText);
+ }
+
+ @Override
+ public TableIdentifier parseTableIdentifier(String sqlText) throws
ParseException {
+ return delegate.parseTableIdentifier(sqlText);
+ }
+
+ @Override
+ public FunctionIdentifier parseFunctionIdentifier(String sqlText) throws
ParseException {
+ return delegate.parseFunctionIdentifier(sqlText);
+ }
+
+ @Override
+ public Seq<String> parseMultipartIdentifier(String sqlText) throws
ParseException {
+ return delegate.parseMultipartIdentifier(sqlText).toList();
+ }
+
+ @Override
+ public LogicalPlan parseQuery(String sqlText) throws ParseException {
+ AuthorizationTable.clear();
+ return delegate.parseQuery(sqlText);
+ }
+
+ @Override
+ public StructType parseTableSchema(String sqlText) throws ParseException {
+ return delegate.parseTableSchema(sqlText);
+ }
+
+ @Override
+ public DataType parseDataType(String sqlText) throws ParseException {
+ return delegate.parseDataType(sqlText);
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/RequiredPrivilegesCheck.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/RequiredPrivilegesCheck.java
new file mode 100644
index 0000000000..902e30045e
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/authorization/RequiredPrivilegesCheck.java
@@ -0,0 +1,46 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import java.util.Optional;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.catalyst.rules.Rule;
+
+/**
+ * A post-hoc resolution rule that reports the tables a query lacks Gravitino
privileges for.
+ *
+ * <p>It runs after Spark's {@code ResolveRelations} has resolved every
relation in the query, so
+ * all denied tables have been collected by {@link AuthorizationTable#deny},
but before {@code
+ * checkAnalysis} would fail the query for an unrelated reason (such as a
column that cannot be
+ * resolved against a denied table's placeholder schema). This lets a single
error list every
+ * inaccessible table and its required privileges instead of failing on the
first one.
+ */
+public class RequiredPrivilegesCheck extends Rule<LogicalPlan> {
+
+ @Override
+ public LogicalPlan apply(LogicalPlan plan) {
+ Optional<ForbiddenException> failure = AuthorizationTable.drainFailure();
+ if (failure.isPresent()) {
+ throw failure.get();
+ }
+ return plan;
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
index 228ef8a395..7ebdf160a3 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
@@ -48,6 +48,7 @@ import
org.apache.gravitino.spark.connector.SparkTableChangeConverter;
import org.apache.gravitino.spark.connector.SparkTransformConverter;
import
org.apache.gravitino.spark.connector.SparkTransformConverter.DistributionAndSortOrdersInfo;
import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.authorization.AuthorizationTable;
import org.apache.spark.sql.catalyst.analysis.NamespaceAlreadyExistsException;
import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException;
import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
@@ -260,6 +261,15 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
} catch (NoSuchTableException e) {
// Not a table in Gravitino; try as a view.
return loadViewAsTable(ident);
+ } catch (ForbiddenException e) {
+ // Do not load the underlying Spark table here: that load bypasses
Gravitino authorization,
+ // which the caller is precisely missing. Return a standalone table that
carries the failure
+ // so RequiredPrivilegesCheck can aggregate and report it during
analysis.
+ return AuthorizationTable.deny(
+ ident.name(),
+ String.format("%s.%s.%s", catalogName, getDatabase(ident),
ident.name()),
+ Sets.newHashSet(Privilege.Name.SELECT_TABLE),
+ e);
}
Table sparkTable = loadSparkTable(ident);
return createSparkTable(
@@ -491,7 +501,9 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
String database = getDatabase(ident);
return gravitinoCatalogClient
.asTableCatalog()
- .loadTable(NameIdentifier.of(database, ident.name()));
+ .loadTable(
+ NameIdentifier.of(database, ident.name()),
+ Sets.newHashSet(Privilege.Name.SELECT_TABLE));
} catch (org.apache.gravitino.exceptions.NoSuchTableException e) {
throw new NoSuchTableException(ident);
}
@@ -580,7 +592,26 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
protected Table loadTableForWriting(Identifier ident)
throws NoSuchTableException, ForbiddenException {
- org.apache.gravitino.rel.Table gravitinoTable =
loadGravitinoTableForWriting(ident);
+ org.apache.gravitino.rel.Table gravitinoTable;
+ try {
+ gravitinoTable = loadGravitinoTableForWriting(ident);
+ } catch (ForbiddenException e) {
+ // Do not load the underlying Spark table here: that load bypasses
Gravitino authorization,
+ // which the caller is precisely missing.
+ //
+ // Unlike the read path, a write command targets a single table, so
there is nothing to
+ // aggregate across relations. Returning an empty-schema placeholder
here would let Spark's
+ // ResolveOutputRelation reject the write with a column-arity error
before
+ // RequiredPrivilegesCheck runs, surfacing the wrong failure and leaving
the recorded denial
+ // on the thread to leak into later queries. Record the denial and
surface the aggregated
+ // authorization failure immediately, which also clears the per-thread
collector.
+ AuthorizationTable.deny(
+ ident.name(),
+ String.format("%s.%s.%s", catalogName, getDatabase(ident),
ident.name()),
+ Sets.newHashSet(Privilege.Name.MODIFY_TABLE),
+ e);
+ throw AuthorizationTable.drainFailure().orElse(e);
+ }
org.apache.spark.sql.connector.catalog.Table sparkTable =
loadSparkTable(ident);
// Will create a catalog specific table
return createSparkTable(
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
index ea0e03aa9d..90e7830bff 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
@@ -46,6 +46,7 @@ import
org.apache.gravitino.client.GravitinoClient.ClientBuilder;
import org.apache.gravitino.client.GravitinoClientConfiguration;
import org.apache.gravitino.client.KerberosTokenProvider;
import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import
org.apache.gravitino.spark.connector.authorization.GravitinoAuthorizationSparkSessionExtensions;
import org.apache.gravitino.spark.connector.catalog.GravitinoCatalogManager;
import
org.apache.gravitino.spark.connector.iceberg.extensions.GravitinoIcebergSparkSessionExtensions;
import org.apache.gravitino.spark.connector.version.CatalogNameAdaptor;
@@ -80,7 +81,9 @@ public class GravitinoDriverPlugin implements DriverPlugin {
GravitinoIcebergSparkSessionExtensions.class.getName(),
ICEBERG_SPARK_EXTENSIONS);
private final List<String> gravitinoPaimonExtensions =
Arrays.asList(PAIMON_SPARK_EXTENSIONS);
- private final List<String> gravitinoDriverExtensions = new ArrayList<>();
+ private final List<String> gravitinoDriverExtensions =
+ new ArrayList<>(
+
Collections.singletonList(GravitinoAuthorizationSparkSessionExtensions.class.getName()));
private boolean enableIcebergSupport = false;
private boolean enablePaimonSupport = false;
@@ -173,7 +176,8 @@ public class GravitinoDriverPlugin implements DriverPlugin {
LOG.info("Register {} catalog to Spark catalog manager.", catalogName);
}
- private void registerSqlExtensions(SparkConf conf) {
+ @VisibleForTesting
+ void registerSqlExtensions(SparkConf conf) {
String extensionString = String.join(COMMA, gravitinoDriverExtensions);
if (conf.contains(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key())) {
String sparkSessionExtensions =
conf.get(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key());
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestAuthorizationTable.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestAuthorizationTable.java
new file mode 100644
index 0000000000..38d9a645fe
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestAuthorizationTable.java
@@ -0,0 +1,101 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Optional;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.spark.sql.connector.catalog.SupportsRead;
+import org.apache.spark.sql.connector.catalog.SupportsWrite;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableCapability;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class TestAuthorizationTable {
+
+ @AfterEach
+ void clearCollector() {
+ AuthorizationTable.clear();
+ }
+
+ @Test
+ void testDenyCollectsAndAggregatesDeniedTables() {
+ ForbiddenException firstCause = new ForbiddenException("denied table_a");
+ Table table =
+ AuthorizationTable.deny(
+ "table_a",
+ "catalog.schema.table_a",
+ ImmutableSet.of(Privilege.Name.SELECT_TABLE),
+ firstCause);
+ AuthorizationTable.deny(
+ "table_b",
+ "catalog.schema.table_b",
+ ImmutableSet.of(Privilege.Name.MODIFY_TABLE),
+ new ForbiddenException("denied table_b"));
+
+ assertTrue(table instanceof SupportsRead);
+ assertTrue(table instanceof SupportsWrite);
+ assertTrue(table.capabilities().contains(TableCapability.BATCH_READ));
+ assertTrue(table.capabilities().contains(TableCapability.BATCH_WRITE));
+
+ Optional<ForbiddenException> failure = AuthorizationTable.drainFailure();
+ assertTrue(failure.isPresent());
+ assertEquals(
+ "Missing required privileges for Spark tables: "
+ + "[catalog.schema.table_a: SELECT_TABLE; catalog.schema.table_b:
MODIFY_TABLE]",
+ failure.get().getMessage());
+ assertSame(firstCause, failure.get().getCause());
+
+ // The collector is drained, so a subsequent query starts clean.
+ assertFalse(AuthorizationTable.drainFailure().isPresent());
+ }
+
+ @Test
+ void testFailsClosedWhenAccessed() {
+ ForbiddenException cause = new ForbiddenException("denied");
+ Table table =
+ AuthorizationTable.deny(
+ "table_a",
+ "catalog.schema.table_a",
+ ImmutableSet.of(Privilege.Name.SELECT_TABLE),
+ cause);
+
+ assertSame(
+ cause,
+ assertThrows(ForbiddenException.class, () -> ((SupportsRead)
table).newScanBuilder(null)));
+ assertSame(
+ cause,
+ assertThrows(
+ ForbiddenException.class, () -> ((SupportsWrite)
table).newWriteBuilder(null)));
+ }
+
+ @Test
+ void testDrainFailureEmptyWhenNothingDenied() {
+ assertFalse(AuthorizationTable.drainFailure().isPresent());
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesCheck.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesCheck.java
new file mode 100644
index 0000000000..73c082206c
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesCheck.java
@@ -0,0 +1,75 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.catalyst.plans.logical.OneRowRelation;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class TestRequiredPrivilegesCheck {
+
+ @AfterEach
+ void clearCollector() {
+ AuthorizationTable.clear();
+ }
+
+ @Test
+ void testReportsAllDeniedTablesInDeterministicOrder() {
+ AuthorizationTable.deny(
+ "table_b",
+ "catalog.schema.table_b",
+ ImmutableSet.of(Privilege.Name.MODIFY_TABLE),
+ new ForbiddenException("denied table_b"));
+ AuthorizationTable.deny(
+ "table_a",
+ "catalog.schema.table_a",
+ ImmutableSet.of(Privilege.Name.SELECT_TABLE),
+ new ForbiddenException("denied table_a"));
+ AuthorizationTable.deny(
+ "table_a",
+ "catalog.schema.table_a",
+ ImmutableSet.of(Privilege.Name.MODIFY_TABLE),
+ new ForbiddenException("denied table_a"));
+
+ LogicalPlan plan = new OneRowRelation();
+ ForbiddenException exception =
+ assertThrows(ForbiddenException.class, () -> new
RequiredPrivilegesCheck().apply(plan));
+
+ assertEquals(
+ "Missing required privileges for Spark tables: "
+ + "[catalog.schema.table_a: MODIFY_TABLE, SELECT_TABLE; "
+ + "catalog.schema.table_b: MODIFY_TABLE]",
+ exception.getMessage());
+ }
+
+ @Test
+ void testReturnsPlanUnchangedWhenNothingDenied() {
+ LogicalPlan plan = new OneRowRelation();
+ assertSame(plan, new RequiredPrivilegesCheck().apply(plan));
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesSparkResolution.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesSparkResolution.java
new file mode 100644
index 0000000000..c6a41bc393
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/authorization/TestRequiredPrivilegesSparkResolution.java
@@ -0,0 +1,184 @@
+/*
+ * 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.gravitino.spark.connector.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.connector.catalog.TableChange;
+import org.apache.spark.sql.connector.expressions.Transform;
+import org.apache.spark.sql.internal.StaticSQLConf;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+/**
+ * Drives a real Spark analyzer to prove how {@link AuthorizationTable} and
{@link
+ * RequiredPrivilegesCheck} behave end to end: every relation is resolved
first (Spark calls {@code
+ * schema()} on each denied table while building the relation), and the
post-hoc resolution rule
+ * then reports all denied tables together, before analysis fails for any
other reason.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestRequiredPrivilegesSparkResolution {
+
+ private SparkSession spark;
+
+ @BeforeAll
+ void startSpark() {
+ spark =
+ SparkSession.builder()
+ .master("local[1]")
+ .appName("test-authorization-resolution")
+ .config("spark.ui.enabled", "false")
+ .config("spark.sql.catalog.denied",
DeniedTableCatalog.class.getName())
+ .config(
+ StaticSQLConf.SPARK_SESSION_EXTENSIONS().key(),
+ GravitinoAuthorizationSparkSessionExtensions.class.getName())
+ .getOrCreate();
+ }
+
+ @AfterAll
+ void stopSpark() {
+ if (spark != null) {
+ spark.stop();
+ }
+ AuthorizationTable.clear();
+ }
+
+ @AfterEach
+ void clearCollector() {
+ AuthorizationTable.clear();
+ }
+
+ @Test
+ void testSelectAcrossDeniedTablesReportsEveryTable() {
+ ForbiddenException failure =
+ assertThrows(
+ ForbiddenException.class,
+ () -> spark.sql("SELECT 1 FROM denied.db.t1 UNION ALL SELECT 1
FROM denied.db.t2"));
+
+ // Both relations were resolved before the failure was raised, so a single
error lists them all.
+ assertTrue(failure.getMessage().contains("denied.db.t1: SELECT_TABLE"),
failure.getMessage());
+ assertTrue(failure.getMessage().contains("denied.db.t2: SELECT_TABLE"),
failure.getMessage());
+ }
+
+ @Test
+ void testInsertIntoDeniedTableReportsForbidden() {
+ ForbiddenException failure =
+ assertThrows(
+ ForbiddenException.class,
+ () -> spark.sql("INSERT INTO denied.db.t1 SELECT * FROM
denied.db.t2"));
+
+ // Both the write target and the read source are denied and reported
together.
+ assertTrue(failure.getMessage().contains("denied.db.t1: SELECT_TABLE"),
failure.getMessage());
+ assertTrue(failure.getMessage().contains("denied.db.t2: SELECT_TABLE"),
failure.getMessage());
+ }
+
+ @Test
+ void testColumnReferenceReportsForbiddenInsteadOfUnresolvedColumn() {
+ // The denied table exposes an empty placeholder schema, so "missing_col"
cannot be resolved.
+ // The post-hoc rule runs before checkAnalysis, so the authorization
failure wins over the
+ // "cannot resolve column" error.
+ ForbiddenException failure =
+ assertThrows(
+ ForbiddenException.class, () -> spark.sql("SELECT missing_col FROM
denied.db.t1"));
+
+ assertTrue(failure.getMessage().contains("denied.db.t1: SELECT_TABLE"),
failure.getMessage());
+ }
+
+ @Test
+ void testSqlParserClearsStaleDeniedTablesBeforeNextQuery() throws Exception {
+ AuthorizationTable.deny(
+ "stale_table",
+ "catalog.schema.stale_table",
+ ImmutableSet.of(Privilege.Name.SELECT_TABLE),
+ new ForbiddenException("denied stale table"));
+
+ spark.sessionState().sqlParser().parsePlan("SELECT 1");
+
+ assertFalse(AuthorizationTable.drainFailure().isPresent());
+ }
+
+ /** A catalog that denies every table, used to exercise the authorization
resolution path. */
+ public static class DeniedTableCatalog implements TableCatalog {
+
+ private String name;
+
+ @Override
+ public void initialize(String name, CaseInsensitiveStringMap options) {
+ this.name = name;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public Table loadTable(Identifier ident) {
+ String tableIdentifier =
+ name + "." + String.join(".", ident.namespace()) + "." +
ident.name();
+ return AuthorizationTable.deny(
+ ident.name(),
+ tableIdentifier,
+ ImmutableSet.of(Privilege.Name.SELECT_TABLE),
+ new ForbiddenException("denied %s", tableIdentifier));
+ }
+
+ @Override
+ public Identifier[] listTables(String[] namespace) {
+ return new Identifier[0];
+ }
+
+ @Override
+ public Table createTable(
+ Identifier ident, StructType schema, Transform[] partitions,
Map<String, String> props) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Table alterTable(Identifier ident, TableChange... changes) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean dropTable(Identifier ident) {
+ return false;
+ }
+
+ @Override
+ public void renameTable(Identifier oldIdent, Identifier newIdent) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
new file mode 100644
index 0000000000..37ef2c501a
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
@@ -0,0 +1,177 @@
+/*
+ * 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.gravitino.spark.connector.catalog;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.rel.TableCatalog;
+import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.gravitino.spark.connector.SparkTransformConverter;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.authorization.AuthorizationTable;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestBaseCatalogAuthorization {
+
+ private Catalog gravitinoCatalog;
+ private TableCatalog gravitinoTableCatalog;
+ private org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog;
+ private TestCatalog catalog;
+
+ @BeforeAll
+ void initCatalogManager() {
+ GravitinoCatalogManager.create(() -> mock(GravitinoClient.class));
+ }
+
+ @AfterAll
+ void cleanupCatalogManager() {
+ GravitinoCatalogManager.get().close();
+ }
+
+ @BeforeEach
+ void setUp() {
+ gravitinoCatalog = mock(Catalog.class);
+ gravitinoTableCatalog = mock(TableCatalog.class);
+ sparkCatalog =
mock(org.apache.spark.sql.connector.catalog.TableCatalog.class);
+ when(gravitinoCatalog.asTableCatalog()).thenReturn(gravitinoTableCatalog);
+ catalog = new TestCatalog(gravitinoCatalog, sparkCatalog);
+ }
+
+ @AfterEach
+ void clearDeniedTables() {
+ AuthorizationTable.clear();
+ }
+
+ @Test
+ void testLoadTablePassesSelectTablePrivilege() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table gravitinoTable =
mock(org.apache.gravitino.rel.Table.class);
+ Table sparkTable = mock(Table.class);
+ when(gravitinoTableCatalog.loadTable(
+ eq(gravitinoIdentifier),
eq(ImmutableSet.of(Privilege.Name.SELECT_TABLE))))
+ .thenReturn(gravitinoTable);
+ when(sparkCatalog.loadTable(identifier)).thenReturn(sparkTable);
+
+ assertSame(sparkTable, catalog.loadTable(identifier));
+
+ verify(gravitinoTableCatalog)
+ .loadTable(gravitinoIdentifier,
ImmutableSet.of(Privilege.Name.SELECT_TABLE));
+ }
+
+ @Test
+ void testDeniedReadReturnsMarkedSparkTable() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ when(gravitinoTableCatalog.loadTable(
+ eq(gravitinoIdentifier),
eq(ImmutableSet.of(Privilege.Name.SELECT_TABLE))))
+ .thenThrow(new ForbiddenException("denied"));
+
+ Table result = catalog.loadTable(identifier);
+
+ // The underlying Spark table is never loaded for a denied table; a
placeholder is returned and
+ // the required privileges are collected for RequiredPrivilegesCheck to
report during analysis.
+ assertTrue(result instanceof AuthorizationTable);
+ Optional<ForbiddenException> failure = AuthorizationTable.drainFailure();
+ assertTrue(failure.isPresent());
+ assertTrue(failure.get().getMessage().contains("table_a: SELECT_TABLE"));
+ }
+
+ @Test
+ void testLoadTableForWritingPassesModifyTablePrivilege() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table gravitinoTable =
mock(org.apache.gravitino.rel.Table.class);
+ Table sparkTable = mock(Table.class);
+ when(gravitinoTableCatalog.loadTable(
+ eq(gravitinoIdentifier),
eq(ImmutableSet.of(Privilege.Name.MODIFY_TABLE))))
+ .thenReturn(gravitinoTable);
+ when(sparkCatalog.loadTable(identifier)).thenReturn(sparkTable);
+
+ assertSame(sparkTable, catalog.loadForWriting(identifier));
+
+ verify(gravitinoTableCatalog)
+ .loadTable(gravitinoIdentifier,
ImmutableSet.of(Privilege.Name.MODIFY_TABLE));
+ }
+
+ private static class TestCatalog extends BaseCatalog {
+
+ private TestCatalog(
+ Catalog gravitinoCatalog,
+ org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog) {
+ this.gravitinoCatalogClient = gravitinoCatalog;
+ this.sparkCatalog = sparkCatalog;
+ }
+
+ private Table loadForWriting(Identifier identifier) throws Exception {
+ return loadTableForWriting(identifier);
+ }
+
+ @Override
+ protected org.apache.spark.sql.connector.catalog.TableCatalog
createAndInitSparkCatalog(
+ String name, CaseInsensitiveStringMap options, Map<String, String>
properties) {
+ return sparkCatalog;
+ }
+
+ @Override
+ protected Table createSparkTable(
+ Identifier identifier,
+ org.apache.gravitino.rel.Table gravitinoTable,
+ Table sparkTable,
+ org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog,
+ PropertiesConverter propertiesConverter,
+ SparkTransformConverter sparkTransformConverter,
+ SparkTypeConverter sparkTypeConverter) {
+ return sparkTable;
+ }
+
+ @Override
+ protected PropertiesConverter getPropertiesConverter() {
+ return mock(PropertiesConverter.class);
+ }
+
+ @Override
+ protected SparkTransformConverter getSparkTransformConverter() {
+ return mock(SparkTransformConverter.class);
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
index 9111682620..7da747c98d 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
@@ -19,7 +19,12 @@
package org.apache.gravitino.spark.connector.plugin;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import
org.apache.gravitino.spark.connector.authorization.GravitinoAuthorizationSparkSessionExtensions;
import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions;
+import org.apache.spark.SparkConf;
+import org.apache.spark.sql.internal.StaticSQLConf;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -31,4 +36,26 @@ public class TestGravitinoDriverPlugin {
IcebergSparkSessionExtensions.class.getName(),
GravitinoDriverPlugin.ICEBERG_SPARK_EXTENSIONS);
}
+
+ @Test
+ void testAlwaysRegistersAuthorizationExtension() {
+ SparkConf sparkConf = new SparkConf(false);
+
+ new GravitinoDriverPlugin().registerSqlExtensions(sparkConf);
+
+ assertEquals(
+ GravitinoAuthorizationSparkSessionExtensions.class.getName(),
+ sparkConf.get(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key()));
+ }
+
+ @Test
+ void testDoesNotDuplicateAuthorizationExtension() {
+ SparkConf sparkConf = new SparkConf(false);
+ String extension =
GravitinoAuthorizationSparkSessionExtensions.class.getName();
+ sparkConf.set(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key(), extension);
+
+ new GravitinoDriverPlugin().registerSqlExtensions(sparkConf);
+
+ assertEquals(extension,
sparkConf.get(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key()));
+ }
}