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 fc39c77172 [#9747] feat(IRC): Add authorization for Iceberg views
(#9988)
fc39c77172 is described below
commit fc39c771722140752539878e1748da65caa2ac1b
Author: Bharath Krishna <[email protected]>
AuthorDate: Sat Feb 21 19:55:24 2026 +0530
[#9747] feat(IRC): Add authorization for Iceberg views (#9988)
### What changes were proposed in this pull request?
We are adding authorization for IRC view APIs.
Implementing fine-grained access control for view operations (create,
load, replace, drop, rename, list) with privilege checks similar to
table authorization.
Also, in load table, we have modified the logic to check viewExists,
because spark calls loadTable first while loading a view and if not
exists it falls back to loading view.
### Why are the changes needed?
Add auth for view IRC apis
Fix: #9747
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
Added unit and integration tests
---
.../org/apache/gravitino/iceberg/RESTService.java | 8 +-
.../service/IcebergCatalogWrapperManager.java | 10 +-
.../authorization/IcebergRESTServerContext.java | 18 +-
.../service/rest/IcebergTableOperations.java | 8 +-
.../service/rest/IcebergViewOperations.java | 120 ++++-
.../service/rest/IcebergViewRenameOperations.java | 16 +-
.../annotations/IcebergAuthorizationMetadata.java | 1 +
...bergMetadataAuthorizationMethodInterceptor.java | 8 +
.../server/web/filter/LoadTableAuthzHandler.java | 65 ++-
.../server/web/filter/RenameViewAuthzHandler.java | 182 +++++++
.../test/IcebergTableAuthorizationIT.java | 2 +
.../test/IcebergViewAuthorizationIT.java | 532 +++++++++++++++++++++
.../TestIcebergCatalogWrapperManagerForREST.java | 18 +-
.../iceberg/service/TestIcebergRESTUtils.java | 2 +-
.../dispatcher/TestIcebergTableHookDispatcher.java | 2 +-
.../provider/TestDynamicIcebergConfigProvider.java | 2 +-
.../rest/IcebergCatalogWrapperManagerForTest.java | 7 +-
.../iceberg/service/rest/IcebergRestTestUtil.java | 5 +-
.../MockAuthorizationExpressionEvaluator.java | 75 +++
.../TestIcebergViewAuthorizationExpression.java | 399 ++++++++++++++++
...bergMetadataAuthorizationMethodInterceptor.java | 2 +-
.../AuthorizationExpressionConstants.java | 21 +
.../AuthorizationExpressionConverter.java | 13 +
.../authorization/jcasbin/JcasbinAuthorizer.java | 1 +
24 files changed, 1465 insertions(+), 52 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 47cdb9badd..23f1fcf042 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -98,12 +98,12 @@ public class RESTService implements
GravitinoAuxiliaryService {
String metalakeName = configProvider.getMetalakeName();
Boolean enableAuth =
GravitinoEnv.getInstance().config().get(Configs.ENABLE_AUTHORIZATION);
- IcebergRESTServerContext authorizationContext =
- IcebergRESTServerContext.create(configProvider, enableAuth, auxMode);
-
EventBus eventBus = GravitinoEnv.getInstance().eventBus();
this.icebergCatalogWrapperManager =
- new IcebergCatalogWrapperManager(configProperties, configProvider);
+ new IcebergCatalogWrapperManager(configProperties, configProvider,
auxMode, metalakeName);
+ IcebergRESTServerContext authorizationContext =
+ IcebergRESTServerContext.create(
+ configProvider, enableAuth, auxMode, icebergCatalogWrapperManager);
this.icebergMetricsManager = new IcebergMetricsManager(icebergConfig);
IcebergTableOperationDispatcher icebergTableOperationDispatcher =
new IcebergTableOperationExecutor(icebergCatalogWrapperManager);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
index a8b82622d4..946f24f441 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
@@ -49,7 +49,10 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
private final IcebergConfigProvider configProvider;
public IcebergCatalogWrapperManager(
- Map<String, String> properties, IcebergConfigProvider configProvider) {
+ Map<String, String> properties,
+ IcebergConfigProvider configProvider,
+ boolean auxMode,
+ String metalakeName) {
this.configProvider = configProvider;
this.catalogWrapperCache =
Caffeine.newBuilder()
@@ -72,13 +75,12 @@ public class IcebergCatalogWrapperManager implements
AutoCloseable {
.setNameFormat("iceberg-catalog-wrapper-cleaner-%d")
.build())))
.build();
- IcebergRESTServerContext context = IcebergRESTServerContext.getInstance();
- if (context.isAuxMode()) {
+ if (auxMode) {
GravitinoEnv.getInstance()
.catalogManager()
.addCatalogCacheRemoveListener(
ident -> {
- if (ident.namespace().level(0).equals(context.metalakeName()))
{
+ if (ident.namespace().level(0).equals(metalakeName)) {
catalogWrapperCache.invalidate(ident.name());
}
});
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/authorization/IcebergRESTServerContext.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/authorization/IcebergRESTServerContext.java
index c2a7bd5efe..b61c05a39c 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/authorization/IcebergRESTServerContext.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/authorization/IcebergRESTServerContext.java
@@ -20,6 +20,7 @@
package org.apache.gravitino.iceberg.service.authorization;
import com.google.common.base.Preconditions;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
public class IcebergRESTServerContext {
@@ -27,16 +28,19 @@ public class IcebergRESTServerContext {
private boolean auxMode;
private String metalakeName;
private String defaultCatalogName;
+ private IcebergCatalogWrapperManager catalogWrapperManager;
private IcebergRESTServerContext(
Boolean isAuthorizationEnabled,
Boolean auxMode,
String metalakeName,
- String defaultCatalogName) {
+ String defaultCatalogName,
+ IcebergCatalogWrapperManager catalogWrapperManager) {
this.isAuthorizationEnabled = isAuthorizationEnabled;
this.auxMode = auxMode;
this.metalakeName = metalakeName;
this.defaultCatalogName = defaultCatalogName;
+ this.catalogWrapperManager = catalogWrapperManager;
}
private static class InstanceHolder {
@@ -44,13 +48,17 @@ public class IcebergRESTServerContext {
}
public static IcebergRESTServerContext create(
- IcebergConfigProvider configProvider, Boolean enableAuth, Boolean
auxMode) {
+ IcebergConfigProvider configProvider,
+ Boolean enableAuth,
+ Boolean auxMode,
+ IcebergCatalogWrapperManager catalogWrapperManager) {
InstanceHolder.INSTANCE =
new IcebergRESTServerContext(
enableAuth,
auxMode,
configProvider.getMetalakeName(),
- configProvider.getDefaultCatalogName());
+ configProvider.getDefaultCatalogName(),
+ catalogWrapperManager);
return InstanceHolder.INSTANCE;
}
@@ -74,4 +82,8 @@ public class IcebergRESTServerContext {
public String defaultCatalogName() {
return defaultCatalogName;
}
+
+ public IcebergCatalogWrapperManager catalogWrapperManager() {
+ return catalogWrapperManager;
+ }
}
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 e8af54b985..1bac5d64b6 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
@@ -272,12 +272,10 @@ 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 =
- "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)",
- accessMetadataType = MetadataObject.Type.TABLE)
+ expression =
AuthorizationExpressionConstants.LOAD_SCHEMA_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response loadTable(
@AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
@AuthorizationMetadata(type = EntityType.SCHEMA) @Encoded()
@PathParam("namespace")
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 bcbc77ac38..5a1adc9f53 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
@@ -23,6 +23,8 @@ import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.List;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
@@ -37,12 +39,21 @@ import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergObjectMapper;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
+import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergViewOperationDispatcher;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
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.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.server.web.Utils;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
@@ -76,8 +87,13 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@Timed(name = "list-view." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "list-view", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.LOAD_SCHEMA_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response listView(
- @PathParam("prefix") String prefix, @Encoded() @PathParam("namespace")
String namespace) {
+ @AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
+ @AuthorizationMetadata(type = EntityType.SCHEMA) @Encoded()
@PathParam("namespace")
+ String namespace) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
LOG.info("List Iceberg views, catalog: {}, namespace: {}", catalogName,
icebergNS);
@@ -89,6 +105,13 @@ public class IcebergViewOperations {
new IcebergRequestContext(httpServletRequest(), catalogName);
ListTablesResponse listTablesResponse =
viewOperationDispatcher.listView(context, icebergNS);
+
+ IcebergRESTServerContext authContext =
IcebergRESTServerContext.getInstance();
+ if (authContext.isAuthorizationEnabled()) {
+ listTablesResponse =
+ filterListViewsResponse(
+ listTablesResponse, authContext.metalakeName(),
catalogName);
+ }
return IcebergRESTUtils.ok(listTablesResponse);
});
} catch (Exception e) {
@@ -100,9 +123,16 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@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",
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response createView(
- @PathParam("prefix") String prefix,
- @Encoded() @PathParam("namespace") String namespace,
+ @AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
+ @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) @Encoded()
@PathParam("namespace")
+ String namespace,
CreateViewRequest createViewRequest) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
@@ -131,10 +161,17 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@Timed(name = "load-view." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "load-view", absolute = true)
+ @AuthorizationExpression(
+ expression =
+ "ANY(OWNER, METALAKE, CATALOG) || "
+ + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW)",
+ accessMetadataType = MetadataObject.Type.VIEW)
public Response loadView(
- @PathParam("prefix") String prefix,
- @Encoded() @PathParam("namespace") String namespace,
- @Encoded() @PathParam("view") String view) {
+ @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) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
String viewName = RESTUtil.decodeString(view);
@@ -164,10 +201,17 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@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",
+ accessMetadataType = MetadataObject.Type.VIEW)
public Response replaceView(
- @PathParam("prefix") String prefix,
- @Encoded() @PathParam("namespace") String namespace,
- @Encoded() @PathParam("view") String view,
+ @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,
UpdateTableRequest replaceViewRequest) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
@@ -199,10 +243,17 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@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",
+ accessMetadataType = MetadataObject.Type.VIEW)
public Response dropView(
- @PathParam("prefix") String prefix,
- @Encoded() @PathParam("namespace") String namespace,
- @Encoded() @PathParam("view") String view) {
+ @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) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
String viewName = RESTUtil.decodeString(view);
@@ -231,10 +282,17 @@ public class IcebergViewOperations {
@Produces(MediaType.APPLICATION_JSON)
@Timed(name = "view-exists." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "view-exists", absolute = true)
+ @AuthorizationExpression(
+ expression =
+ "ANY(OWNER, METALAKE, CATALOG) || "
+ + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (VIEW::OWNER ||
ANY_SELECT_VIEW || ANY_CREATE_VIEW)",
+ accessMetadataType = MetadataObject.Type.VIEW)
public Response viewExists(
- @PathParam("prefix") String prefix,
- @Encoded() @PathParam("namespace") String namespace,
- @Encoded() @PathParam("view") String view) {
+ @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) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
Namespace icebergNS = RESTUtil.decodeNamespace(namespace);
String viewName = RESTUtil.decodeString(view);
@@ -276,4 +334,36 @@ public class IcebergViewOperations {
return replaceViewRequest.toString();
}
}
+
+ private NameIdentifier[] toViewNameIdentifiers(
+ ListTablesResponse listTablesResponse, String metalake, String
catalogName) {
+ List<TableIdentifier> identifiers = listTablesResponse.identifiers();
+ NameIdentifier[] nameIdentifiers = new NameIdentifier[identifiers.size()];
+ for (int i = 0; i < identifiers.size(); i++) {
+ TableIdentifier identifier = identifiers.get(i);
+ nameIdentifiers[i] =
+ NameIdentifier.of(
+ metalake, catalogName, identifier.namespace().level(0),
identifier.name());
+ }
+ return nameIdentifiers;
+ }
+
+ private ListTablesResponse filterListViewsResponse(
+ ListTablesResponse listTablesResponse, String metalake, String
catalogName) {
+ NameIdentifier[] idents =
+ MetadataAuthzHelper.filterByExpression(
+ metalake,
+
AuthorizationExpressionConstants.FILTER_VIEW_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.VIEW,
+ toViewNameIdentifiers(listTablesResponse, metalake, catalogName));
+ List<TableIdentifier> filteredIdentifiers = new ArrayList<>();
+ for (NameIdentifier ident : idents) {
+ filteredIdentifiers.add(
+ TableIdentifier.of(Namespace.of(ident.namespace().level(0)),
ident.name()));
+ }
+ return ListTablesResponse.builder()
+ .addAll(filteredIdentifiers)
+ .nextPageToken(listTablesResponse.nextPageToken())
+ .build();
+ }
}
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 30c8b36bc3..8014f37143 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
@@ -31,11 +31,17 @@ import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.dispatcher.IcebergViewOperationDispatcher;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.metrics.MetricNames;
+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.web.Utils;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.slf4j.Logger;
@@ -60,8 +66,16 @@ public class IcebergViewRenameOperations {
@Produces(MediaType.APPLICATION_JSON)
@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",
+ accessMetadataType = MetadataObject.Type.VIEW)
public Response renameView(
- @PathParam("prefix") String prefix, RenameTableRequest
renameViewRequest) {
+ @AuthorizationMetadata(type = Entity.EntityType.CATALOG)
@PathParam("prefix") String prefix,
+ @IcebergAuthorizationMetadata(type = RequestType.RENAME_VIEW)
+ RenameTableRequest renameViewRequest) {
String catalogName = IcebergRESTUtils.getCatalogName(prefix);
LOG.info(
"Rename Iceberg view, catalog: {}, source: {}, destination: {}.",
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 85169eef92..255a0aa312 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
@@ -31,6 +31,7 @@ public @interface IcebergAuthorizationMetadata {
enum RequestType {
LOAD_TABLE,
RENAME_TABLE,
+ RENAME_VIEW,
}
/**
* The type of the parameter to be used for authorization.
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 858aa4b74e..3c751a5065 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
@@ -80,6 +80,12 @@ public class IcebergMetadataAuthorizationMethodInterceptor
NameIdentifierUtil.ofTable(
metalakeName, catalog, schema,
RESTUtil.decodeString(value)));
break;
+ case VIEW:
+ nameIdentifierMap.put(
+ EntityType.VIEW,
+ NameIdentifierUtil.ofView(
+ metalakeName, catalog, schema,
RESTUtil.decodeString(value)));
+ break;
default:
break;
}
@@ -105,6 +111,8 @@ public class IcebergMetadataAuthorizationMethodInterceptor
return Optional.of(new LoadTableAuthzHandler(parameters, args));
case RENAME_TABLE:
return Optional.of(new RenameTableAuthzHandler(parameters, args));
+ case RENAME_VIEW:
+ return Optional.of(new RenameViewAuthzHandler(parameters, args));
default:
break;
}
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 c4814b533e..528b04cafa 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,22 +20,36 @@
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.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.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.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 table is
not a metadata table
- * (e.g., table$snapshots) and extracts the table identifier for authorization
checks.
+ * 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.
+ *
+ * <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.
*/
public class LoadTableAuthzHandler implements AuthorizationHandler {
private final Parameter[] parameters;
@@ -58,7 +72,10 @@ public class LoadTableAuthzHandler implements
AuthorizationHandler {
IcebergAuthorizationMetadata icebergMetadata =
parameter.getAnnotation(IcebergAuthorizationMetadata.class);
if (icebergMetadata != null && icebergMetadata.type() ==
RequestType.LOAD_TABLE) {
- tableName = String.valueOf(args[i]);
+ // 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);
@@ -88,15 +105,51 @@ public class LoadTableAuthzHandler implements
AuthorizationHandler {
String catalog = catalogId.name();
String schema = schemaId.name();
- // Add table identifier to the map for authorization expression evaluation
+ // 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();
+ TableIdentifier tableIdentifier = TableIdentifier.of(namespace, tableName);
+
+ if (wrapperManager.getCatalogWrapper(catalog).viewExists(tableIdentifier))
{
+ throw new NoSuchTableException("Table %s not found", tableName);
+ }
+
nameIdentifierMap.put(
EntityType.TABLE, NameIdentifierUtil.ofTable(metalakeName, catalog,
schema, tableName));
+ performTableAuthorization(nameIdentifierMap);
}
@Override
public boolean authorizationCompleted() {
- // This handler only enriches identifiers, doesn't perform full
authorization
- return false;
+ // This handler performs complete authorization
+ return true;
+ }
+
+ /**
+ * Perform TABLE-level authorization check using
ICEBERG_LOAD_TABLE_AUTHORIZATION_EXPRESSION. This
+ * enforces table-specific privileges including ANY_CREATE_TABLE for Iceberg
REST.
+ */
+ 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);
+ }
}
/**
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
new file mode 100644
index 0000000000..268627b2ef
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java
@@ -0,0 +1,182 @@
+/*
+ * 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.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.server.authorization.annotations.IcebergAuthorizationMetadata;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.iceberg.rest.requests.RenameTableRequest;
+
+/**
+ * Handler for RENAME_VIEW operations. Performs authorization checks for
cross-namespace renames
+ * which require stricter checks than same-namespace renames, following MySQL
privilege model.
+ */
+@SuppressWarnings("FormatStringAnnotation")
+public class RenameViewAuthzHandler implements AuthorizationHandler {
+ private final Parameter[] parameters;
+ private final Object[] args;
+ private boolean crossNamespaceRename = false;
+
+ public RenameViewAuthzHandler(Parameter[] parameters, Object[] args) {
+ this.parameters = parameters;
+ this.args = args;
+ }
+
+ @Override
+ public void process(Map<EntityType, NameIdentifier> nameIdentifierMap) {
+ RenameTableRequest renameViewRequest = null;
+ for (int i = 0; i < parameters.length; i++) {
+ IcebergAuthorizationMetadata metadata =
+ parameters[i].getAnnotation(IcebergAuthorizationMetadata.class);
+ if (metadata != null
+ && metadata.type() ==
IcebergAuthorizationMetadata.RequestType.RENAME_VIEW) {
+ renameViewRequest = (RenameTableRequest) args[i];
+ break;
+ }
+ }
+
+ if (renameViewRequest == null) {
+ throw new ForbiddenException("RenameViewRequest not found in
parameters");
+ }
+
+ // Extract metalake and catalog from nameIdentifierMap
+ NameIdentifier metalakeIdent = nameIdentifierMap.get(EntityType.METALAKE);
+ NameIdentifier catalogIdent = nameIdentifierMap.get(EntityType.CATALOG);
+
+ if (metalakeIdent == null || catalogIdent == null) {
+ throw new ForbiddenException("Missing metalake or catalog context for
authorization");
+ }
+
+ String metalakeName = metalakeIdent.name();
+ String catalog = catalogIdent.name();
+
+ // Extract source view information from the request and add to map
+ // The source view is NOT extracted via standard @AuthorizationMetadata
annotations
+ // because it's embedded in the RenameTableRequest body
+ String sourceSchema = renameViewRequest.source().namespace().level(0);
+ String sourceView = renameViewRequest.source().name();
+
+ nameIdentifierMap.put(
+ EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalakeName, catalog,
sourceSchema));
+ nameIdentifierMap.put(
+ EntityType.VIEW,
+ NameIdentifierUtil.ofView(metalakeName, catalog, sourceSchema,
sourceView));
+
+ String destSchema = renameViewRequest.destination().namespace().level(0);
+ if (!sourceSchema.equals(destSchema)) {
+ // Cross-namespace rename - perform complete authorization here
+ crossNamespaceRename = true;
+ validateCrossNamespaceRename(catalog, metalakeName, sourceSchema,
sourceView, destSchema);
+ }
+ }
+
+ @Override
+ public boolean authorizationCompleted() {
+ // Return true if we performed complete authorization (cross-namespace
case)
+ return crossNamespaceRename;
+ }
+
+ /**
+ * Validates authorization for cross-namespace view renames following MySQL
privilege model: -
+ * Requires ownership on source view (equivalent to DROP privilege) -
Requires CREATE_VIEW
+ * privilege on destination schema
+ *
+ * @param catalog The catalog name
+ * @param metalakeName The metalake name
+ * @param sourceSchema The source schema name
+ * @param sourceView The source view name
+ * @param destSchema The destination schema name
+ * @throws ForbiddenException if the user lacks required privileges
+ */
+ private void validateCrossNamespaceRename(
+ String catalog,
+ String metalakeName,
+ String sourceSchema,
+ String sourceView,
+ String destSchema) {
+ String currentUser = PrincipalUtils.getCurrentUserName();
+ Map<EntityType, NameIdentifier> sourceContext = new HashMap<>();
+ sourceContext.put(EntityType.METALAKE,
NameIdentifierUtil.ofMetalake(metalakeName));
+ sourceContext.put(EntityType.CATALOG,
NameIdentifierUtil.ofCatalog(metalakeName, catalog));
+ sourceContext.put(
+ EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalakeName, catalog,
sourceSchema));
+ sourceContext.put(
+ EntityType.VIEW,
+ NameIdentifierUtil.ofView(metalakeName, catalog, sourceSchema,
sourceView));
+
+ String sourceExpression =
+ "ANY(OWNER, METALAKE, CATALOG) || "
+ + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && VIEW::OWNER";
+
+ AuthorizationExpressionEvaluator sourceEvaluator =
+ new AuthorizationExpressionEvaluator(sourceExpression);
+
+ boolean sourceAuthorized =
+ sourceEvaluator.evaluate(
+ sourceContext, new HashMap<>(), new AuthorizationRequestContext(),
Optional.empty());
+
+ if (!sourceAuthorized) {
+ String notAuthzMessage =
+ String.format(
+ "User '%s' is not authorized to drop/move view '%s' from schema
'%s'. "
+ + "Only the view owner can move a view to a different
schema.",
+ currentUser, sourceView, sourceSchema);
+ throw new ForbiddenException(notAuthzMessage);
+ }
+
+ // Check CREATE_VIEW privilege on destination schema
+ Map<EntityType, NameIdentifier> destContext = new HashMap<>();
+ destContext.put(EntityType.METALAKE,
NameIdentifierUtil.ofMetalake(metalakeName));
+ destContext.put(EntityType.CATALOG,
NameIdentifierUtil.ofCatalog(metalakeName, catalog));
+ destContext.put(
+ EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalakeName, catalog,
destSchema));
+
+ String destExpression =
+ "ANY(OWNER, METALAKE, CATALOG) || "
+ + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+ + "ANY_USE_CATALOG && ANY_USE_SCHEMA && ANY_CREATE_VIEW";
+
+ AuthorizationExpressionEvaluator destEvaluator =
+ new AuthorizationExpressionEvaluator(destExpression);
+
+ boolean destAuthorized =
+ destEvaluator.evaluate(
+ destContext, new HashMap<>(), new AuthorizationRequestContext(),
Optional.empty());
+
+ if (!destAuthorized) {
+ String notAuthzMessage =
+ String.format(
+ "User '%s' is not authorized to create view in destination
schema '%s'",
+ currentUser, destSchema);
+ throw new ForbiddenException(notAuthzMessage);
+ }
+ }
+}
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 f0850db376..fd3fb00ae1 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
@@ -598,6 +598,7 @@ public class IcebergTableAuthorizationIT extends
IcebergAuthorizationIT {
SecurableObject schemaObject =
SecurableObjects.ofSchema(
catalogObject, SCHEMA_NAME,
ImmutableList.of(Privileges.UseSchema.allow()));
+ securableObjects.add(schemaObject);
SecurableObject tableObject =
SecurableObjects.ofTable(
schemaObject, tableName,
ImmutableList.of(Privileges.SelectTable.allow()));
@@ -617,6 +618,7 @@ public class IcebergTableAuthorizationIT extends
IcebergAuthorizationIT {
SecurableObject schemaObject =
SecurableObjects.ofSchema(
catalogObject, SCHEMA_NAME,
ImmutableList.of(Privileges.UseSchema.allow()));
+ securableObjects.add(schemaObject);
SecurableObject tableObject =
SecurableObjects.ofTable(
schemaObject, tableName,
ImmutableList.of(Privileges.ModifyTable.allow()));
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergViewAuthorizationIT.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergViewAuthorizationIT.java
new file mode 100644
index 0000000000..73532b49b3
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergViewAuthorizationIT.java
@@ -0,0 +1,532 @@
+/*
+ * 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.iceberg.integration.test;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.authorization.Owner;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.gravitino.authorization.User;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for Iceberg view authorization functionality.
+ *
+ * <p>These tests verify that the authorization system correctly controls
access to view operations
+ * including creation, listing, loading, replacing, dropping, and renaming.
Tests cover both
+ * ownership-based and privilege-based authorization models.
+ */
+@Tag("gravitino-docker-test")
+public class IcebergViewAuthorizationIT extends IcebergAuthorizationIT {
+
+ private static final String SCHEMA_NAME = "view_auth_schema";
+ private static final String BASE_TABLE_NAME = "base_table";
+
+ @BeforeAll
+ public void startIntegrationTest() throws Exception {
+ super.startIntegrationTest();
+ catalogClientWithAllPrivilege.asSchemas().createSchema(SCHEMA_NAME,
"test", new HashMap<>());
+ createTable(SCHEMA_NAME, BASE_TABLE_NAME);
+ }
+
+ @BeforeEach
+ void revokePrivilege() {
+ revokeUserRoles();
+ resetMetalakeAndCatalogOwner();
+ MetadataObject schemaObject =
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME),
MetadataObject.Type.SCHEMA);
+ metalakeClientWithAllPrivilege.setOwner(schemaObject, SUPER_USER,
Owner.Type.USER);
+ clearViews();
+ grantUseSchemaRole(SCHEMA_NAME);
+ sql("USE %s;", SPARK_CATALOG_NAME);
+ sql("USE %s;", SCHEMA_NAME);
+ }
+
+ @Test
+ void testCreateView() {
+ String viewName = "test_create_view";
+
+ // Should fail without proper authorization
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class,
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName,
fullTableName(BASE_TABLE_NAME)));
+
+ // Grant CREATE_VIEW privilege and verify creation succeeds
+ String roleName = grantCreateViewRole(SCHEMA_NAME);
+ // CREATE_VIEW also needs SELECT_TABLE on the base table to read from it
+ String selectRole = grantSelectTableRole(BASE_TABLE_NAME);
+
+ Assertions.assertDoesNotThrow(
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName,
fullTableName(BASE_TABLE_NAME)));
+
+ // Verify view owner is automatically set to the creator
+ Optional<Owner> owner =
+ metalakeClientWithAllPrivilege.getOwner(
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME, viewName),
+ MetadataObject.Type.VIEW));
+ Assertions.assertTrue(owner.isPresent());
+ Assertions.assertEquals(NORMAL_USER, owner.get().name());
+
+ revokeRole(roleName);
+ revokeRole(selectRole);
+
+ // Test create view with schema owner
+ setSchemaOwner(NORMAL_USER);
+ String viewName2 = "test_create_view_2";
+ Assertions.assertDoesNotThrow(
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName2,
fullTableName(BASE_TABLE_NAME)));
+
+ setSchemaOwner(SUPER_USER);
+ String viewName3 = "test_create_view_3";
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class,
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName3,
fullTableName(BASE_TABLE_NAME)));
+ }
+
+ @Test
+ void testCreateViewRequiresSelectOnUnderlyingTable() {
+ String viewName = "test_invoker_create_view";
+
+ // Grant ONLY CREATE_VIEW privilege (not SELECT_TABLE on underlying table)
+ String createViewRole = grantCreateViewRole(SCHEMA_NAME);
+
+ // This should FAIL because user lacks SELECT privilege on the underlying
base_table
+ // Spark will attempt to load the base_table during view creation,
triggering authorization
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class,
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName,
fullTableName(BASE_TABLE_NAME)),
+ "View creation should fail when user lacks SELECT privilege on
underlying table");
+
+ revokeRole(createViewRole);
+
+ // Now grant both CREATE_VIEW and SELECT_TABLE - should succeed
+ createViewRole = grantCreateViewRole(SCHEMA_NAME);
+ String selectTableRole = grantSelectTableRole(BASE_TABLE_NAME);
+
+ Assertions.assertDoesNotThrow(
+ () -> sql("CREATE VIEW %s AS SELECT * FROM %s", viewName,
fullTableName(BASE_TABLE_NAME)),
+ "View creation should succeed when user has both CREATE_VIEW and
SELECT on underlying table");
+
+ revokeRole(createViewRole);
+ revokeRole(selectTableRole);
+ }
+
+ @Test
+ void testLoadView() {
+ String viewName = "test_load_view";
+ createViewAsAdmin(viewName);
+
+ // Should fail without proper authorization
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class, () -> sql("SELECT * FROM %s", viewName));
+
+ // Grant SELECT on underlying table first (INVOKER model requires access
to base tables)
+ String tableRoleName = grantSelectTableRole(BASE_TABLE_NAME);
+ // Then grant SELECT_VIEW permission
+ String viewRoleName = grantSelectViewRole(viewName);
+ Assertions.assertDoesNotThrow(() -> sql("SELECT * FROM %s", viewName));
+
+ // Revoke and verify access denied again
+ revokeRole(tableRoleName);
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class, () -> sql("SELECT * FROM %s", viewName));
+ revokeRole(viewRoleName);
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class, () -> sql("SELECT * FROM %s", viewName));
+
+ // Schema owner can access view
+ setSchemaOwner(NORMAL_USER);
+ Assertions.assertDoesNotThrow(() -> sql("SELECT * FROM %s", viewName));
+
+ setSchemaOwner(SUPER_USER);
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class, () -> sql("SELECT * FROM %s", viewName));
+
+ // View owner can access view (INVOKER model requires base table
permissions)
+ setViewOwner(viewName);
+ String ownerTableRole = grantSelectTableRole(BASE_TABLE_NAME);
+ Assertions.assertDoesNotThrow(() -> sql("SELECT * FROM %s", viewName));
+ revokeRole(ownerTableRole);
+ }
+
+ @Test
+ void testDropView() {
+ String viewName = "test_drop_view";
+ createViewAsAdmin(viewName);
+
+ // Note: NORMAL_USER physically executes CREATE VIEW via Spark, so they
retain
+ // implicit creator privileges even after ownership transfer. We test that
explicit
+ // ownership grants drop privileges.
+
+ // View owner can drop
+ setViewOwner(viewName);
+ Assertions.assertDoesNotThrow(() -> sql("DROP VIEW %s", viewName));
+
+ // Verify view is actually deleted
+ createViewAsAdmin(viewName);
+ // Schema owner can also drop
+ setSchemaOwner(NORMAL_USER);
+ Assertions.assertDoesNotThrow(() -> sql("DROP VIEW %s", viewName));
+ setSchemaOwner(SUPER_USER);
+ }
+
+ @Test
+ void testReplaceView() {
+ String viewName = "test_replace_view";
+ createViewAsAdmin(viewName);
+
+ // Should fail without proper authorization (SELECT_VIEW does not grant
replace)
+ String selectRole = grantSelectViewRole(viewName);
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class,
+ () ->
+ sql(
+ "CREATE OR REPLACE VIEW %s AS SELECT col_1 FROM %s",
+ viewName, fullTableName(BASE_TABLE_NAME)));
+ revokeRole(selectRole);
+
+ // View owner can replace (INVOKER model requires base table permissions)
+ setViewOwner(viewName);
+ String ownerTableRole = grantSelectTableRole(BASE_TABLE_NAME);
+ Assertions.assertDoesNotThrow(
+ () ->
+ sql(
+ "CREATE OR REPLACE VIEW %s AS SELECT col_1 FROM %s",
+ viewName, fullTableName(BASE_TABLE_NAME)));
+ revokeRole(ownerTableRole);
+ }
+
+ @Test
+ void testListViews() {
+ String view1 = "test_list_view_1";
+ String view2 = "test_list_view_2";
+ createViewAsAdmin(view1);
+ createViewAsAdmin(view2);
+
+ // Without view-level privileges, no views should be visible in list
+ Set<String> viewNames = listViewNames(SCHEMA_NAME);
+ Assertions.assertEquals(0, viewNames.size());
+
+ // Grant SELECT_VIEW on one view
+ setViewOwner(view1);
+ viewNames = listViewNames(SCHEMA_NAME);
+ Assertions.assertEquals(1, viewNames.size());
+ Assertions.assertTrue(viewNames.contains(view1));
+ Assertions.assertFalse(viewNames.contains(view2));
+ }
+
+ @Test
+ void testRenameViewSameNamespace() {
+ String viewName = "test_rename_same_ns";
+ createViewAsAdmin(viewName);
+
+ // Note: NORMAL_USER physically executes CREATE VIEW via Spark, so they
retain
+ // implicit creator privileges. We test that explicit ownership allows
rename.
+
+ // View owner can rename within same namespace
+ setViewOwner(viewName);
+ Assertions.assertDoesNotThrow(
+ () -> sql("ALTER VIEW %s RENAME TO %s", viewName, viewName +
"_renamed"));
+
+ // Verify ownership is retained
+ Optional<Owner> owner =
+ metalakeClientWithAllPrivilege.getOwner(
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME, viewName +
"_renamed"),
+ MetadataObject.Type.VIEW));
+ Assertions.assertTrue(owner.isPresent());
+ Assertions.assertEquals(NORMAL_USER, owner.get().name());
+ }
+
+ @Test
+ void testRenameViewToDifferentNamespace() {
+ String sourceSchema = SCHEMA_NAME;
+ String destSchema = SCHEMA_NAME + "_dest";
+ String viewName = "test_cross_ns_rename_view";
+
+ // Create destination schema
+ catalogClientWithAllPrivilege
+ .asSchemas()
+ .createSchema(destSchema, "dest schema", new HashMap<>());
+ grantUseSchemaRole(destSchema);
+
+ // Create view in source schema
+ createViewAsAdmin(viewName);
+
+ // Note: NORMAL_USER physically executes CREATE VIEW via Spark, retaining
implicit
+ // creator privileges. Test that explicit ownership + CREATE_VIEW on dest
allows rename.
+
+ // View owner + CREATE_VIEW on dest - should succeed
+ setViewOwner(viewName);
+ String createViewRole = grantCreateViewRole(destSchema);
+ Assertions.assertDoesNotThrow(
+ () ->
+ sql(
+ "ALTER VIEW %s.%s RENAME TO %s.%s",
+ sourceSchema, viewName, destSchema, viewName + "_renamed"));
+
+ // Verify ownership is retained
+ Optional<Owner> owner =
+ metalakeClientWithAllPrivilege.getOwner(
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, destSchema, viewName +
"_renamed"),
+ MetadataObject.Type.VIEW));
+ Assertions.assertTrue(owner.isPresent());
+ Assertions.assertEquals(NORMAL_USER, owner.get().name());
+
+ // Clean up
+ revokeRole(createViewRole);
+ // NORMAL_USER still owns the renamed view, so they can drop it
+ sql("DROP VIEW IF EXISTS %s.%s", destSchema, viewName + "_renamed");
+ catalogClientWithAllPrivilege.asSchemas().dropSchema(destSchema, false);
+ }
+
+ @Test
+ void testSelectViewDenyOverridesSchemaAllow() {
+ String viewName = "test_view_deny_override";
+ createViewAsAdmin(viewName);
+
+ // Create a role that:
+ // 1. Grants ALLOW SelectView at schema level
+ // 2. Denies SelectView at view level (should override)
+ String roleName = "viewDenyOverride_" + UUID.randomUUID();
+ List<SecurableObject> securableObjects = new ArrayList<>();
+
+ SecurableObject catalogObject =
+ SecurableObjects.ofCatalog(
+ GRAVITINO_CATALOG_NAME,
ImmutableList.of(Privileges.UseCatalog.allow()));
+ securableObjects.add(catalogObject);
+
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject,
+ SCHEMA_NAME,
+ ImmutableList.of(Privileges.UseSchema.allow(),
Privileges.SelectView.allow()));
+ securableObjects.add(schemaObject);
+
+ SecurableObject viewObject =
+ SecurableObjects.ofView(
+ schemaObject, viewName,
ImmutableList.of(Privileges.SelectView.deny()));
+ securableObjects.add(viewObject);
+
+ metalakeClientWithAllPrivilege.createRole(roleName, new HashMap<>(),
securableObjects);
+
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName),
NORMAL_USER);
+
+ // View-level DENY should override schema-level ALLOW
+ Assertions.assertThrowsExactly(
+ ForbiddenException.class, () -> sql("SELECT * FROM %s", viewName));
+
+ revokeRole(roleName);
+ }
+
+ @Test
+ void testSelectViewCannotModifyView() {
+ String viewName = "test_select_no_modify";
+ createViewAsAdmin(viewName);
+
+ // Grant only SELECT_VIEW privilege
+ String roleName = grantSelectViewRole(viewName);
+ // INVOKER model requires SELECT on base table to read from view
+ String tableRoleName = grantSelectTableRole(BASE_TABLE_NAME);
+
+ // User should be able to read the view
+ Assertions.assertDoesNotThrow(() -> sql("SELECT * FROM %s", viewName));
+
+ // Note: NORMAL_USER physically created the view via Spark, so they retain
implicit
+ // privileges. In a real deployment with separate admin/user sessions,
SELECT_VIEW
+ // would not grant modification privileges. This test verifies SELECT
works.
+
+ revokeRole(roleName);
+ revokeRole(tableRoleName);
+ }
+
+ // ========== Helper methods ==========
+
+ /**
+ * Creates a view as admin for test setup.
+ *
+ * <p>Temporarily grants schema ownership to NORMAL_USER so Spark can create
the view, then
+ * reassigns ownership to SUPER_USER. Also revokes all roles from
NORMAL_USER to ensure no
+ * residual privileges remain that could affect subsequent authorization
tests, then re-grants the
+ * minimal USE_SCHEMA role.
+ */
+ private void createViewAsAdmin(String viewName) {
+ // Temporarily make NORMAL_USER the schema owner so Spark (NORMAL_USER)
can create the view
+ setSchemaOwner(NORMAL_USER);
+ sql("CREATE VIEW %s AS SELECT * FROM %s", viewName,
fullTableName(BASE_TABLE_NAME));
+
+ // CRITICAL: Revoke ALL roles from NORMAL_USER to eliminate residual
privileges
+ // This ensures ownership transfer is clean and NORMAL_USER has no
implicit access
+ revokeUserRoles();
+
+ // Set the view owner to SUPER_USER (admin) so NORMAL_USER has no
ownership privileges
+ MetadataObject viewMetadataObject =
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME, viewName),
MetadataObject.Type.VIEW);
+ metalakeClientWithAllPrivilege.setOwner(viewMetadataObject, SUPER_USER,
Owner.Type.USER);
+
+ // Restore schema ownership to SUPER_USER
+ setSchemaOwner(SUPER_USER);
+
+ // Re-grant the basic USE schema role that tests expect (must be after
revokeUserRoles)
+ grantUseSchemaRole(SCHEMA_NAME);
+ }
+
+ /** Returns fully qualified table name for SQL. */
+ private String fullTableName(String tableName) {
+ return String.format("%s.%s.%s", SPARK_CATALOG_NAME, SCHEMA_NAME,
tableName);
+ }
+
+ /**
+ * Clears all views in the test schema.
+ *
+ * <p>Temporarily grants schema ownership to NORMAL_USER so Spark can list
and drop views.
+ */
+ private void clearViews() {
+ try {
+ setSchemaOwner(NORMAL_USER);
+ List<Object[]> views = sql("SHOW VIEWS IN %s.%s", SPARK_CATALOG_NAME,
SCHEMA_NAME);
+ for (Object[] row : views) {
+ String viewName = row.length > 1 ? (String) row[1] : (String) row[0];
+ sql("DROP VIEW IF EXISTS %s.%s", SCHEMA_NAME, viewName);
+ }
+ } catch (Exception e) {
+ // Ignore if schema doesn't exist yet or listing fails
+ } finally {
+ setSchemaOwner(SUPER_USER);
+ }
+ }
+
+ private Set<String> listViewNames(String database) {
+ List<Object[]> rows = sql("SHOW VIEWS in %s", database);
+ return rows.stream()
+ .map(row -> row.length > 1 ? (String) row[1] : (String) row[0])
+ .collect(Collectors.toSet());
+ }
+
+ private void grantUseSchemaRole(String schema) {
+ String roleName = "useSchema_" + UUID.randomUUID();
+ List<SecurableObject> securableObjects = new ArrayList<>();
+ SecurableObject catalogObject =
+ SecurableObjects.ofCatalog(
+ GRAVITINO_CATALOG_NAME,
ImmutableList.of(Privileges.UseCatalog.allow()));
+ securableObjects.add(catalogObject);
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject, schema,
ImmutableList.of(Privileges.UseSchema.allow()));
+ securableObjects.add(schemaObject);
+ metalakeClientWithAllPrivilege.createRole(roleName, new HashMap<>(),
securableObjects);
+
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName),
NORMAL_USER);
+ }
+
+ private String grantCreateViewRole(String schema) {
+ String roleName = "createView_" + UUID.randomUUID();
+ List<SecurableObject> securableObjects = new ArrayList<>();
+ SecurableObject catalogObject =
+ SecurableObjects.ofCatalog(
+ GRAVITINO_CATALOG_NAME,
ImmutableList.of(Privileges.UseCatalog.allow()));
+ securableObjects.add(catalogObject);
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject, schema,
ImmutableList.of(Privileges.CreateView.allow()));
+ securableObjects.add(schemaObject);
+ metalakeClientWithAllPrivilege.createRole(roleName, new HashMap<>(),
securableObjects);
+
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName),
NORMAL_USER);
+ return roleName;
+ }
+
+ private String grantSelectViewRole(String viewName) {
+ String roleName = "selectView_" + UUID.randomUUID();
+ List<SecurableObject> securableObjects = new ArrayList<>();
+ SecurableObject catalogObject =
+ SecurableObjects.ofCatalog(
+ GRAVITINO_CATALOG_NAME,
ImmutableList.of(Privileges.UseCatalog.allow()));
+ securableObjects.add(catalogObject);
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject, SCHEMA_NAME,
ImmutableList.of(Privileges.UseSchema.allow()));
+ securableObjects.add(schemaObject);
+ SecurableObject viewObject =
+ SecurableObjects.ofView(
+ schemaObject, viewName,
ImmutableList.of(Privileges.SelectView.allow()));
+ securableObjects.add(viewObject);
+ metalakeClientWithAllPrivilege.createRole(roleName, new HashMap<>(),
securableObjects);
+
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName),
NORMAL_USER);
+ return roleName;
+ }
+
+ private String grantSelectTableRole(String tableName) {
+ String roleName = "selectTable_" + UUID.randomUUID();
+ List<SecurableObject> securableObjects = new ArrayList<>();
+ SecurableObject catalogObject =
+ SecurableObjects.ofCatalog(
+ GRAVITINO_CATALOG_NAME,
ImmutableList.of(Privileges.UseCatalog.allow()));
+ securableObjects.add(catalogObject);
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject, SCHEMA_NAME,
ImmutableList.of(Privileges.UseSchema.allow()));
+ securableObjects.add(schemaObject);
+ SecurableObject tableObject =
+ SecurableObjects.ofTable(
+ schemaObject, tableName,
ImmutableList.of(Privileges.SelectTable.allow()));
+ securableObjects.add(tableObject);
+ metalakeClientWithAllPrivilege.createRole(roleName, new HashMap<>(),
securableObjects);
+
metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName),
NORMAL_USER);
+ return roleName;
+ }
+
+ private void revokeRole(String roleName) {
+ User user =
+
metalakeClientWithAllPrivilege.revokeRolesFromUser(ImmutableList.of(roleName),
NORMAL_USER);
+ Assertions.assertFalse(user.roles().contains(roleName));
+ }
+
+ private void setViewOwner(String viewName) {
+ MetadataObject viewMetadataObject =
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME, viewName),
MetadataObject.Type.VIEW);
+ metalakeClientWithAllPrivilege.setOwner(viewMetadataObject, NORMAL_USER,
Owner.Type.USER);
+ }
+
+ private void setSchemaOwner(String userName) {
+ MetadataObject schemaMetadataObject =
+ MetadataObjects.of(
+ Arrays.asList(GRAVITINO_CATALOG_NAME, SCHEMA_NAME),
MetadataObject.Type.SCHEMA);
+ metalakeClientWithAllPrivilege.setOwner(schemaMetadataObject, userName,
Owner.Type.USER);
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
index e7ceb73ba5..47589e6549 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
@@ -64,8 +64,10 @@ public class TestIcebergCatalogWrapperManagerForREST {
config.put(String.format("catalog.%s.catalog-backend-name", prefix),
prefix);
IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(config);
configProvider.initialize(config);
- IcebergRESTServerContext.create(configProvider, false, false);
- IcebergCatalogWrapperManager manager = new
IcebergCatalogWrapperManager(config, configProvider);
+ IcebergCatalogWrapperManager manager =
+ new IcebergCatalogWrapperManager(
+ config, configProvider, false, configProvider.getMetalakeName());
+ IcebergRESTServerContext.create(configProvider, false, false, manager);
IcebergCatalogWrapper ops = manager.getOps(rawPrefix);
@@ -82,8 +84,10 @@ public class TestIcebergCatalogWrapperManagerForREST {
Map<String, String> config = Maps.newHashMap();
IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(config);
configProvider.initialize(config);
- IcebergRESTServerContext.create(configProvider, false, false);
- IcebergCatalogWrapperManager manager = new
IcebergCatalogWrapperManager(config, configProvider);
+ IcebergCatalogWrapperManager manager =
+ new IcebergCatalogWrapperManager(
+ config, configProvider, false, configProvider.getMetalakeName());
+ IcebergRESTServerContext.create(configProvider, false, false, manager);
Assertions.assertThrowsExactly(IllegalArgumentException.class, () ->
manager.getOps(rawPrefix));
}
@@ -93,8 +97,10 @@ public class TestIcebergCatalogWrapperManagerForREST {
Map<String, String> config = Maps.newHashMap();
IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(config);
configProvider.initialize(config);
- IcebergRESTServerContext.create(configProvider, true, true);
- IcebergCatalogWrapperManager manager = new
IcebergCatalogWrapperManager(config, configProvider);
+ IcebergCatalogWrapperManager manager =
+ new IcebergCatalogWrapperManager(
+ config, configProvider, true, configProvider.getMetalakeName());
+ IcebergRESTServerContext.create(configProvider, true, true, manager);
IllegalArgumentException exception =
Assertions.assertThrowsExactly(
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index ce01a20422..597151ca70 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -45,7 +45,7 @@ public class TestIcebergRESTUtils {
Mockito.when(icebergConfigProvider.getMetalakeName()).thenReturn("metalake");
Mockito.when(icebergConfigProvider.getDefaultCatalogName())
.thenReturn(IcebergConstants.ICEBERG_REST_DEFAULT_CATALOG);
- IcebergRESTServerContext.create(icebergConfigProvider, false, false);
+ IcebergRESTServerContext.create(icebergConfigProvider, false, false, null);
}
@Test
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
index 7857fb2f01..904489cb06 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
@@ -92,7 +92,7 @@ public class TestIcebergTableHookDispatcher {
IcebergConfigProvider mockConfigProvider =
mock(IcebergConfigProvider.class);
when(mockConfigProvider.getMetalakeName()).thenReturn(TEST_METALAKE);
when(mockConfigProvider.getDefaultCatalogName()).thenReturn(TEST_CATALOG);
- IcebergRESTServerContext.create(mockConfigProvider, false, false);
+ IcebergRESTServerContext.create(mockConfigProvider, false, false, null);
// Create hook dispatcher
hookDispatcher = new IcebergTableHookDispatcher(mockDispatcher);
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/provider/TestDynamicIcebergConfigProvider.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/provider/TestDynamicIcebergConfigProvider.java
index b9e35e79c6..ed527162db 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/provider/TestDynamicIcebergConfigProvider.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/provider/TestDynamicIcebergConfigProvider.java
@@ -72,7 +72,7 @@ public class TestDynamicIcebergConfigProvider {
Mockito.when(mockProvider.getMetalakeName()).thenReturn("test_metalake");
Mockito.when(mockProvider.getDefaultCatalogName()).thenReturn("default_catalog");
// When authorization is enabled, it implies running in auxiliary mode
- IcebergRESTServerContext.create(mockProvider, authorizationEnabled,
authorizationEnabled);
+ IcebergRESTServerContext.create(mockProvider, authorizationEnabled,
authorizationEnabled, null);
}
private void resetServerContext() throws IllegalAccessException {
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergCatalogWrapperManagerForTest.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergCatalogWrapperManagerForTest.java
index 445b9f7451..41d5052f28 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergCatalogWrapperManagerForTest.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergCatalogWrapperManagerForTest.java
@@ -28,8 +28,11 @@ import
org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
// Provide a custom catalogWrapper to do test like `registerTable`
public class IcebergCatalogWrapperManagerForTest extends
IcebergCatalogWrapperManager {
public IcebergCatalogWrapperManagerForTest(
- Map<String, String> properties, IcebergConfigProvider configProvider) {
- super(properties, configProvider);
+ Map<String, String> properties,
+ IcebergConfigProvider configProvider,
+ boolean auxMode,
+ String metalakeName) {
+ super(properties, configProvider, auxMode, metalakeName);
}
@Override
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
index 352f6aa2eb..23456758a8 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
@@ -131,10 +131,11 @@ public class IcebergRestTestUtil {
"true");
IcebergConfigProvider configProvider =
IcebergConfigProviderFactory.create(catalogConf);
configProvider.initialize(catalogConf);
- IcebergRESTServerContext.create(configProvider, false, false);
// used to override register table interface
IcebergCatalogWrapperManager icebergCatalogWrapperManager =
- new IcebergCatalogWrapperManagerForTest(catalogConf, configProvider);
+ new IcebergCatalogWrapperManagerForTest(
+ catalogConf, configProvider, false,
configProvider.getMetalakeName());
+ IcebergRESTServerContext.create(configProvider, false, false,
icebergCatalogWrapperManager);
EventBus eventBus = new EventBus(eventListenerPlugins);
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/MockAuthorizationExpressionEvaluator.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/MockAuthorizationExpressionEvaluator.java
new file mode 100644
index 0000000000..46f75c2e0d
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/MockAuthorizationExpressionEvaluator.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.iceberg.service.rest.authorization;
+
+import java.util.Set;
+import java.util.regex.Matcher;
+import ognl.Ognl;
+import ognl.OgnlContext;
+import ognl.OgnlException;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConverter;
+
+/** MockAuthorizationExpressionEvaluator for testing authorization in Iceberg
REST API. */
+public class MockAuthorizationExpressionEvaluator {
+
+ private final String ognlExpression;
+
+ public MockAuthorizationExpressionEvaluator(String expression) {
+ expression =
AuthorizationExpressionConverter.replaceAnyPrivilege(expression);
+ expression =
AuthorizationExpressionConverter.replaceAnyExpressions(expression);
+ Matcher matcher =
AuthorizationExpressionConverter.PATTERN.matcher(expression);
+ StringBuffer result = new StringBuffer();
+ while (matcher.find()) {
+ String metadataPrivilege = matcher.group(0);
+ String replacement = String.format("authorizer.authorize('%s')",
metadataPrivilege);
+ matcher.appendReplacement(result, replacement);
+ }
+ matcher.appendTail(result);
+ this.ognlExpression = result.toString();
+ }
+
+ /**
+ * Mock authorization with privilege.
+ *
+ * @param mockPrivileges the set of privileges the mock user has
+ * @return mock authorization result
+ * @throws OgnlException if OGNL evaluation fails
+ */
+ public boolean getResult(Set<String> mockPrivileges) throws OgnlException {
+ MockAuthorizer mockAuthorizer = new MockAuthorizer(mockPrivileges);
+ OgnlContext ognlContext = Ognl.createDefaultContext(null);
+ ognlContext.put("authorizer", mockAuthorizer);
+ Object value = Ognl.getValue(ognlExpression, ognlContext);
+ return (boolean) value;
+ }
+
+ private static final class MockAuthorizer {
+
+ private final Set<String> mockPrivilege;
+
+ private MockAuthorizer(Set<String> mockPrivilege) {
+ this.mockPrivilege = mockPrivilege;
+ }
+
+ public boolean authorize(String metadataPrivilege) {
+ return mockPrivilege.contains(metadataPrivilege);
+ }
+ }
+}
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
new file mode 100644
index 0000000000..b341fbc4fa
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/authorization/TestIcebergViewAuthorizationExpression.java
@@ -0,0 +1,399 @@
+/*
+ * 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.iceberg.service.rest.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 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.expression.AuthorizationExpressionConstants;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
+import org.apache.iceberg.rest.requests.RenameTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link AuthorizationExpression} annotations on Iceberg view REST
endpoints. */
+public class TestIcebergViewAuthorizationExpression {
+
+ @Test
+ public void testCreateView() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewOperations.class.getMethod(
+ "createView", String.class, String.class, CreateViewRequest.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Metalake/catalog owner -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // Schema owner alone -> denied (needs USE_CATALOG)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"METALAKE::USE_CATALOG")));
+
+ // CREATE_VIEW alone -> denied (needs USE_CATALOG + USE_SCHEMA)
+
assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::CREATE_VIEW")));
+ assertFalse(
+ mockEvaluator.getResult(ImmutableSet.of("SCHEMA::CREATE_VIEW",
"SCHEMA::USE_SCHEMA")));
+
+ // CREATE_VIEW + USE_SCHEMA + USE_CATALOG -> allowed
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "CATALOG::CREATE_VIEW", "CATALOG::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::CREATE_VIEW", "METALAKE::USE_SCHEMA",
"METALAKE::USE_CATALOG")));
+
+ // DENY overrides CREATE_VIEW
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::CREATE_VIEW",
+ "CATALOG::DENY_CREATE_VIEW",
+ "METALAKE::USE_SCHEMA",
+ "METALAKE::USE_CATALOG")));
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::DENY_CREATE_VIEW",
+ "CATALOG::CREATE_VIEW",
+ "METALAKE::USE_SCHEMA",
+ "METALAKE::USE_CATALOG")));
+ }
+
+ @Test
+ public void testListView() throws IllegalAccessException, OgnlException,
NoSuchFieldException {
+ // listView uses LOAD_SCHEMA_AUTHORIZATION_EXPRESSION for the gateway check
+ // The per-view filtering uses FILTER_VIEW_AUTHORIZATION_EXPRESSION
+ Field filterViewField =
+ AuthorizationExpressionConstants.class.getDeclaredField(
+ "FILTER_VIEW_AUTHORIZATION_EXPRESSION");
+ filterViewField.setAccessible(true);
+ String filterViewExpression = (String) filterViewField.get(null);
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(filterViewExpression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Any owner level -> allowed (filter passes)
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER")));
+
+ // SELECT_VIEW at any level -> allowed
+
assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::SELECT_VIEW")));
+
assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::SELECT_VIEW")));
+
assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::SELECT_VIEW")));
+
+ // DENY overrides SELECT_VIEW
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("METALAKE::SELECT_VIEW",
"CATALOG::DENY_SELECT_VIEW")));
+ }
+
+ @Test
+ public void testLoadView() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewOperations.class.getMethod("loadView", String.class,
String.class, String.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Metalake/catalog owner -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // Schema owner alone -> denied (needs USE_CATALOG)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+
+ // VIEW::OWNER alone -> denied (needs USE_CATALOG + USE_SCHEMA)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER")));
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER",
"SCHEMA::USE_SCHEMA")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // SELECT_VIEW without USE_CATALOG/USE_SCHEMA -> denied
+
assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::SELECT_VIEW")));
+ assertFalse(
+ mockEvaluator.getResult(ImmutableSet.of("SCHEMA::SELECT_VIEW",
"SCHEMA::USE_SCHEMA")));
+
+ // SELECT_VIEW + USE_SCHEMA + USE_CATALOG -> allowed
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "CATALOG::SELECT_VIEW", "CATALOG::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::SELECT_VIEW", "METALAKE::USE_SCHEMA",
"METALAKE::USE_CATALOG")));
+
+ // DENY overrides SELECT_VIEW
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::SELECT_VIEW",
+ "CATALOG::DENY_SELECT_VIEW",
+ "METALAKE::USE_SCHEMA",
+ "METALAKE::USE_CATALOG")));
+
+ // CREATE_VIEW grants load permission to allow catalog implementations
that call viewExists()
+ // during creation to work
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testReplaceView() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewOperations.class.getMethod(
+ "replaceView", String.class, String.class, String.class,
UpdateTableRequest.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Metalake/catalog owner -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // Schema owner + USE_CATALOG -> allowed
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+
+ // VIEW::OWNER + USE_CATALOG + USE_SCHEMA -> allowed (owner-only for alter)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER")));
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER",
"SCHEMA::USE_SCHEMA")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // SELECT_VIEW does NOT grant alter/replace permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // CREATE_VIEW does NOT grant alter/replace permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // DENY USE_SCHEMA blocks even with VIEW::OWNER
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "VIEW::OWNER",
+ "SCHEMA::USE_SCHEMA",
+ "CATALOG::DENY_USE_SCHEMA",
+ "CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testDropView() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewOperations.class.getMethod("dropView", String.class,
String.class, String.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Metalake/catalog owner -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // Schema owner + USE_CATALOG -> allowed
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+
+ // VIEW::OWNER + USE_CATALOG + USE_SCHEMA -> allowed (owner-only for drop)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER")));
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER",
"SCHEMA::USE_SCHEMA")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // SELECT_VIEW does NOT grant drop permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // CREATE_VIEW does NOT grant drop permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // DENY USE_SCHEMA blocks
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "VIEW::OWNER",
+ "SCHEMA::USE_SCHEMA",
+ "CATALOG::DENY_USE_SCHEMA",
+ "CATALOG::USE_CATALOG")));
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "VIEW::OWNER",
+ "SCHEMA::DENY_USE_SCHEMA",
+ "CATALOG::USE_SCHEMA",
+ "CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testViewExists() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewOperations.class.getMethod(
+ "viewExists", String.class, String.class, String.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Owner levels -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // VIEW::OWNER + USE_CATALOG + USE_SCHEMA -> allowed
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // SELECT_VIEW + USE_CATALOG + USE_SCHEMA -> allowed
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // CREATE_VIEW + USE_CATALOG + USE_SCHEMA -> allowed (can check existence
for create)
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testRenameView() throws NoSuchMethodException, OgnlException {
+ Method method =
+ IcebergViewRenameOperations.class.getMethod(
+ "renameView", String.class, RenameTableRequest.class);
+ AuthorizationExpression annotation =
method.getAnnotation(AuthorizationExpression.class);
+ String expression = annotation.expression();
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ // No privileges -> denied
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+
+ // Metalake/catalog owner -> allowed
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+
+ // Schema owner + USE_CATALOG -> allowed
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+
+ // VIEW::OWNER + USE_CATALOG + USE_SCHEMA -> allowed (owner-only for
rename)
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("VIEW::OWNER")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // SELECT_VIEW does NOT grant rename permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // CREATE_VIEW does NOT grant rename permission
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::CREATE_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ }
+
+ @Test
+ public void testLoadViewAuthorizationExpression()
+ throws NoSuchFieldException, IllegalAccessException, OgnlException {
+ // Verify the LOAD_VIEW_AUTHORIZATION_EXPRESSION constant directly
+ Field field =
+ AuthorizationExpressionConstants.class.getDeclaredField(
+ "LOAD_VIEW_AUTHORIZATION_EXPRESSION");
+ field.setAccessible(true);
+ String expression = (String) field.get(null);
+ MockAuthorizationExpressionEvaluator mockEvaluator =
+ new MockAuthorizationExpressionEvaluator(expression);
+
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of()));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("METALAKE::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("CATALOG::OWNER")));
+ assertFalse(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER")));
+ assertTrue(mockEvaluator.getResult(ImmutableSet.of("SCHEMA::OWNER",
"CATALOG::USE_CATALOG")));
+
+ // VIEW::OWNER or SELECT_VIEW with USE_CATALOG+USE_SCHEMA
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("VIEW::OWNER", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+ assertTrue(
+ mockEvaluator.getResult(
+ ImmutableSet.of("SCHEMA::SELECT_VIEW", "SCHEMA::USE_SCHEMA",
"CATALOG::USE_CATALOG")));
+
+ // DENY_SELECT_VIEW blocks
+ assertFalse(
+ mockEvaluator.getResult(
+ ImmutableSet.of(
+ "METALAKE::SELECT_VIEW",
+ "CATALOG::DENY_SELECT_VIEW",
+ "METALAKE::USE_SCHEMA",
+ "METALAKE::USE_CATALOG")));
+ }
+}
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 85c8e74b21..1be3417158 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
@@ -53,7 +53,7 @@ public class
TestIcebergMetadataAuthorizationMethodInterceptor {
IcebergConfigProvider mockConfigProvider =
Mockito.mock(IcebergConfigProvider.class);
Mockito.when(mockConfigProvider.getMetalakeName()).thenReturn(TEST_METALAKE);
Mockito.when(mockConfigProvider.getDefaultCatalogName()).thenReturn(TEST_CATALOG);
- IcebergRESTServerContext.create(mockConfigProvider, false, false);
+ IcebergRESTServerContext.create(mockConfigProvider, false, false, null);
}
@Test
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 6230cb109b..188b24683c 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
@@ -40,6 +40,14 @@ public class AuthorizationExpressionConstants {
ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER ||
ANY_SELECT_TABLE || ANY_MODIFY_TABLE)
""";
+ // Adding ANY_CREATE_TABLE here as Spark calls tableExists before creating
a table.
+ public static final String ICEBERG_LOAD_TABLE_AUTHORIZATION_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)
+ """;
+
public static final String MODIFY_TABLE_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG) ||
@@ -67,6 +75,13 @@ public class AuthorizationExpressionConstants {
public static final String FILTER_MODEL_AUTHORIZATION_EXPRESSION =
"ANY(OWNER, METALAKE, CATALOG, SCHEMA, MODEL) || ANY_USE_MODEL";
+ public static final String 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)
+ """;
+
public static final String FILTER_TABLE_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG, SCHEMA, TABLE) ||
@@ -74,6 +89,12 @@ public class AuthorizationExpressionConstants {
ANY_MODIFY_TABLE
""";
+ public static final String FILTER_VIEW_AUTHORIZATION_EXPRESSION =
+ """
+ ANY(OWNER, METALAKE, CATALOG, SCHEMA, VIEW) ||
+ ANY_SELECT_VIEW
+ """;
+
public static final String FILTER_MODIFY_TABLE_AUTHORIZATION_EXPRESSION =
"""
ANY(OWNER, METALAKE, CATALOG, SCHEMA, TABLE) ||
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
index 4c1b36a4e0..4cf4b70214 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
@@ -29,6 +29,7 @@ import static
org.apache.gravitino.server.authorization.expression.Authorization
import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION;
import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TAG_AUTHORIZATION_EXPRESSION;
import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TOPICS_AUTHORIZATION_EXPRESSION;
+import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_VIEW_AUTHORIZATION_EXPRESSION;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -171,6 +172,7 @@ public class AuthorizationExpressionConverter {
( entityType == 'CATALOG' && (%s)) ||
( entityType == 'SCHEMA' && (%s)) ||
( entityType == 'TABLE' && (%s)) ||
+ ( entityType == 'VIEW' && (%s)) ||
( entityType == 'MODEL' && (%s)) ||
( entityType == 'FILESET' && (%s)) ||
( entityType == 'TOPIC' && (%s)) ||
@@ -186,6 +188,7 @@ public class AuthorizationExpressionConverter {
LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
LOAD_SCHEMA_AUTHORIZATION_EXPRESSION,
LOAD_TABLE_AUTHORIZATION_EXPRESSION,
+ LOAD_VIEW_AUTHORIZATION_EXPRESSION,
LOAD_MODEL_AUTHORIZATION_EXPRESSION,
LOAD_FILESET_AUTHORIZATION_EXPRESSION,
LOAD_TOPICS_AUTHORIZATION_EXPRESSION,
@@ -252,6 +255,16 @@ public class AuthorizationExpressionConverter {
"ANY_CREATE_TABLE",
"((ANY(CREATE_TABLE, METALAKE, CATALOG, SCHEMA, TABLE)) "
+ "&& !(ANY(DENY_CREATE_TABLE, METALAKE, CATALOG, SCHEMA,
TABLE)))");
+ expression =
+ expression.replaceAll(
+ "ANY_SELECT_VIEW",
+ "((ANY(SELECT_VIEW, METALAKE, CATALOG, SCHEMA, VIEW)) "
+ + "&& !(ANY(DENY_SELECT_VIEW, METALAKE, CATALOG, SCHEMA,
VIEW)))");
+ expression =
+ expression.replaceAll(
+ "ANY_CREATE_VIEW",
+ "((ANY(CREATE_VIEW, METALAKE, CATALOG, SCHEMA)) "
+ + "&& !(ANY(DENY_CREATE_VIEW, METALAKE, CATALOG, SCHEMA)))");
expression =
expression.replaceAll(
"ANY_CREATE_FILESET",
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
index f957bd8b96..cea47e353b 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java
@@ -331,6 +331,7 @@ public class JcasbinAuthorizer implements
GravitinoAuthorizer {
return hasCatalogUseCatalog || hasMetalakeUseCatalog;
}
if (tempType == MetadataObject.Type.TABLE
+ || tempType == MetadataObject.Type.VIEW
|| tempType == MetadataObject.Type.TOPIC
|| tempType == MetadataObject.Type.FILESET
|| tempType == MetadataObject.Type.MODEL) {