This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new ac389e00e2 [MINOR] fix(spark): enforce PostgreSQL write privileges on
branch-1.3 (#14529)
ac389e00e2 is described below
commit ac389e00e210cc1294959e3af179ad2f2e53318d
Author: Qi Yu <[email protected]>
AuthorDate: Mon Nov 2 19:37:21 2026 +0800
[MINOR] fix(spark): enforce PostgreSQL write privileges on branch-1.3
(#14529)
### What changes were proposed in this pull request?
Override PostgreSQL Spark 3.5's write-aware loadTable on branch-1.3 and
delegate to the existing shared loadTableForWriting authorization path.
Add direct write-privilege cases and real Spark SQL INSERT/INSERT
OVERWRITE planning regressions.
### Why are the changes needed?
Spark's default write-aware overload falls back to the read load.
Require MODIFY_TABLE before physical table access for writes through
this connector.
Fixes #14525
Main already has the common guard from #12414; this PR targets
branch-1.3 only.
### Does this PR introduce _any_ user-facing change?
Normal PostgreSQL Spark 3.5 writes on branch-1.3 now require
MODIFY_TABLE and fail before accessing the physical table when denied.
This preserves the existing trusted-connector authorization model; it
does not add a new server security boundary.
### How was this patch tested?
- Entire Spark 3.5 module test task: 7 cases across 2 classes, no
failures.
- New cases cover all TableWritePrivilege enum values, denial before
delegate access, and actual INSERT/INSERT OVERWRITE SQL planning with
controlled metadata and physical-table boundaries.
- Reverting the override fails both new SQL authorization regressions;
restored code passes.
- Exact-head independent cumulative review. Deployed REST + PostgreSQL +
released-artifact end-to-end verification remains separate.
---
spark-connector/v3.5/spark/build.gradle.kts | 1 +
.../GravitinoPostgreSqlCatalogSpark35.java | 12 ++
.../TestPostgreSqlWriteAuthorization13.java | 221 +++++++++++++++++++++
3 files changed, 234 insertions(+)
diff --git a/spark-connector/v3.5/spark/build.gradle.kts
b/spark-connector/v3.5/spark/build.gradle.kts
index d59088d5bf..6393340ec7 100644
--- a/spark-connector/v3.5/spark/build.gradle.kts
+++ b/spark-connector/v3.5/spark/build.gradle.kts
@@ -49,6 +49,7 @@ if (hasProperty("excludePackagesForSparkConnector")) {
}
dependencies {
+ testImplementation(libs.mockito.core)
implementation(project(":spark-connector:spark-3.4"))
implementation(project(":spark-connector:spark-common"))
compileOnly("org.apache.kyuubi:kyuubi-spark-connector-hive_$scalaVersion:$kyuubiVersion")
diff --git
a/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/jdbc/postgresql/GravitinoPostgreSqlCatalogSpark35.java
b/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/jdbc/postgresql/GravitinoPostgreSqlCatalogSpark35.java
index 709013786c..58f675d1c9 100644
---
a/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/jdbc/postgresql/GravitinoPostgreSqlCatalogSpark35.java
+++
b/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/jdbc/postgresql/GravitinoPostgreSqlCatalogSpark35.java
@@ -18,11 +18,23 @@
*/
package org.apache.gravitino.spark.connector.jdbc.postgresql;
+import java.util.Set;
import org.apache.gravitino.spark.connector.PropertiesConverter;
import org.apache.gravitino.spark.connector.jdbc.GravitinoJdbcCatalog;
import org.apache.gravitino.spark.connector.jdbc.JdbcPropertiesConverter;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableWritePrivilege;
public class GravitinoPostgreSqlCatalogSpark35 extends GravitinoJdbcCatalog {
+ /** {@inheritDoc} */
+ @Override
+ public Table loadTable(Identifier ident, Set<TableWritePrivilege>
writePrivileges)
+ throws NoSuchTableException {
+ return loadTableForWriting(ident);
+ }
+
@Override
protected PropertiesConverter getPropertiesConverter() {
return JdbcPropertiesConverter.getPGInstance();
diff --git
a/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/jdbc/postgresql/TestPostgreSqlWriteAuthorization13.java
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/jdbc/postgresql/TestPostgreSqlWriteAuthorization13.java
new file mode 100644
index 0000000000..037884f1d1
--- /dev/null
+++
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/jdbc/postgresql/TestPostgreSqlWriteAuthorization13.java
@@ -0,0 +1,221 @@
+/*
+ * 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.jdbc.postgresql;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+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.catalog.GravitinoCatalogManager;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.catalog.Identifier;
+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.catalog.TableWritePrivilege;
+import org.apache.spark.sql.connector.expressions.Transform;
+import org.apache.spark.sql.types.DataTypes;
+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.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Release-branch PostgreSQL writes must use the MODIFY_TABLE metadata load.
*/
+public class TestPostgreSqlWriteAuthorization13 {
+ private static Catalog sqlClient;
+ private static org.apache.spark.sql.connector.catalog.TableCatalog
sqlDelegate;
+ private Catalog client;
+ private TableCatalog metadata;
+ private org.apache.spark.sql.connector.catalog.TableCatalog spark;
+ private TestCatalog catalog;
+ private final Identifier identifier = Identifier.of(new String[] {"schema"},
"table");
+ private final NameIdentifier metadataIdentifier =
NameIdentifier.of("schema", "table");
+
+ @BeforeAll
+ static void initializeManager() {
+ GravitinoCatalogManager.create("test_metalake", () ->
mock(GravitinoClient.class));
+ }
+
+ @AfterAll
+ static void closeManager() {
+ GravitinoCatalogManager.get().close();
+ }
+
+ @BeforeEach
+ void setUp() {
+ client = mock(Catalog.class);
+ metadata = mock(TableCatalog.class);
+ spark = mock(org.apache.spark.sql.connector.catalog.TableCatalog.class);
+ when(client.asTableCatalog()).thenReturn(metadata);
+ catalog = new TestCatalog(client, spark);
+ }
+
+ @ParameterizedTest
+ @EnumSource(TableWritePrivilege.class)
+ void testWriteLoadsRequireModifyPrivilege(TableWritePrivilege privilege)
throws Exception {
+ org.apache.gravitino.rel.Table gravitinoTable =
mock(org.apache.gravitino.rel.Table.class);
+ Table physical = mock(Table.class);
+ when(metadata.loadTable(metadataIdentifier,
Set.of(Privilege.Name.MODIFY_TABLE)))
+ .thenReturn(gravitinoTable);
+ when(spark.loadTable(identifier)).thenReturn(physical);
+ Assertions.assertSame(physical, catalog.loadTable(identifier,
Set.of(privilege)));
+ verify(metadata).loadTable(metadataIdentifier,
Set.of(Privilege.Name.MODIFY_TABLE));
+ verify(metadata, never()).loadTable(metadataIdentifier);
+ }
+
+ @Test
+ void testDeniedWriteDoesNotLoadThePhysicalTable() {
+ when(metadata.loadTable(metadataIdentifier,
Set.of(Privilege.Name.MODIFY_TABLE)))
+ .thenThrow(new ForbiddenException("MODIFY_TABLE is required"));
+ Assertions.assertThrows(
+ ForbiddenException.class,
+ () -> catalog.loadTable(identifier,
Set.of(TableWritePrivilege.INSERT)));
+ verifyNoInteractions(spark);
+ }
+
+ @SuppressWarnings("deprecation")
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "INSERT INTO guarded.schema.table VALUES (1)",
+ "INSERT OVERWRITE guarded.schema.table VALUES (2)"
+ })
+ void testActualSparkSqlRequestsWriteAuthorization(String query) throws
Exception {
+ SupportsWrite physical = mock(SupportsWrite.class);
+ when(physical.name()).thenReturn("schema.table");
+ when(physical.schema()).thenReturn(new StructType().add("id",
DataTypes.IntegerType));
+ when(physical.partitioning()).thenReturn(new Transform[0]);
+ when(physical.properties()).thenReturn(Map.of());
+ when(physical.capabilities())
+ .thenReturn(
+ EnumSet.of(
+ TableCapability.BATCH_WRITE,
+ TableCapability.OVERWRITE_BY_FILTER,
+ TableCapability.TRUNCATE));
+ when(physical.newWriteBuilder(any()))
+ .thenThrow(new AssertionError("Write reached physical table"));
+ when(spark.loadTable(identifier)).thenReturn(physical);
+ when(metadata.loadTable(metadataIdentifier))
+ .thenReturn(mock(org.apache.gravitino.rel.Table.class));
+ when(metadata.loadTable(metadataIdentifier,
Set.of(Privilege.Name.MODIFY_TABLE)))
+ .thenThrow(new ForbiddenException("MODIFY_TABLE is required"));
+ sqlClient = client;
+ sqlDelegate = spark;
+ try (SparkSession session =
+ SparkSession.builder()
+ .master("local[1]")
+ .appName("PostgreSQL write authorization")
+ .config("spark.ui.enabled", "false")
+ .config("spark.driver.host", "127.0.0.1")
+ .config("spark.driver.bindAddress", "127.0.0.1")
+ .config("spark.sql.catalog.guarded", SqlCatalog.class.getName())
+ .getOrCreate()) {
+ Exception failure =
+ Assertions.assertThrows(Exception.class, () ->
session.sql(query).collect());
+ Throwable cause = failure;
+ while (!(cause instanceof ForbiddenException) && cause.getCause() !=
null) {
+ cause = cause.getCause();
+ }
+ Assertions.assertTrue(cause instanceof ForbiddenException,
failure.toString());
+ verify(metadata, atLeastOnce())
+ .loadTable(metadataIdentifier, Set.of(Privilege.Name.MODIFY_TABLE));
+ verify(physical, never()).newWriteBuilder(any());
+ }
+ }
+
+ /** A real Spark catalog plugin using controlled metadata and physical-table
boundaries. */
+ public static class SqlCatalog extends GravitinoPostgreSqlCatalogSpark35 {
+ private String catalogName;
+
+ /** {@inheritDoc} */
+ @Override
+ public void initialize(String name, CaseInsensitiveStringMap options) {
+ catalogName = name;
+ gravitinoCatalogClient = sqlClient;
+ sparkCatalog = sqlDelegate;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String name() {
+ return catalogName;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String[] defaultNamespace() {
+ return new String[] {"schema"};
+ }
+
+ /** {@inheritDoc} */
+ @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;
+ }
+ }
+
+ private static class TestCatalog extends GravitinoPostgreSqlCatalogSpark35 {
+ private TestCatalog(
+ Catalog client, org.apache.spark.sql.connector.catalog.TableCatalog
delegate) {
+ this.gravitinoCatalogClient = client;
+ this.sparkCatalog = delegate;
+ }
+
+ @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;
+ }
+ }
+}