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 974da58a22 [#10902] feat(iceberg-rest): Split load table and view
authorization expressions (#10918)
974da58a22 is described below
commit 974da58a221bfa25b6194df8a324248cf96399db
Author: roryqi <[email protected]>
AuthorDate: Fri May 29 10:12:29 2026 +0800
[#10902] feat(iceberg-rest): Split load table and view authorization
expressions (#10918)
### What changes were proposed in this pull request?
This PR separates Iceberg REST load-table/load-view authorization into
primary authorization expressions and secondary existence-check
expressions.
Main changes:
- Add `allowCheckExistence` to `AuthorizationExpression` for load paths
that need existence probing after primary authorization is denied.
- Add `LoadViewAuthzHandler` and update `LoadTableAuthzHandler` to align
handler behavior with the REST method annotations.
- Split Iceberg table/view load and exists authorization constants.
- Add `ExpressionCondition` for typed secondary-expression conditions
and wire it through the Gravitino load-table authorization executor.
- Add tests for Iceberg view authorization and load-table
secondary-expression selection.
### Why are the changes needed?
Iceberg clients may probe table and view endpoints while resolving
relations. A single authorization expression makes it hard to
distinguish real load authorization from existence checks, which can
produce incorrect 403/404 behavior and block valid fallback flows
between `/tables/` and `/views/`.
Fix: #10902
### Does this PR introduce _any_ user-facing change?
No public API change. This updates authorization behavior for Iceberg
REST load table/view probes so clients get more accurate authorization
and not-found responses.
### How was this patch tested?
- Added unit tests for the new load-view authorization handler and
expression behavior.
- Added coverage for Gravitino load-table secondary-expression
selection.
- Added/updated Iceberg table authorization integration coverage.
- Ran `./gradlew spotlessApply`.
- Ran `./gradlew :server:test --tests
org.apache.gravitino.server.web.filter.authorization.TestLoadTableAuthorizationExecutor
-PskipITs`.
---
iceberg/iceberg-rest-server/build.gradle.kts | 1 +
.../service/rest/IcebergTableOperations.java | 20 +--
.../service/rest/IcebergViewOperations.java | 28 +--
.../service/rest/IcebergViewRenameOperations.java | 6 +-
.../annotations/IcebergAuthorizationMetadata.java | 1 +
...BaseMetadataAuthorizationMethodInterceptor.java | 6 +-
.../web/filter/IcebergLoadAuthzHandlerHelper.java | 188 ++++++++++++++++++++
...bergMetadataAuthorizationMethodInterceptor.java | 9 +-
.../server/web/filter/LoadTableAuthzHandler.java | 160 +++++++----------
.../server/web/filter/LoadViewAuthzHandler.java | 126 +++++++++++++
.../test/IcebergTableAuthorizationIT.java | 9 +
.../TestIcebergViewAuthorizationExpression.java | 65 ++++++-
.../filter/TestIcebergLoadAuthzHandlerHelper.java | 196 +++++++++++++++++++++
...bergMetadataAuthorizationMethodInterceptor.java | 6 +-
.../web/filter/TestLoadViewAuthzHandler.java | 114 ++++++++++++
.../annotations/AuthorizationExpression.java | 13 +-
.../annotations/ExpressionCondition.java | 24 +++
.../AuthorizationExpressionConstants.java | 65 ++++++-
.../web/filter/GravitinoInterceptionService.java | 3 +-
.../authorization/AuthorizeExecutorFactory.java | 3 +-
.../LoadTableAuthorizationExecutor.java | 50 +++---
.../gravitino/server/web/rest/TableOperations.java | 4 +-
.../TestLoadTableAuthorizationExecutor.java | 92 ++++++++++
23 files changed, 1022 insertions(+), 167 deletions(-)
diff --git a/iceberg/iceberg-rest-server/build.gradle.kts
b/iceberg/iceberg-rest-server/build.gradle.kts
index 6f2c73b8d0..60b0980987 100644
--- a/iceberg/iceberg-rest-server/build.gradle.kts
+++ b/iceberg/iceberg-rest-server/build.gradle.kts
@@ -108,6 +108,7 @@ dependencies {
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
testImplementation(libs.mockito.core)
+ testImplementation(libs.mockito.inline)
testImplementation(libs.mysql.driver)
testImplementation(libs.postgresql.driver)
testImplementation(libs.sqlite.jdbc)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index d537de2b8e..96c8a861ec 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -200,7 +200,7 @@ public class IcebergTableOperations {
expression =
"ANY(OWNER, METALAKE, CATALOG) || "
+ "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_MODIFY_TABLE)",
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_MODIFY_TABLE)",
accessMetadataType = MetadataObject.Type.TABLE)
public Response updateTable(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -245,7 +245,7 @@ public class IcebergTableOperations {
expression =
"ANY(OWNER, METALAKE, CATALOG) || "
+ "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && TABLE::OWNER ",
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && TABLE::OWNER ",
accessMetadataType = MetadataObject.Type.TABLE)
public Response dropTable(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -283,10 +283,11 @@ public class IcebergTableOperations {
@Produces(MediaType.APPLICATION_JSON)
@Timed(name = "load-table." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "load-table", absolute = true)
- // SCHEMA-level authorization; TABLE-specific authorization is handled in
LoadTableAuthzHandler
@AuthorizationExpression(
- expression =
AuthorizationExpressionConstants.LOAD_SCHEMA_AUTHORIZATION_EXPRESSION,
- accessMetadataType = MetadataObject.Type.SCHEMA)
+ expression =
AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION,
+ allowCheckExistence =
+
AuthorizationExpressionConstants.ICEBERG_TABLE_EXISTS_SECONDARY_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.TABLE)
public Response loadTable(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@AuthorizationMetadata(type = EntityType.SCHEMA) @Encoded()
@PathParam("namespace")
@@ -351,10 +352,7 @@ public class IcebergTableOperations {
@Timed(name = "table-exists." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "table-exists", absolute = true)
@AuthorizationExpression(
- expression =
- "ANY(OWNER, METALAKE, CATALOG) || "
- + "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE || ANY_CREATE_TABLE)",
+ expression =
AuthorizationExpressionConstants.ICEBERG_TABLE_EXISTS_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.TABLE)
public Response tableExists(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -450,7 +448,7 @@ public class IcebergTableOperations {
expression =
"ANY(OWNER, METALAKE, CATALOG) || "
+ "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE)",
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE)",
accessMetadataType = MetadataObject.Type.TABLE)
public Response getTableCredentials(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -504,7 +502,7 @@ public class IcebergTableOperations {
expression =
"ANY(OWNER, METALAKE, CATALOG) || "
+ "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE|| ANY_MODIFY_TABLE)",
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE)",
accessMetadataType = MetadataObject.Type.TABLE)
public Response planTableScan(
@PathParam("prefix") @AuthorizationMetadata(type = EntityType.CATALOG)
String prefix,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewOperations.java
index 2a456277dc..3685333162 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewOperations.java
@@ -54,6 +54,8 @@ import org.apache.gravitino.metrics.MetricNames;
import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.server.web.Utils;
import org.apache.gravitino.utils.HierarchicalSchemaUtil;
@@ -126,10 +128,7 @@ public class IcebergViewOperations {
@Timed(name = "create-view." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "create-view", absolute = true)
@AuthorizationExpression(
- expression =
- "ANY(OWNER, METALAKE, CATALOG) || "
- + "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && ANY_CREATE_VIEW",
+ expression =
AuthorizationExpressionConstants.ICEBERG_CREATE_VIEW_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.SCHEMA)
public Response createView(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -165,12 +164,19 @@ public class IcebergViewOperations {
@ResponseMetered(name = "load-view", absolute = true)
@AuthorizationExpression(
expression =
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+ allowCheckExistence =
+ AuthorizationExpressionConstants
+ .ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.VIEW)
public Response loadView(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@AuthorizationMetadata(type = EntityType.SCHEMA) @Encoded()
@PathParam("namespace")
String namespace,
- @AuthorizationMetadata(type = EntityType.VIEW) @Encoded()
@PathParam("view") String view) {
+ @IcebergAuthorizationMetadata(type = RequestType.LOAD_VIEW)
+ @AuthorizationMetadata(type = EntityType.VIEW)
+ @Encoded()
+ @PathParam("view")
+ String view) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
String viewName = RESTUtil.decodeString(view);
@@ -201,10 +207,7 @@ public class IcebergViewOperations {
@Timed(name = "replace-view." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "replace-view", absolute = true)
@AuthorizationExpression(
- expression =
- "ANY(OWNER, METALAKE, CATALOG) || "
- + "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && VIEW::OWNER",
+ expression =
AuthorizationExpressionConstants.ICEBERG_VIEW_OWNER_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.VIEW)
public Response replaceView(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -243,10 +246,7 @@ public class IcebergViewOperations {
@Timed(name = "drop-view." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "drop-view", absolute = true)
@AuthorizationExpression(
- expression =
- "ANY(OWNER, METALAKE, CATALOG) || "
- + "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && VIEW::OWNER",
+ expression =
AuthorizationExpressionConstants.ICEBERG_VIEW_OWNER_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.VIEW)
public Response dropView(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@@ -282,7 +282,7 @@ public class IcebergViewOperations {
@Timed(name = "view-exists." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "view-exists", absolute = true)
@AuthorizationExpression(
- expression =
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+ expression =
AuthorizationExpressionConstants.ICEBERG_VIEW_EXISTS_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.VIEW)
public Response viewExists(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewRenameOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewRenameOperations.java
index 8014f37143..40c3f6da07 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewRenameOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergViewRenameOperations.java
@@ -42,6 +42,7 @@ import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpres
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.server.web.Utils;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.slf4j.Logger;
@@ -67,10 +68,7 @@ public class IcebergViewRenameOperations {
@Timed(name = "rename-view." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "rename-view", absolute = true)
@AuthorizationExpression(
- expression =
- "ANY(OWNER, METALAKE, CATALOG) || "
- + "SCHEMA_OWNER_WITH_USE_CATALOG || "
- + "ANY_USE_CATALOG && ANY_USE_SCHEMA && VIEW::OWNER",
+ expression =
AuthorizationExpressionConstants.ICEBERG_VIEW_OWNER_AUTHORIZATION_EXPRESSION,
accessMetadataType = MetadataObject.Type.VIEW)
public Response renameView(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
index 9ba92fab56..6488acb84f 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java
@@ -30,6 +30,7 @@ import java.lang.annotation.Target;
public @interface IcebergAuthorizationMetadata {
enum RequestType {
LOAD_TABLE,
+ LOAD_VIEW,
RENAME_TABLE,
RENAME_VIEW,
CREATE_NAMESPACE,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index f2553751bd..d3ad8f0c73 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -89,13 +89,14 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor implements Meth
* <p>Override this method to provide custom handlers based on request
characteristics (e.g.,
* annotations, request types, parameters).
*
+ * @param method REST method being invoked
* @param parameters Method parameters
* @param args Method arguments
* @return Optional handler for custom authorization processing, or empty if
standard
* authorization is sufficient
*/
protected Optional<AuthorizationHandler> createAuthorizationHandler(
- Parameter[] parameters, Object[] args) {
+ Method method, Parameter[] parameters, Object[] args) {
return Optional.empty();
}
@@ -160,7 +161,8 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor implements Meth
}
// Process custom authorization if handler exists
- Optional<AuthorizationHandler> handler =
createAuthorizationHandler(parameters, args);
+ Optional<AuthorizationHandler> handler =
+ createAuthorizationHandler(method, parameters, args);
if (!skipStandardCheck && handler.isPresent()) {
AuthorizationHandler authzHandler = handler.get();
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergLoadAuthzHandlerHelper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergLoadAuthzHandlerHelper.java
new file mode 100644
index 0000000000..3f18f82d6c
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergLoadAuthzHandlerHelper.java
@@ -0,0 +1,188 @@
+/*
+ * 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.server.web.filter;
+
+import java.lang.reflect.Parameter;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BooleanSupplier;
+import java.util.function.Supplier;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.iceberg.rest.RESTUtil;
+
+/** Shared helper for Iceberg load table/view authorization handlers. */
+final class IcebergLoadAuthzHandlerHelper {
+
+ private IcebergLoadAuthzHandlerHelper() {}
+
+ static LoadTarget extractLoadTarget(
+ Parameter[] parameters, Object[] args, RequestType requestType) {
+ String targetName = null;
+ Namespace namespace = null;
+
+ for (int i = 0; i < parameters.length; i++) {
+ Parameter parameter = parameters[i];
+
+ IcebergAuthorizationMetadata icebergMetadata =
+ parameter.getAnnotation(IcebergAuthorizationMetadata.class);
+ if (icebergMetadata != null && icebergMetadata.type() == requestType) {
+ targetName = RESTUtil.decodeString(String.valueOf(args[i]));
+ }
+
+ AuthorizationMetadata authMetadata =
parameter.getAnnotation(AuthorizationMetadata.class);
+ if (authMetadata != null && authMetadata.type() == EntityType.SCHEMA) {
+ namespace = RESTUtil.decodeNamespace(String.valueOf(args[i]));
+ }
+ }
+
+ return new LoadTarget(targetName, namespace);
+ }
+
+ static LoadContext extractLoadContext(
+ Map<EntityType, NameIdentifier> nameIdentifierMap,
+ Supplier<RuntimeException> missingContextException) {
+ NameIdentifier catalogId = nameIdentifierMap.get(EntityType.CATALOG);
+ NameIdentifier schemaId = nameIdentifierMap.get(EntityType.SCHEMA);
+
+ if (catalogId == null || schemaId == null) {
+ throw missingContextException.get();
+ }
+
+ return new LoadContext(catalogId.namespace().level(0), catalogId.name(),
schemaId.name());
+ }
+
+ static IcebergCatalogWrapper getCatalogWrapper(String catalog) {
+ return IcebergRESTServerContext.getInstance()
+ .catalogWrapperManager()
+ .getCatalogWrapper(catalog);
+ }
+
+ static String resolveExpression(
+ AuthorizationExpression authorizationExpression, String
defaultExpression) {
+ if (authorizationExpression != null
+ && authorizationExpression.expression() != null
+ && !authorizationExpression.expression().isBlank()) {
+ return authorizationExpression.expression();
+ }
+ return defaultExpression;
+ }
+
+ static String resolveAllowCheckExistenceExpression(
+ AuthorizationExpression authorizationExpression, String
defaultExpression) {
+ if (authorizationExpression != null) {
+ String expr = authorizationExpression.allowCheckExistence();
+ if (expr != null && !expr.isBlank()) {
+ return expr;
+ }
+ }
+ return defaultExpression;
+ }
+
+ static void authorizeLoadEntity(
+ Map<EntityType, NameIdentifier> nameIdentifierMap,
+ String primaryExpression,
+ String allowCheckExistenceExpression,
+ BooleanSupplier exists,
+ Supplier<RuntimeException> notFoundException,
+ String entityType,
+ NameIdentifier entityId) {
+ Map<String, Object> emptyPathParams = Collections.emptyMap();
+ AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
+ Optional<String> emptyEntityType = Optional.empty();
+
+ AuthorizationExpressionEvaluator primaryEvaluator =
+ new AuthorizationExpressionEvaluator(primaryExpression);
+ if (primaryEvaluator.evaluate(
+ nameIdentifierMap, emptyPathParams, requestContext, emptyEntityType)) {
+ return;
+ }
+
+ AuthorizationExpressionEvaluator allowExistenceEvaluator =
+ new AuthorizationExpressionEvaluator(allowCheckExistenceExpression);
+ if (allowExistenceEvaluator.evaluate(
+ nameIdentifierMap, emptyPathParams, requestContext,
emptyEntityType)
+ && !exists.getAsBoolean()) {
+ throw notFoundException.get();
+ }
+
+ String currentUser = PrincipalUtils.getCurrentUserName();
+ throw new ForbiddenException(
+ "User '%s' is not authorized to load %s '%s'", currentUser,
entityType, entityId);
+ }
+
+ static final class LoadTarget {
+ private final String name;
+ private final Namespace namespace;
+
+ private LoadTarget(String name, Namespace namespace) {
+ this.name = name;
+ this.namespace = namespace;
+ }
+
+ boolean hasMissingPart() {
+ return name == null || namespace == null;
+ }
+
+ String name() {
+ return name;
+ }
+
+ Namespace namespace() {
+ return namespace;
+ }
+ }
+
+ static final class LoadContext {
+ private final String metalakeName;
+ private final String catalog;
+ private final String schema;
+
+ private LoadContext(String metalakeName, String catalog, String schema) {
+ this.metalakeName = metalakeName;
+ this.catalog = catalog;
+ this.schema = schema;
+ }
+
+ String metalakeName() {
+ return metalakeName;
+ }
+
+ String catalog() {
+ return catalog;
+ }
+
+ String schema() {
+ return schema;
+ }
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index 197ff36256..43ab6d6c03 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.server.web.filter;
+import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.HashMap;
import java.util.Map;
@@ -31,6 +32,7 @@ import
org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
@@ -109,7 +111,8 @@ public class IcebergMetadataAuthorizationMethodInterceptor
*/
@Override
protected Optional<AuthorizationHandler> createAuthorizationHandler(
- Parameter[] parameters, Object[] args) {
+ Method method, Parameter[] parameters, Object[] args) {
+ AuthorizationExpression authExpr =
method.getAnnotation(AuthorizationExpression.class);
for (Parameter parameter : parameters) {
IcebergAuthorizationMetadata icebergMetadata =
parameter.getAnnotation(IcebergAuthorizationMetadata.class);
@@ -118,7 +121,9 @@ public class IcebergMetadataAuthorizationMethodInterceptor
RequestType type = icebergMetadata.type();
switch (type) {
case LOAD_TABLE:
- return Optional.of(new LoadTableAuthzHandler(parameters, args));
+ return Optional.of(new LoadTableAuthzHandler(authExpr, parameters,
args));
+ case LOAD_VIEW:
+ return Optional.of(new LoadViewAuthzHandler(authExpr, parameters,
args));
case RENAME_TABLE:
return Optional.of(new RenameTableAuthzHandler(parameters, args));
case RENAME_VIEW:
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
index 178ce2524a..f715b52ed0 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java
@@ -20,112 +20,82 @@
package org.apache.gravitino.server.web.filter;
import java.lang.reflect.Parameter;
-import java.util.HashMap;
import java.util.Map;
-import java.util.Optional;
import org.apache.gravitino.Entity.EntityType;
import org.apache.gravitino.NameIdentifier;
-import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
-import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
-import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
-import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
-import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
-import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
import
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
+import
org.apache.gravitino.server.web.filter.IcebergLoadAuthzHandlerHelper.LoadContext;
+import
org.apache.gravitino.server.web.filter.IcebergLoadAuthzHandlerHelper.LoadTarget;
import org.apache.gravitino.utils.NameIdentifierUtil;
-import org.apache.gravitino.utils.PrincipalUtils;
import org.apache.iceberg.MetadataTableType;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
-import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.exceptions.NoSuchTableException;
-import org.apache.iceberg.rest.RESTUtil;
/**
- * Handler for LOAD_TABLE operations. Validates that the requested entity is
not a metadata table or
- * a view, and extracts the table identifier for authorization checks.
+ * Handler for LOAD_TABLE operations. Validates that the requested entity is
not a metadata table,
+ * rejects identifiers that resolve to views (so clients can fall back to
{@code /views/}), and
+ * performs authorization aligned with the REST {@link
AuthorizationExpression} on {@code
+ * loadTable}.
*
- * <p>Per Iceberg REST spec, the /tables/ endpoint should only serve tables.
If the identifier
- * refers to a view, this handler throws NoSuchTableException (404),
triggering Spark to retry with
- * the /views/ endpoint.
+ * <p>Authorization follows {@link
+ *
org.apache.gravitino.iceberg.service.rest.IcebergTableOperations#loadTable}:
primary {@code
+ * LOAD_TABLE} expression from {@link AuthorizationExpression#expression()},
then {@link
+ * AuthorizationExpression#allowCheckExistence()} when the primary denies
(existence-probe path for
+ * clients such as Trino/Spark).
*/
public class LoadTableAuthzHandler implements AuthorizationHandler {
+ private final AuthorizationExpression authorizationExpression;
private final Parameter[] parameters;
private final Object[] args;
- public LoadTableAuthzHandler(Parameter[] parameters, Object[] args) {
+ public LoadTableAuthzHandler(
+ AuthorizationExpression authorizationExpression, Parameter[] parameters,
Object[] args) {
+ this.authorizationExpression = authorizationExpression;
this.parameters = parameters;
this.args = args;
}
@Override
public void process(Map<EntityType, NameIdentifier> nameIdentifierMap) {
- // Find the table name and namespace from parameters
- String tableName = null;
- Namespace namespace = null;
-
- for (int i = 0; i < parameters.length; i++) {
- Parameter parameter = parameters[i];
-
- IcebergAuthorizationMetadata icebergMetadata =
- parameter.getAnnotation(IcebergAuthorizationMetadata.class);
- if (icebergMetadata != null && icebergMetadata.type() ==
RequestType.LOAD_TABLE) {
- // TODO: Refactor to move decode logic to interceptor in a generic way
- // See:
https://docs.google.com/document/d/18yx88tBbU3S9LB8hhL7xUVzSWHIWkXJ7sRNmLh2v_kQ/
- // Consider consolidating custom authorization handlers and
standardizing parameter decoding
- tableName = RESTUtil.decodeString(String.valueOf(args[i]));
- }
-
- AuthorizationMetadata authMetadata =
parameter.getAnnotation(AuthorizationMetadata.class);
- if (authMetadata != null && authMetadata.type() == EntityType.SCHEMA) {
- // Decode the raw Iceberg namespace parameter
- namespace = RESTUtil.decodeNamespace(String.valueOf(args[i]));
- }
- }
+ LoadTarget loadTarget =
+ IcebergLoadAuthzHandlerHelper.extractLoadTarget(parameters, args,
RequestType.LOAD_TABLE);
- if (tableName == null || namespace == null) {
+ if (loadTarget.hasMissingPart()) {
throw new NoSuchTableException("Table not found - missing table name or
namespace");
}
+ String tableName = loadTarget.name();
+ Namespace namespace = loadTarget.namespace();
+
// Validate that this is not a metadata table access
if (isMetadataTable(tableName, namespace)) {
throw new NoSuchTableException("Table %s not found", tableName);
}
- NameIdentifier catalogId = nameIdentifierMap.get(EntityType.CATALOG);
- NameIdentifier schemaId = nameIdentifierMap.get(EntityType.SCHEMA);
-
- if (catalogId == null || schemaId == null) {
- throw new NoSuchTableException("Missing catalog or schema context for
table authorization");
- }
-
- String metalakeName = catalogId.namespace().level(0);
- String catalog = catalogId.name();
- String schema = schemaId.name();
+ LoadContext loadContext =
+ IcebergLoadAuthzHandlerHelper.extractLoadContext(
+ nameIdentifierMap,
+ () ->
+ new NoSuchTableException(
+ "Missing catalog or schema context for table
authorization"));
// Per Iceberg REST spec, /tables/ endpoint should only serve tables, not
views.
- // 1. Check if it's a view first -> 404 (enables Spark fallback to /views/)
- // 2. Authorize the table access -> 403 if unauthorized
- // 3. Let request proceed - the actual loadTable() call will handle
non-existence
- IcebergCatalogWrapperManager wrapperManager =
- IcebergRESTServerContext.getInstance().catalogWrapperManager();
- IcebergCatalogWrapper catalogWrapper =
wrapperManager.getCatalogWrapper(catalog);
+ // 1. Authorize the table access (see performTableAuthorization) -> 403 if
unauthorized
+ // 2. Let request proceed - the actual loadTable() call will handle
non-existence
+ IcebergCatalogWrapper catalogWrapper =
+ IcebergLoadAuthzHandlerHelper.getCatalogWrapper(loadContext.catalog());
TableIdentifier tableIdentifier = TableIdentifier.of(namespace, tableName);
- // Only check view existence when the catalog supports view operations.
Catalogs backed by
- // JDBC without jdbc.schema-version=V1 throw UnsupportedOperationException
from viewExists(),
- // which would surface as a 500 error. When view operations are
unsupported the identifier
- // cannot be a view, so we skip the check entirely.
- if (catalogWrapper.supportsViewOperations() &&
catalogWrapper.viewExists(tableIdentifier)) {
- throw new NoSuchTableException("Table %s not found", tableName);
- }
-
- nameIdentifierMap.put(
- EntityType.TABLE, NameIdentifierUtil.ofTable(metalakeName, catalog,
schema, tableName));
- performTableAuthorization(nameIdentifierMap);
+ NameIdentifier tableId =
+ NameIdentifierUtil.ofTable(
+ loadContext.metalakeName(), loadContext.catalog(),
loadContext.schema(), tableName);
+ nameIdentifierMap.put(EntityType.TABLE, tableId);
+ performTableAuthorization(nameIdentifierMap, catalogWrapper,
tableIdentifier, tableId);
}
@Override
@@ -135,27 +105,36 @@ public class LoadTableAuthzHandler implements
AuthorizationHandler {
}
/**
- * Perform TABLE-level authorization check using
ICEBERG_LOAD_TABLE_AUTHORIZATION_EXPRESSION. This
- * enforces table-specific privileges including ANY_CREATE_TABLE for Iceberg
REST.
+ * Perform TABLE-level authorization aligned with {@link
+ *
org.apache.gravitino.iceberg.service.rest.IcebergTableOperations#loadTable}:
evaluate the
+ * primary expression first; if it denies, evaluate {@link
+ * AuthorizationExpression#allowCheckExistence()} when non-blank. Before
primary expression
+ * evaluation, if the catalog supports views and the identifier resolves to
a view, throws {@link
+ * NoSuchTableException} so clients can fall back to {@code /views/} (see
Iceberg REST spec for
+ * {@code /tables/}).
*/
- private void performTableAuthorization(Map<EntityType, NameIdentifier>
nameIdentifierMap) {
- AuthorizationExpressionEvaluator evaluator =
- new AuthorizationExpressionEvaluator(
-
AuthorizationExpressionConstants.ICEBERG_LOAD_TABLE_AUTHORIZATION_EXPRESSION);
-
- boolean authorized =
- evaluator.evaluate(
- nameIdentifierMap,
- new HashMap<>(),
- new AuthorizationRequestContext(),
- Optional.empty());
-
- if (!authorized) {
- String currentUser = PrincipalUtils.getCurrentUserName();
- NameIdentifier tableId = nameIdentifierMap.get(EntityType.TABLE);
- throw new ForbiddenException(
- "User '%s' is not authorized to load table '%s'", currentUser,
tableId);
+ private void performTableAuthorization(
+ Map<EntityType, NameIdentifier> nameIdentifierMap,
+ IcebergCatalogWrapper catalogWrapper,
+ TableIdentifier tableIdentifier,
+ NameIdentifier tableId) {
+ if (catalogWrapper.supportsViewOperations() &&
catalogWrapper.viewExists(tableIdentifier)) {
+ throw new NoSuchTableException("Table %s not found",
tableIdentifier.name());
}
+
+ IcebergLoadAuthzHandlerHelper.authorizeLoadEntity(
+ nameIdentifierMap,
+ IcebergLoadAuthzHandlerHelper.resolveExpression(
+ authorizationExpression,
+
AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION),
+ IcebergLoadAuthzHandlerHelper.resolveAllowCheckExistenceExpression(
+ authorizationExpression,
+ AuthorizationExpressionConstants
+ .ICEBERG_TABLE_EXISTS_SECONDARY_AUTHORIZATION_EXPRESSION),
+ () -> catalogWrapper.tableExists(tableIdentifier),
+ () -> new NoSuchTableException("Table %s not found",
tableIdentifier.name()),
+ "table",
+ tableId);
}
/**
@@ -173,11 +152,8 @@ public class LoadTableAuthzHandler implements
AuthorizationHandler {
return false;
}
- // Metadata tables have namespace length > 1 (e.g., catalog.db.table has 3
levels)
- // Regular tables have namespace length = 1 (e.g., catalog.db has 2
levels, but we get "db")
- if (namespace.levels().length > 1) {
- return true;
- }
- return false;
+ // Metadata tables have namespace length > 1 (e.g., catalog.db.table has 3
levels).
+ // Regular tables have namespace length = 1 (e.g., catalog.db has 2
levels, but we get "db").
+ return namespace.levels().length > 1;
}
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
new file mode 100644
index 0000000000..304046e270
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java
@@ -0,0 +1,126 @@
+/*
+ * 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.server.web.filter;
+
+import java.lang.reflect.Parameter;
+import java.util.Map;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata.RequestType;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
+import
org.apache.gravitino.server.web.filter.IcebergLoadAuthzHandlerHelper.LoadContext;
+import
org.apache.gravitino.server.web.filter.IcebergLoadAuthzHandlerHelper.LoadTarget;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchViewException;
+
+/**
+ * Handler for LOAD_VIEW operations. Validates the view identifier and
performs authorization
+ * aligned with {@link
org.apache.gravitino.iceberg.service.rest.IcebergViewOperations#loadView}.
+ *
+ * <p>Authorization follows the REST method: primary {@code ICEBERG_LOAD_VIEW}
from {@link
+ * AuthorizationExpression#expression()}, then non-blank {@link
+ * AuthorizationExpression#allowCheckExistence()} when the primary denies.
+ */
+public class LoadViewAuthzHandler implements AuthorizationHandler {
+ private final AuthorizationExpression authorizationExpression;
+ private final Parameter[] parameters;
+ private final Object[] args;
+
+ public LoadViewAuthzHandler(
+ AuthorizationExpression authorizationExpression, Parameter[] parameters,
Object[] args) {
+ this.authorizationExpression = authorizationExpression;
+ this.parameters = parameters;
+ this.args = args;
+ }
+
+ @Override
+ public void process(Map<EntityType, NameIdentifier> nameIdentifierMap) {
+ LoadTarget loadTarget =
+ IcebergLoadAuthzHandlerHelper.extractLoadTarget(parameters, args,
RequestType.LOAD_VIEW);
+
+ if (loadTarget.hasMissingPart()) {
+ throw new NoSuchViewException("View not found - missing view name or
namespace");
+ }
+
+ String viewName = loadTarget.name();
+ Namespace namespace = loadTarget.namespace();
+ LoadContext loadContext =
+ IcebergLoadAuthzHandlerHelper.extractLoadContext(
+ nameIdentifierMap,
+ () ->
+ new NoSuchViewException(
+ "Missing catalog or schema context for view
authorization"));
+
+ IcebergCatalogWrapper catalogWrapper =
+ IcebergLoadAuthzHandlerHelper.getCatalogWrapper(loadContext.catalog());
+ TableIdentifier viewIdentifier = TableIdentifier.of(namespace, viewName);
+
+ NameIdentifier viewId =
+ NameIdentifierUtil.ofView(
+ loadContext.metalakeName(), loadContext.catalog(),
loadContext.schema(), viewName);
+ nameIdentifierMap.put(EntityType.VIEW, viewId);
+ // Spark probes loadView(name) before falling back to loadTable(name), so
the existence-check
+ // expression grants the existence probe to principals holding privileges
on a like-named
+ // table. Register a TABLE identifier under the same name so that
expression has a TABLE entity
+ // to evaluate against; this is what lets a table owner get 404 (not 403)
for an absent view.
+ nameIdentifierMap.put(
+ EntityType.TABLE,
+ NameIdentifierUtil.ofTable(
+ loadContext.metalakeName(), loadContext.catalog(),
loadContext.schema(), viewName));
+
+ performViewAuthorization(nameIdentifierMap, catalogWrapper,
viewIdentifier, viewId);
+ }
+
+ @Override
+ public boolean authorizationCompleted() {
+ return true;
+ }
+
+ /**
+ * Perform VIEW-level authorization aligned with {@link
+ *
org.apache.gravitino.iceberg.service.rest.IcebergViewOperations#loadView}:
evaluate the primary
+ * expression first; if it denies, evaluate {@link
AuthorizationExpression#allowCheckExistence()}
+ * when non-blank.
+ */
+ private void performViewAuthorization(
+ Map<EntityType, NameIdentifier> nameIdentifierMap,
+ IcebergCatalogWrapper catalogWrapper,
+ TableIdentifier viewIdentifier,
+ NameIdentifier viewId) {
+ IcebergLoadAuthzHandlerHelper.authorizeLoadEntity(
+ nameIdentifierMap,
+ IcebergLoadAuthzHandlerHelper.resolveExpression(
+ authorizationExpression,
+
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION),
+ IcebergLoadAuthzHandlerHelper.resolveAllowCheckExistenceExpression(
+ authorizationExpression,
+ AuthorizationExpressionConstants
+ .ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION),
+ () -> catalogWrapper.viewExists(viewIdentifier),
+ () -> new NoSuchViewException("View %s not found",
viewIdentifier.name()),
+ "view",
+ viewId);
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergTableAuthorizationIT.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergTableAuthorizationIT.java
index cea3f1acce..0d19211fc7 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergTableAuthorizationIT.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergTableAuthorizationIT.java
@@ -282,6 +282,15 @@ public class IcebergTableAuthorizationIT extends
IcebergAuthorizationIT {
ForbiddenException.class, () -> sql("DESC TABLE %s_not_exist",
tableName));
}
+ @Test
+ void testSelectTableAsTableOwnerWithUseCatalogAndUseSchema() {
+ String tableName = "test_select_table_owner_with_use";
+ createTable(SCHEMA_NAME, tableName);
+ setTableOwner(tableName);
+
+ Assertions.assertDoesNotThrow(() -> sql("SELECT * FROM %s", tableName));
+ }
+
@Test
void testDropTable() {
String tableName = "test_drop";
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/TestIcebergViewAuthorizationExpression.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/TestIcebergViewAuthorizationExpression.java
index b341fbc4fa..44bd419f4e 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/TestIcebergViewAuthorizationExpression.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/TestIcebergViewAuthorizationExpression.java
@@ -19,16 +19,20 @@
package org.apache.gravitino.iceberg.service.rest.authorization;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.common.collect.ImmutableSet;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
import ognl.OgnlException;
import org.apache.gravitino.iceberg.service.rest.IcebergViewOperations;
import org.apache.gravitino.iceberg.service.rest.IcebergViewRenameOperations;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.iceberg.rest.requests.CreateViewRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
@@ -181,11 +185,68 @@ public class TestIcebergViewAuthorizationExpression {
"METALAKE::USE_SCHEMA",
"METALAKE::USE_CATALOG")));
- // CREATE_VIEW grants load permission to allow catalog implementations
that call viewExists()
- // during creation to work
+ // CREATE_VIEW alone does NOT grant the primary load-view permission;
existence checks for
+ // view creation are handled by the allowCheckExistence expression -- see
+ // {@link
+ //
AuthorizationExpressionConstants#ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION}
+ // and {@link
#testIcebergLoadViewAllowCheckExistenceAuthorizationExpression()} below.
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testIcebergLoadViewAllowCheckExistenceAuthorizationExpression()
throws OgnlException {
+ // Existence-check expression used by LoadViewAuthzHandler when the
primary load-view expression
+ // denies: should allow schema-level principals with create-view or
table-access privileges to
+ // resolve a view identifier (existence check) even without full load-view
privileges.
+ String expression =
+
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION;
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied.
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // CREATE_VIEW + USE_SCHEMA + USE_CATALOG -> allowed: lets catalog
implementations that call
+ // viewExists() during creation succeed.
assertTrue(
mockEvaluator.getResult(
ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // Table-side grants also pass the secondary check (used for resolving
view backing tables).
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_TABLE", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_TABLE", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // Missing USE_SCHEMA -> denied.
+ assertFalse(
+ mockEvaluator.getResult(ImmutableSet.of("SCHEMA::CREATE_VIEW",
"CATALOG::USE_CATALOG")));
+ // Missing USE_CATALOG -> denied.
+ assertFalse(
+ mockEvaluator.getResult(ImmutableSet.of("SCHEMA::CREATE_VIEW",
"SCHEMA::USE_SCHEMA")));
+ }
+
+ @Test
+ public void testLoadViewIcebergAuthorizationMetadata() throws
NoSuchMethodException {
+ Method method =
+ IcebergViewOperations.class.getMethod("loadView", String.class,
String.class, String.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ assertEquals(
+
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+ annotation.expression());
+ assertEquals(
+
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION,
+ annotation.allowCheckExistence());
+
+ Parameter viewParameter = method.getParameters()[2];
+ IcebergAuthorizationMetadata icebergMetadata =
+ viewParameter.getAnnotation(IcebergAuthorizationMetadata.class);
+ assertNotNull(icebergMetadata);
+ assertEquals(IcebergAuthorizationMetadata.RequestType.LOAD_VIEW,
icebergMetadata.type());
}
@Test
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergLoadAuthzHandlerHelper.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergLoadAuthzHandlerHelper.java
new file mode 100644
index 0000000000..f675986510
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergLoadAuthzHandlerHelper.java
@@ -0,0 +1,196 @@
+/*
+ * 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.server.web.filter;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.BooleanSupplier;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.web.filter.IcebergLoadAuthzHandlerHelper.LoadTarget;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+/** Test for {@link IcebergLoadAuthzHandlerHelper}. */
+public class TestIcebergLoadAuthzHandlerHelper {
+
+ private static final String METALAKE = "test_metalake";
+ private static final String CATALOG = "test_catalog";
+ private static final String SCHEMA = "test_schema";
+ private static final String NAME = "probe_target";
+
+ // Models the loadView probe Spark issues before falling back to loadTable:
the primary
+ // expression requires view ownership, while allowCheckExistence lets a
table owner probe. The
+ // three branches of authorizeLoadEntity decide whether such a caller sees
the entity, a 404, or
+ // a 403.
+ private static final String PRIMARY_EXPRESSION = "VIEW::OWNER";
+ private static final String ALLOW_CHECK_EXISTENCE_EXPRESSION =
"TABLE::OWNER";
+
+ @Test
+ public void testPrimaryGrantAllows() {
+ // View owner: primary grants regardless of existence, so the load is
authorized.
+ assertDoesNotThrow(
+ () -> runAuthorizeLoadEntity(true, false, false),
+ "view owner must be authorized even when the existence check would
deny");
+ }
+
+ @Test
+ public void testAbsentProbeNotFound() {
+ // Table owner probing a view that does not exist: primary denies,
existence check allows, and
+ // the view is absent, so the caller gets 404 instead of 403 (the bug this
PR fixes, which lets
+ // Spark fall back to loadTable).
+ assertThrows(NoSuchViewException.class, () ->
runAuthorizeLoadEntity(false, true, false));
+ }
+
+ @Test
+ public void testPresentProbeForbidden() {
+ // Table owner probing a view that actually exists: the existence check
must not leak access to
+ // a real view the caller has no privilege on, so it stays 403.
+ assertThrows(ForbiddenException.class, () -> runAuthorizeLoadEntity(false,
true, true));
+ }
+
+ @Test
+ public void testNoAccessForbidden() {
+ // Caller satisfies neither expression: denied with 403 without probing
existence.
+ assertThrows(ForbiddenException.class, () -> runAuthorizeLoadEntity(false,
false, false));
+ }
+
+ private void runAuthorizeLoadEntity(
+ boolean primaryGranted, boolean existenceCheckGranted, boolean exists) {
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> providerStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ principalUtils
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.isOwner(
+ any(),
+ any(),
+ argThat(object -> object != null && object.type() ==
MetadataObject.Type.VIEW),
+ any()))
+ .thenReturn(primaryGranted);
+ when(authorizer.isOwner(
+ any(),
+ any(),
+ argThat(object -> object != null && object.type() ==
MetadataObject.Type.TABLE),
+ any()))
+ .thenReturn(existenceCheckGranted);
+
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
providerStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+
+ Map<Entity.EntityType, NameIdentifier> nameIdentifierMap = new
HashMap<>();
+ nameIdentifierMap.put(Entity.EntityType.METALAKE,
NameIdentifierUtil.ofMetalake(METALAKE));
+ nameIdentifierMap.put(
+ Entity.EntityType.VIEW, NameIdentifierUtil.ofView(METALAKE, CATALOG,
SCHEMA, NAME));
+ nameIdentifierMap.put(
+ Entity.EntityType.TABLE, NameIdentifierUtil.ofTable(METALAKE,
CATALOG, SCHEMA, NAME));
+
+ BooleanSupplier existsSupplier = () -> exists;
+ IcebergLoadAuthzHandlerHelper.authorizeLoadEntity(
+ nameIdentifierMap,
+ PRIMARY_EXPRESSION,
+ ALLOW_CHECK_EXISTENCE_EXPRESSION,
+ existsSupplier,
+ () -> new NoSuchViewException("View %s not found", NAME),
+ "view",
+ NameIdentifierUtil.ofView(METALAKE, CATALOG, SCHEMA, NAME));
+ }
+ }
+
+ @Test
+ public void testExtractLoadTarget() throws Exception {
+ Method method =
+ TestOperations.class.getMethod("loadTable", String.class,
String.class, String.class);
+
+ LoadTarget loadTarget =
+ IcebergLoadAuthzHandlerHelper.extractLoadTarget(
+ method.getParameters(),
+ new Object[] {"test_catalog/", "test_schema", "orders%20table"},
+ IcebergAuthorizationMetadata.RequestType.LOAD_TABLE);
+
+ assertEquals("orders table", loadTarget.name());
+ assertEquals(Namespace.of("test_schema"), loadTarget.namespace());
+ }
+
+ @Test
+ public void testResolveExpression() throws Exception {
+ Method method =
+ TestOperations.class.getMethod("loadTable", String.class,
String.class, String.class);
+ AuthorizationExpression authorizationExpression =
+ method.getAnnotation(AuthorizationExpression.class);
+
+ assertEquals(
+ "custom_primary",
+ IcebergLoadAuthzHandlerHelper.resolveExpression(
+ authorizationExpression, "default_primary"));
+ assertEquals(
+ "custom_existence",
+ IcebergLoadAuthzHandlerHelper.resolveAllowCheckExistenceExpression(
+ authorizationExpression, "default_existence"));
+ assertEquals(
+ "default_primary",
+ IcebergLoadAuthzHandlerHelper.resolveExpression(null,
"default_primary"));
+ assertEquals(
+ "default_existence",
+ IcebergLoadAuthzHandlerHelper.resolveAllowCheckExistenceExpression(
+ null, "default_existence"));
+ }
+
+ @SuppressWarnings("unused")
+ public static class TestOperations {
+ @AuthorizationExpression(
+ expression = "custom_primary",
+ allowCheckExistence = "custom_existence",
+ accessMetadataType = MetadataObject.Type.TABLE)
+ public void loadTable(
+ @AuthorizationMetadata(type = Entity.EntityType.CATALOG) String prefix,
+ @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) String
namespace,
+ @IcebergAuthorizationMetadata(type =
IcebergAuthorizationMetadata.RequestType.LOAD_TABLE)
+ @AuthorizationMetadata(type = Entity.EntityType.TABLE)
+ String table) {}
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index fa675cad4b..b33b85bb8e 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -268,7 +268,7 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
new IcebergMetadataAuthorizationMethodInterceptor() {
@Override
protected Optional<AuthorizationHandler> createAuthorizationHandler(
- Parameter[] parameters, Object[] args) {
+ Method method, Parameter[] parameters, Object[] args) {
return Optional.of(
new AuthorizationHandler() {
@Override
@@ -316,7 +316,7 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
new IcebergMetadataAuthorizationMethodInterceptor() {
@Override
protected Optional<AuthorizationHandler> createAuthorizationHandler(
- Parameter[] parameters, Object[] args) {
+ Method method, Parameter[] parameters, Object[] args) {
return Optional.of(
new AuthorizationHandler() {
@Override
@@ -394,7 +394,7 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
new IcebergMetadataAuthorizationMethodInterceptor() {
@Override
protected Optional<AuthorizationHandler> createAuthorizationHandler(
- Parameter[] parameters, Object[] args) {
+ Method method, Parameter[] parameters, Object[] args) {
throw new RuntimeException("test");
}
};
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadViewAuthzHandler.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadViewAuthzHandler.java
new file mode 100644
index 0000000000..94b5e87369
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestLoadViewAuthzHandler.java
@@ -0,0 +1,114 @@
+/*
+ * 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.server.web.filter;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.gravitino.server.ServerConfig;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+/** Test for {@link LoadViewAuthzHandler}. */
+public class TestLoadViewAuthzHandler {
+
+ private static final String TEST_METALAKE = "test_metalake";
+ private static final String TEST_CATALOG = "test_catalog";
+ private static final String TEST_SCHEMA = "test_schema";
+
+ @BeforeAll
+ public static void initAuthorizer() {
+ GravitinoAuthorizerProvider.getInstance().initialize(new ServerConfig());
+ }
+
+ @Test
+ public void testLoadViewDoesNotRejectMetadataTableLikeName() throws
Exception {
+ IcebergCatalogWrapperManager wrapperManager =
Mockito.mock(IcebergCatalogWrapperManager.class);
+ CatalogWrapperForREST catalogWrapper =
Mockito.mock(CatalogWrapperForREST.class);
+
when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(catalogWrapper);
+ resetContext(wrapperManager);
+
+ Method method =
+ TestOperations.class.getMethod("loadView", String.class, String.class,
String.class);
+ LoadViewAuthzHandler handler =
+ new LoadViewAuthzHandler(
+ method.getAnnotation(AuthorizationExpression.class),
+ method.getParameters(),
+ new Object[] {TEST_CATALOG + "/", "nested.table", "files"});
+
+ Map<Entity.EntityType, NameIdentifier> nameIdentifierMap = new HashMap<>();
+ nameIdentifierMap.put(Entity.EntityType.METALAKE,
NameIdentifierUtil.ofMetalake(TEST_METALAKE));
+ nameIdentifierMap.put(
+ Entity.EntityType.CATALOG, NameIdentifierUtil.ofCatalog(TEST_METALAKE,
TEST_CATALOG));
+ nameIdentifierMap.put(
+ Entity.EntityType.SCHEMA,
+ NameIdentifierUtil.ofSchema(TEST_METALAKE, TEST_CATALOG, TEST_SCHEMA));
+
+ assertDoesNotThrow(() -> handler.process(nameIdentifierMap));
+ verify(catalogWrapper, never()).viewExists(any(TableIdentifier.class));
+ }
+
+ private static void resetContext(IcebergCatalogWrapperManager
wrapperManager) {
+ IcebergConfigProvider configProvider =
Mockito.mock(IcebergConfigProvider.class);
+ when(configProvider.getMetalakeName()).thenReturn(TEST_METALAKE);
+ when(configProvider.getDefaultCatalogName()).thenReturn(TEST_CATALOG);
+ IcebergRESTServerContext.create(configProvider, false, false, true,
wrapperManager);
+ }
+
+ /** Test operations class to provide method annotations for testing. */
+ @SuppressWarnings("unused")
+ public static class TestOperations {
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+ allowCheckExistence =
+ AuthorizationExpressionConstants
+ .ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.VIEW)
+ public void loadView(
+ @AuthorizationMetadata(type = Entity.EntityType.CATALOG) String prefix,
+ @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) String
namespace,
+ @IcebergAuthorizationMetadata(type =
IcebergAuthorizationMetadata.RequestType.LOAD_VIEW)
+ @AuthorizationMetadata(type = Entity.EntityType.VIEW)
+ String view) {
+ // Test method
+ }
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
index 2e772b4ce3..05a6b32bf4 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java
@@ -64,5 +64,16 @@ public @interface AuthorizationExpression {
*
* @return the condition for evaluating the secondary expression.
*/
- String secondaryExpressionCondition() default "";
+ ExpressionCondition secondaryExpressionCondition() default
ExpressionCondition.NEVER;
+
+ /**
+ * Optional expression evaluated after the primary {@link #expression()}
denies authorization, to
+ * decide whether the caller may probe object existence (for example Iceberg
REST load probes that
+ * distinguish 403 from 404). This is separate from {@link
#secondaryExpression()} so load paths
+ * do not overload secondary for unrelated semantics (such as MODIFY_TABLE
privilege switching on
+ * the Gravitino core server).
+ *
+ * @return expression that permits existence checks when primary denies
+ */
+ String allowCheckExistence() default "";
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
new file mode 100644
index 0000000000..e811871217
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/ExpressionCondition.java
@@ -0,0 +1,24 @@
+/*
+ * 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.server.authorization.annotations;
+
+public enum ExpressionCondition {
+ NEVER,
+ REQUIRED_MODIFY_PRIVILEGES
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
index bdb13b8ba8..122bdfcd01 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConstants.java
@@ -48,6 +48,26 @@ public class AuthorizationExpressionConstants {
ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE || ANY_CREATE_TABLE)
""";
+ /**
+ * Existence-check expression for Iceberg REST {@code loadTable}: used when
the primary load-table
+ * expression is forbidden, to allow schema-level principals who can
create/select views to
+ * resolve a table identifier (e.g. view backing table) without full table
privileges.
+ */
+ public static final String
ICEBERG_TABLE_EXISTS_SECONDARY_AUTHORIZATION_EXPRESSION =
+ """
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && (ANY_CREATE_TABLE ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW || VIEW::OWNER)
+ """;
+
+ /** Iceberg REST {@code HEAD .../tables/{table}} (table exists). */
+ public static final String ICEBERG_TABLE_EXISTS_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG) ||
+ SCHEMA_OWNER_WITH_USE_CATALOG ||
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
VIEW::OWNER ||
+ ANY_CREATE_VIEW || ANY_SELECT_VIEW || ANY_SELECT_TABLE ||
+ ANY_MODIFY_TABLE || ANY_CREATE_TABLE)
+ """;
+
public static final String MODIFY_TABLE_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG) ||
@@ -97,16 +117,46 @@ public class AuthorizationExpressionConstants {
ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW)
""";
- // Adding ANY_SELECT_TABLE / ANY_MODIFY_TABLE / ANY_CREATE_TABLE here
because Spark probes
- // viewExists(tableName) when resolving any relation. Without table
privileges in the
- // expression, users who only hold table grants get a spurious 403 on the
HEAD /views/{name}
- // probe, blocking legitimate table reads.
public static final String ICEBERG_LOAD_VIEW_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG) ||
SCHEMA_OWNER_WITH_USE_CATALOG ||
- ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW
- || ANY_SELECT_TABLE || ANY_MODIFY_TABLE ||
ANY_CREATE_TABLE)
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW)
+ """;
+
+ /**
+ * Existence-check expression for Iceberg REST {@code loadView}: when the
primary load-view
+ * expression is forbidden, allows schema-level principals with view or
referenced-table paths
+ * (similar shape to {@link
#ICEBERG_TABLE_EXISTS_SECONDARY_AUTHORIZATION_EXPRESSION}, tuned for
+ * view metadata and backing relation resolution).
+ */
+ public static final String
ICEBERG_LOAD_VIEW_EXISTENCE_CHECK_AUTHORIZATION_EXPRESSION =
+ """
+ ANY_USE_CATALOG && ANY_USE_SCHEMA &&
+ (ANY_CREATE_VIEW || TABLE::OWNER || ANY_SELECT_TABLE ||
ANY_MODIFY_TABLE || ANY_CREATE_TABLE)
+ """;
+
+ public static final String ICEBERG_CREATE_VIEW_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG) ||
+ SCHEMA_OWNER_WITH_USE_CATALOG ||
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && ANY_CREATE_VIEW
+ """;
+
+ /** Iceberg REST replace view, drop view, and rename view (VIEW::OWNER
path). */
+ public static final String ICEBERG_VIEW_OWNER_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG) ||
+ SCHEMA_OWNER_WITH_USE_CATALOG ||
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && VIEW::OWNER
+ """;
+
+ /** Iceberg REST {@code HEAD .../views/{view}} (view exists). */
+ public static final String ICEBERG_VIEW_EXISTS_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG) ||
+ SCHEMA_OWNER_WITH_USE_CATALOG ||
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW)
""";
public static final String FILTER_TABLE_AUTHORIZATION_EXPRESSION =
@@ -186,7 +236,4 @@ public class AuthorizationExpressionConstants {
public static final String LOAD_JOB_TEMPLATE_AUTHORIZATION_EXPRESSION =
"METALAKE::OWNER || JOB_TEMPLATE::OWNER || ANY_USE_JOB_TEMPLATE";
-
- public static final String REQUEST_REQUIRED_PRIVILEGES_CONTAINS_MODIFY_TABLE
=
- "REQUEST::REQUIRED_PRIVILEGES_CONTAINS_MODIFY_TABLE";
}
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 0f542bdeb9..0f229d85a4 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -46,6 +46,7 @@ import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
import org.apache.gravitino.server.web.Utils;
import
org.apache.gravitino.server.web.filter.authorization.AuthorizationExecutor;
import
org.apache.gravitino.server.web.filter.authorization.AuthorizeExecutorFactory;
@@ -190,7 +191,7 @@ public class GravitinoInterceptionService implements
InterceptionService {
AuthorizationRequest.RequestType requestType =
extractAuthorizationRequestTypeFromParameters(parameters);
String secondaryExpression =
expressionAnnotation.secondaryExpression();
- String secondaryExpressionCondition =
+ ExpressionCondition secondaryExpressionCondition =
expressionAnnotation.secondaryExpressionCondition();
executor =
AuthorizeExecutorFactory.create(
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
index 5badd704d4..4d2f4c63aa 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
@@ -23,6 +23,7 @@ import java.util.Optional;
import org.apache.gravitino.Entity;
import org.apache.gravitino.NameIdentifier;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
public class AuthorizeExecutorFactory {
@@ -35,7 +36,7 @@ public class AuthorizeExecutorFactory {
Parameter[] parameters,
Object[] args,
String secondaryExpression,
- String secondaryExpressionCondition) {
+ ExpressionCondition secondaryExpressionCondition) {
return switch (requestType) {
case COMMON -> new CommonAuthorizerExecutor(
expression, metadataContext, pathParams, entityType);
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
index caa6a7b312..5b87979518 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LoadTableAuthorizationExecutor.java
@@ -20,13 +20,11 @@ import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Entity;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.authorization.Privilege;
-import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
import org.apache.gravitino.server.web.filter.ParameterUtil;
@@ -52,30 +50,36 @@ public class LoadTableAuthorizationExecutor extends
CommonAuthorizerExecutor {
Map<String, Object> pathParams,
Optional<String> entityType,
String secondaryExpression,
- String secondaryExpressionCondition) {
+ ExpressionCondition secondaryExpressionCondition) {
super(expression, metadataContext, pathParams, entityType);
- // If secondaryExpression and condition are provided, evaluate the
condition
- if (StringUtils.isNotBlank(secondaryExpression)
- && StringUtils.isNotBlank(secondaryExpressionCondition)) {
- String privileges = (String)
ParameterUtil.extractFromParameters(parameters, args);
+ if (!shouldCheckModifyTablePrivilege(secondaryExpression,
secondaryExpressionCondition)) {
+ return;
+ }
+
+ String privileges = (String)
ParameterUtil.extractFromParameters(parameters, args);
+ if (containsModifyTablePrivilege(privileges)) {
+ this.expression = secondaryExpression;
+ this.authorizationExpressionEvaluator =
+ new AuthorizationExpressionEvaluator(secondaryExpression);
+ }
+ }
- // Evaluate the condition: does the request contain MODIFY_TABLE
privilege?
- if (privileges != null
- && secondaryExpressionCondition.equals(
-
AuthorizationExpressionConstants.REQUEST_REQUIRED_PRIVILEGES_CONTAINS_MODIFY_TABLE))
{
- Set<Privilege.Name> privilegeNames =
- Arrays.stream(privileges.split(","))
- .map(Privilege.Name::valueOf)
- .collect(Collectors.toSet());
+ private static boolean shouldCheckModifyTablePrivilege(
+ String secondaryExpression, ExpressionCondition
secondaryExpressionCondition) {
+ return StringUtils.isNotBlank(secondaryExpression)
+ && secondaryExpressionCondition ==
ExpressionCondition.REQUIRED_MODIFY_PRIVILEGES;
+ }
- if (privilegeNames.contains(Privilege.Name.MODIFY_TABLE)) {
- // Use the secondary expression for stricter authorization
- this.expression = secondaryExpression;
- this.authorizationExpressionEvaluator =
- new AuthorizationExpressionEvaluator(secondaryExpression);
- }
- }
+ private static boolean containsModifyTablePrivilege(String privileges) {
+ if (StringUtils.isBlank(privileges)) {
+ return false;
}
+
+ return Arrays.stream(privileges.split(","))
+ .map(String::trim)
+ .filter(StringUtils::isNotBlank)
+ .map(Privilege.Name::valueOf)
+ .anyMatch(Privilege.Name.MODIFY_TABLE::equals);
}
}
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java
index 0940abb458..fa89ee0259 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/TableOperations.java
@@ -55,6 +55,7 @@ import
org.apache.gravitino.server.authorization.MetadataAuthzHelper;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.server.web.Utils;
import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -167,8 +168,7 @@ public class TableOperations {
@AuthorizationExpression(
expression =
AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION,
secondaryExpression =
AuthorizationExpressionConstants.MODIFY_TABLE_AUTHORIZATION_EXPRESSION,
- secondaryExpressionCondition =
-
AuthorizationExpressionConstants.REQUEST_REQUIRED_PRIVILEGES_CONTAINS_MODIFY_TABLE,
+ secondaryExpressionCondition =
ExpressionCondition.REQUIRED_MODIFY_PRIVILEGES,
accessMetadataType = MetadataObject.Type.TABLE)
public Response loadTable(
@PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
new file mode 100644
index 0000000000..fcd0f273e7
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLoadTableAuthorizationExecutor.java
@@ -0,0 +1,92 @@
+/*
+ * 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.server.web.filter.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Optional;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.junit.jupiter.api.Test;
+
+public class TestLoadTableAuthorizationExecutor {
+ private static final String PRIMARY_EXPRESSION =
+ AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION;
+ private static final String SECONDARY_EXPRESSION =
+ AuthorizationExpressionConstants.MODIFY_TABLE_AUTHORIZATION_EXPRESSION;
+
+ @Test
+ public void testUsesSecondaryExpressionForRequiredModifyPrivilege() throws
Exception {
+ LoadTableAuthorizationExecutor executor =
+ createExecutor(
+ "SELECT_TABLE, MODIFY_TABLE",
ExpressionCondition.REQUIRED_MODIFY_PRIVILEGES);
+
+ assertEquals(SECONDARY_EXPRESSION, expression(executor));
+ }
+
+ @Test
+ public void testUsesPrimaryExpressionWhenConditionNever() throws Exception {
+ LoadTableAuthorizationExecutor executor =
+ createExecutor("MODIFY_TABLE", ExpressionCondition.NEVER);
+
+ assertEquals(PRIMARY_EXPRESSION, expression(executor));
+ }
+
+ @Test
+ public void testUsesPrimaryExpressionWithoutModifyPrivilege() throws
Exception {
+ LoadTableAuthorizationExecutor executor =
+ createExecutor("SELECT_TABLE",
ExpressionCondition.REQUIRED_MODIFY_PRIVILEGES);
+
+ assertEquals(PRIMARY_EXPRESSION, expression(executor));
+ }
+
+ private static LoadTableAuthorizationExecutor createExecutor(
+ String privileges, ExpressionCondition condition) throws Exception {
+ Method method = TestOperations.class.getMethod("loadTable", String.class);
+ return new LoadTableAuthorizationExecutor(
+ method.getParameters(),
+ new Object[] {privileges},
+ PRIMARY_EXPRESSION,
+ Collections.<Entity.EntityType, NameIdentifier>emptyMap(),
+ Collections.emptyMap(),
+ Optional.empty(),
+ SECONDARY_EXPRESSION,
+ condition);
+ }
+
+ private static String expression(LoadTableAuthorizationExecutor executor)
throws Exception {
+ Field expressionField =
CommonAuthorizerExecutor.class.getDeclaredField("expression");
+ expressionField.setAccessible(true);
+ return (String) expressionField.get(executor);
+ }
+
+ @SuppressWarnings("unused")
+ public static class TestOperations {
+ public void loadTable(
+ @AuthorizationRequest(type =
AuthorizationRequest.RequestType.LOAD_TABLE)
+ String privileges) {}
+ }
+}