This is an automated email from the ASF dual-hosted git repository.
mchades 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 c732bfa5de [#12594] feat(server): Authorize Semantic Model REST and
validate sources (#13512)
c732bfa5de is described below
commit c732bfa5de7c1b58a21c086452a9f7cd83dccc4d
Author: Akshay Thorat <[email protected]>
AuthorDate: Sun Oct 11 19:39:08 2026 -0700
[#12594] feat(server): Authorize Semantic Model REST and validate sources
(#13512)
### What changes were proposed in this pull request?
Follow-up to #12867 for caller-facing Semantic Model authorization and
source validation (#12594). The Ossie dependency #13571 has landed. This
branch is merged with current main and uses its renamed Ossie APIs and
converter while preserving caller-facing validation.
- Authorizes every Semantic Model REST endpoint: list has a schema gate
and filters individual models; create/import use create privileges;
load/export use load privileges; alter/drop use their existing
modify/owner expressions.
- Validates source visibility, existence, and primary-key, unique-key,
and relationship columns before native create, Ossie import, and every
`replaceDefinition`.
- Checks catalog View support before fallback lookup, avoiding
unsupported View operations when a table is missing. Unexpected failures
from supported operations still propagate.
- Runs existence and column validation even when authorization is
disabled, before persistence.
### Why are the changes needed?
Internal metadata dispatchers do not enforce caller visibility. All REST
entry points must enforce their permissions, and writes must validate
their sources consistently. Related to #12594 and #12209.
Fix: #12594
Whitespace regressions characterize existing behavior: blank catalog
names are rejected before lookup; missing sources are rejected; blank
schema/table names can pass if the mocked connector resolves them. No
new naming restriction is introduced. Alignment with Ossie for
resolvable blank names still needs the policy decision requested in
https://github.com/apache/gravitino/issues/12594#issuecomment-5944902157.
### Does this PR introduce _any_ user-facing change?
Semantic Model REST operations enforce their privileges and list
visibility. Writes return `400` for missing sources/columns, `403` for
denied source access, and `502` for source connection failures. A
missing table in a catalog without View support returns `400` instead of
`501`. Invalid replacement requests do not reach persistence.
SQL expression interpretation and transitive view validation remain
outside scope.
### How was this patch tested?
JDK 17: all 632 server tests, 23 focused API tests, and 100 focused core
Semantic Model/Ossie tests passed, with no failures or skips.
Formatting, server checks, and server Javadoc passed:
```bash
./gradlew :server:spotlessApply :server:check :server:javadoc \
:api:test --tests '*TestSemanticModel*' --tests '*TestOssie*' \
:core:test --tests '*TestSemanticModel*' --tests '*TestOssie*' -PskipITs
```
Regression tests reproduced the endpoint authorization bypass and `501`
response before the fixes. Coverage includes denial/owner access for all
seven endpoints, list filtering, real catalog-operations capability
checks, no-View fallback for create/replacement with authorization
on/off, Ossie import failure paths, and the 24-case native
whitespace-source matrix. Connector resolution is mocked; external
Docker/catalog integration suites were not run.
---------
Co-authored-by: mchades <[email protected]>
---
.../gravitino/catalog/SemanticModelValidator.java | 3 -
docs/security/access-control.md | 10 +
.../apache/gravitino/server/GravitinoServer.java | 2 +
.../web/filter/GravitinoInterceptionService.java | 2 +
.../server/web/rest/SemanticModelOperations.java | 119 +++++--
.../web/rest/SemanticModelSourceValidator.java | 213 ++++++++++++
.../filter/TestGravitinoInterceptionService.java | 111 +++++++
.../web/rest/TestSemanticModelOperations.java | 357 ++++++++++++++++++++-
.../web/rest/TestSemanticModelSourceValidator.java | 318 ++++++++++++++++++
9 files changed, 1096 insertions(+), 39 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
b/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
index bebda80bb1..aab54da4e0 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/SemanticModelValidator.java
@@ -56,9 +56,6 @@ final class SemanticModelValidator {
private SemanticModelValidator() {}
- // TODO(#12594): Validate source existence, columns, and authorization in
the caller before
- // invoking this definition-only validator. Cover whitespace-only source
segments in regression
- // tests for create and replaceDefinition, and verify consistency with Ossie
import/export.
static void validateDefinition(@Nullable SemanticModelDefinition definition)
{
if (definition == null) {
throw invalid("$", "definition must not be null");
diff --git a/docs/security/access-control.md b/docs/security/access-control.md
index 37deeb5d4d..a406385fde 100755
--- a/docs/security/access-control.md
+++ b/docs/security/access-control.md
@@ -310,6 +310,16 @@ authorization behavior.
The native View rename operation changes only the name within the existing
schema and remains
owner-only; it does not accept a target schema.
+Creating or importing a Semantic Model, or replacing its definition, validates
every dataset source
+as a Table or supported logical View under the
+caller's permissions, including cross-catalog references within the request's
metalake. Each lookup
+requires visibility of that source type before loading metadata. Primary-key,
unique-key, and
+relationship columns must exist in the resolved source. Source existence and
column validation also
+run when authorization is disabled. Access denial returns `403`, missing
sources or columns return
+`400`, and source catalog connection failures return `502`. These checks do
not authorize queries
+against the referenced data or interpret SQL expressions. A missing table in a
catalog without View
+support is a missing source (`400`); no unsupported View lookup is attempted.
+
#### Metalake Objects
| Object | Create | Read
| Alter or delete | Use |
diff --git
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 6900fc6f9f..019e26563b 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -73,6 +73,7 @@ import
org.apache.gravitino.server.web.mapper.JsonProcessingExceptionMapper;
import org.apache.gravitino.server.web.mapper.NotFoundExceptionMapper;
import org.apache.gravitino.server.web.mapper.ParamExceptionMapper;
import org.apache.gravitino.server.web.mapper.WebApplicationExceptionMapper;
+import org.apache.gravitino.server.web.rest.SemanticModelSourceValidator;
import org.apache.gravitino.server.web.ui.WebUIFilter;
import org.apache.gravitino.stats.StatisticDispatcher;
import org.apache.gravitino.tag.TagDispatcher;
@@ -168,6 +169,7 @@ public class GravitinoServer extends ResourceConfig {
bind(gravitinoEnv.metalakeDispatcher()).to(MetalakeDispatcher.class).ranked(1);
bind(gravitinoEnv.catalogDispatcher()).to(CatalogDispatcher.class).ranked(1);
bind(gravitinoEnv.schemaDispatcher()).to(SchemaDispatcher.class).ranked(1);
+ bindAsContract(SemanticModelSourceValidator.class);
bind(gravitinoEnv.tableDispatcher()).to(TableDispatcher.class).ranked(1);
bind(gravitinoEnv.viewDispatcher()).to(ViewDispatcher.class).ranked(1);
bind(gravitinoEnv.partitionDispatcher()).to(PartitionDispatcher.class).ranked(1);
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index f4307bf90f..026413e4ce 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -78,6 +78,7 @@ import org.apache.gravitino.server.web.rest.PolicyOperations;
import org.apache.gravitino.server.web.rest.RoleOperations;
import org.apache.gravitino.server.web.rest.SchemaOperations;
import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
+import org.apache.gravitino.server.web.rest.SemanticModelOperations;
import org.apache.gravitino.server.web.rest.StatisticOperations;
import org.apache.gravitino.server.web.rest.TableOperations;
import org.apache.gravitino.server.web.rest.TagOperations;
@@ -109,6 +110,7 @@ public class GravitinoInterceptionService implements
InterceptionService {
ViewOperations.class.getName(),
ModelOperations.class.getName(),
FunctionOperations.class.getName(),
+ SemanticModelOperations.class.getName(),
TopicOperations.class.getName(),
FilesetOperations.class.getName(),
BulkOperations.class.getName(),
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
index 657350edbd..d75cb5dc91 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
@@ -38,6 +38,8 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
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.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.catalog.SemanticModelDispatcher;
@@ -50,10 +52,16 @@ import
org.apache.gravitino.dto.responses.SemanticModelResponse;
import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.metrics.MetricNames;
import org.apache.gravitino.semantic.OssieDocument;
+import org.apache.gravitino.semantic.OssieDocumentConverter;
+import
org.apache.gravitino.semantic.OssieDocumentConverter.ImportedSemanticModel;
import org.apache.gravitino.semantic.OssieFormat;
import org.apache.gravitino.semantic.SemanticModel;
import org.apache.gravitino.semantic.SemanticModelChange;
import org.apache.gravitino.semantic.SemanticModelDefinition;
+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.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
@@ -71,6 +79,7 @@ public class SemanticModelOperations {
private static final String OSSIE_TEXT_YAML_MEDIA_TYPE = "text/yaml";
private final SemanticModelDispatcher dispatcher;
+ private final SemanticModelSourceValidator sourceValidator;
@Context private HttpServletRequest httpRequest;
@@ -78,10 +87,13 @@ public class SemanticModelOperations {
* Creates Semantic Model REST operations.
*
* @param dispatcher The Semantic Model dispatcher.
+ * @param sourceValidator The caller-facing source validator.
*/
@Inject
- public SemanticModelOperations(SemanticModelDispatcher dispatcher) {
+ public SemanticModelOperations(
+ SemanticModelDispatcher dispatcher, SemanticModelSourceValidator
sourceValidator) {
this.dispatcher = dispatcher;
+ this.sourceValidator = sourceValidator;
}
/**
@@ -96,10 +108,14 @@ public class SemanticModelOperations {
@Produces("application/vnd.gravitino.v1+json")
@Timed(name = "list-semantic-model." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "list-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.LOAD_SCHEMA_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response listSemanticModels(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema) {
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema) {
LOG.info(
"Received list Semantic Models request for schema: {}.{}.{}",
metalake, catalog, schema);
try {
@@ -108,6 +124,13 @@ public class SemanticModelOperations {
() -> {
Namespace namespace = NamespaceUtil.ofSemanticModel(metalake,
catalog, schema);
NameIdentifier[] identifiers =
dispatcher.listSemanticModels(namespace);
+ identifiers = identifiers == null ? new NameIdentifier[0] :
identifiers;
+ identifiers =
+ MetadataAuthzHelper.filterByExpression(
+ metalake,
+
AuthorizationExpressionConstants.FILTER_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.SEMANTIC_MODEL,
+ identifiers);
LOG.info(
"List {} Semantic Models under schema: {}.{}.{}",
identifiers.length,
@@ -134,10 +157,14 @@ public class SemanticModelOperations {
@Produces(VND_GRAVITINO_V1_JSON)
@Timed(name = "create-semantic-model." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "create-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.CREATE_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response createSemanticModel(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
SemanticModelCreateRequest request) {
String name = request == null ? "" : request.getName();
LOG.info(
@@ -189,10 +216,14 @@ public class SemanticModelOperations {
@Produces(VND_GRAVITINO_V1_JSON)
@Timed(name = "import-ossie-semantic-model." +
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
@ResponseMetered(name = "import-ossie-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.CREATE_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SCHEMA)
public Response importOssieDocument(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
String document,
@Context HttpHeaders headers) {
LOG.info(
@@ -208,9 +239,14 @@ public class SemanticModelOperations {
MediaType.APPLICATION_JSON_TYPE.isCompatible(headers.getMediaType())
? OssieDocument.json(document)
: OssieDocument.yaml(document);
+ ImportedSemanticModel imported =
OssieDocumentConverter.importDocument(ossieDocument);
+ sourceValidator.validate(metalake, imported.definition());
SemanticModel semanticModel =
- dispatcher.importOssieDocument(
- NamespaceUtil.ofSemanticModel(metalake, catalog, schema),
ossieDocument);
+ dispatcher.createSemanticModel(
+ NameIdentifierUtil.ofSemanticModel(metalake, catalog,
schema, imported.name()),
+ imported.comment(),
+ imported.definition(),
+ imported.properties());
LOG.info(
"Apache Ossie Semantic Model imported: {}.{}.{}.{}",
metalake,
@@ -239,11 +275,16 @@ public class SemanticModelOperations {
@Produces(VND_GRAVITINO_V1_JSON)
@Timed(name = "load-semantic-model." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "load-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.LOAD_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SEMANTIC_MODEL)
public Response loadSemanticModel(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
- @PathParam("semanticModel") String semanticModel) {
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
+ @PathParam("semanticModel") @AuthorizationMetadata(type =
Entity.EntityType.SEMANTIC_MODEL)
+ String semanticModel) {
LOG.info(
"Received load Semantic Model request: {}.{}.{}.{}",
metalake,
@@ -282,11 +323,16 @@ public class SemanticModelOperations {
@Produces("application/vnd.gravitino.v1+json")
@Timed(name = "alter-semantic-model." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "alter-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.MODIFY_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SEMANTIC_MODEL)
public Response alterSemanticModel(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
- @PathParam("semanticModel") String semanticModel,
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
+ @PathParam("semanticModel") @AuthorizationMetadata(type =
Entity.EntityType.SEMANTIC_MODEL)
+ String semanticModel,
SemanticModelUpdatesRequest request) {
LOG.info(
"Received alter Semantic Model request: {}.{}.{}.{}",
@@ -308,6 +354,12 @@ public class SemanticModelOperations {
request.getUpdates().stream()
.map(SemanticModelUpdateRequest::semanticModelChange)
.toArray(SemanticModelChange[]::new);
+ for (SemanticModelChange change : changes) {
+ if (change instanceof SemanticModelChange.ReplaceDefinition) {
+ sourceValidator.validate(
+ metalake, ((SemanticModelChange.ReplaceDefinition)
change).getDefinition());
+ }
+ }
SemanticModel altered = dispatcher.alterSemanticModel(ident,
changes);
LOG.info(
"Semantic Model altered: {}.{}.{}.{}", metalake, catalog,
schema, altered.name());
@@ -333,11 +385,16 @@ public class SemanticModelOperations {
@Produces("application/vnd.gravitino.v1+json")
@Timed(name = "drop-semantic-model." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "drop-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.DROP_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SEMANTIC_MODEL)
public Response dropSemanticModel(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
- @PathParam("semanticModel") String semanticModel) {
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
+ @PathParam("semanticModel") @AuthorizationMetadata(type =
Entity.EntityType.SEMANTIC_MODEL)
+ String semanticModel) {
LOG.info(
"Received drop Semantic Model request: {}.{}.{}.{}",
metalake,
@@ -380,11 +437,16 @@ public class SemanticModelOperations {
@Path("{semanticModel}/ossie")
@Timed(name = "export-ossie-semantic-model." +
MetricNames.HTTP_PROCESS_DURATION, absolute = true)
@ResponseMetered(name = "export-ossie-semantic-model", absolute = true)
+ @AuthorizationExpression(
+ expression =
AuthorizationExpressionConstants.LOAD_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ accessMetadataType = MetadataObject.Type.SEMANTIC_MODEL)
public Response exportOssieDocument(
- @PathParam("metalake") String metalake,
- @PathParam("catalog") String catalog,
- @PathParam("schema") String schema,
- @PathParam("semanticModel") String semanticModel,
+ @PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
+ String metalake,
+ @PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG) String catalog,
+ @PathParam("schema") @AuthorizationMetadata(type =
Entity.EntityType.SCHEMA) String schema,
+ @PathParam("semanticModel") @AuthorizationMetadata(type =
Entity.EntityType.SEMANTIC_MODEL)
+ String semanticModel,
@DefaultValue("yaml") @QueryParam("format") String format) {
LOG.info(
"Received export Semantic Model as Apache Ossie request: {}.{}.{}.{},
format: {}",
@@ -421,6 +483,7 @@ public class SemanticModelOperations {
String metalake, String catalog, String schema,
SemanticModelCreateRequest request) {
request.validate();
SemanticModelDefinition definition = request.toDefinition();
+ sourceValidator.validate(metalake, definition);
NameIdentifier ident =
NameIdentifierUtil.ofSemanticModel(metalake, catalog, schema,
request.getName());
return dispatcher.createSemanticModel(
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelSourceValidator.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelSourceValidator.java
new file mode 100644
index 0000000000..6b26e7e50a
--- /dev/null
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelSourceValidator.java
@@ -0,0 +1,213 @@
+/*
+ * 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.rest;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import javax.inject.Inject;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalSemanticModelException;
+import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.ViewCatalog;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Validates source visibility, existence, and explicit column references
before model writes. */
+public class SemanticModelSourceValidator {
+ private final TableDispatcher tables;
+ private final ViewDispatcher views;
+ private final Supplier<GravitinoAuthorizer> authorizer;
+ private final Predicate<NameIdentifier> supportsViews;
+
+ /**
+ * Creates a validator using the server's authorizer, including its
disabled-mode pass-through.
+ *
+ * @param tables table metadata dispatcher
+ * @param views view metadata dispatcher
+ */
+ @Inject
+ public SemanticModelSourceValidator(TableDispatcher tables, ViewDispatcher
views) {
+ this(tables, views, () ->
GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer());
+ }
+
+ SemanticModelSourceValidator(
+ TableDispatcher tables, ViewDispatcher views,
Supplier<GravitinoAuthorizer> authorizer) {
+ this(
+ tables,
+ views,
+ authorizer,
+ ident ->
+ GravitinoEnv.getInstance()
+ .catalogManager()
+ .doWithCatalog(
+ NameIdentifierUtil.getCatalogIdentifier(ident),
+ catalog -> catalog.ops() instanceof ViewCatalog));
+ }
+
+ SemanticModelSourceValidator(
+ TableDispatcher tables,
+ ViewDispatcher views,
+ Supplier<GravitinoAuthorizer> authorizer,
+ Predicate<NameIdentifier> supportsViews) {
+ this.supportsViews = supportsViews;
+ this.tables = tables;
+ this.views = views;
+ this.authorizer = authorizer;
+ }
+
+ /**
+ * Validates every dataset source and key or relationship column under the
current caller.
+ *
+ * <p>This method must run even when authorization is disabled. It does not
interpret SQL
+ * expressions or validate transitive view dependencies. Connection failures
propagate unchanged.
+ *
+ * @param metalake the request's metalake, also used for cross-catalog
source references
+ * @param definition the complete definition to validate before persistence
+ */
+ public void validate(String metalake, SemanticModelDefinition definition) {
+ AuthorizationRequestContext context = new AuthorizationRequestContext();
+ GravitinoAuthorizer currentAuthorizer = authorizer.get();
+ Map<String, Set<String>> datasetColumns = new HashMap<>();
+ Map<NameIdentifier, Set<String>> sourceColumns = new HashMap<>();
+ for (Dataset dataset : definition.datasets()) {
+ NameIdentifier source = dataset.source();
+ if (source.namespace().length() != 2) {
+ throw new IllegalSemanticModelException("Source must contain
catalog.schema.name");
+ }
+ NameIdentifier ident =
+ NameIdentifier.of(
+ metalake, source.namespace().level(0),
source.namespace().level(1), source.name());
+ Set<String> columns =
+ sourceColumns.computeIfAbsent(ident, key -> loadColumns(key,
currentAuthorizer, context));
+ datasetColumns.put(dataset.name(), columns);
+ checkColumns(dataset.name(), dataset.primaryKey(), columns);
+ if (dataset.uniqueKeys() != null) {
+ for (String[] key : dataset.uniqueKeys()) {
+ checkColumns(dataset.name(), key, columns);
+ }
+ }
+ }
+ if (definition.relationships() != null) {
+ for (Relationship relationship : definition.relationships()) {
+ checkColumns(
+ relationship.from(),
+ relationship.fromColumns(),
+ datasetColumns.get(relationship.from()));
+ checkColumns(
+ relationship.to(), relationship.toColumns(),
datasetColumns.get(relationship.to()));
+ }
+ }
+ }
+
+ private Set<String> loadColumns(
+ NameIdentifier ident,
+ GravitinoAuthorizer currentAuthorizer,
+ AuthorizationRequestContext context) {
+ boolean tableAllowed =
+ canLoad(
+ ident,
+ Entity.EntityType.TABLE,
+
AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION,
+ currentAuthorizer,
+ context);
+ if (tableAllowed) {
+ try {
+ return columnNames(ident, tables.loadTable(ident).columns());
+ } catch (NotFoundException missing) {
+ // The untyped source can also refer to a logical view. Authorize that
lookup separately.
+ }
+ }
+ boolean viewAllowed =
+ canLoad(
+ ident,
+ Entity.EntityType.VIEW,
+
AuthorizationExpressionConstants.LOAD_VIEW_AUTHORIZATION_EXPRESSION,
+ currentAuthorizer,
+ context);
+ boolean viewSupported = true;
+ if (tableAllowed || viewAllowed) {
+ try {
+ viewSupported = supportsViews.test(ident);
+ if (viewAllowed && viewSupported) {
+ return columnNames(ident, views.loadView(ident).columns());
+ }
+ } catch (NotFoundException missing) {
+ // Do not disclose whether the other, inaccessible object type exists.
+ }
+ }
+ if (!tableAllowed || (viewSupported && !viewAllowed)) {
+ throw new ForbiddenException("Not authorized to resolve Semantic Model
source %s", ident);
+ }
+ throw new IllegalSemanticModelException("Semantic Model source %s does not
exist", ident);
+ }
+
+ private boolean canLoad(
+ NameIdentifier ident,
+ Entity.EntityType type,
+ String expression,
+ GravitinoAuthorizer currentAuthorizer,
+ AuthorizationRequestContext context) {
+ return new AuthorizationExpressionEvaluator(expression, currentAuthorizer)
+ .evaluate(
+ NameIdentifierUtil.splitNameIdentifier(ident.namespace().level(0),
type, ident),
+ context);
+ }
+
+ private Set<String> columnNames(NameIdentifier ident, Column[] columns) {
+ if (columns == null) {
+ throw new IllegalSemanticModelException(
+ "Column metadata is unavailable for source %s", ident);
+ }
+ Set<String> names = new HashSet<>();
+ Arrays.stream(columns).forEach(column -> names.add(column.name()));
+ return names;
+ }
+
+ private void checkColumns(String dataset, String[] references, Set<String>
columns) {
+ if (columns == null) {
+ throw new IllegalSemanticModelException("Unknown dataset %s in
relationship", dataset);
+ }
+ if (references != null) {
+ for (String reference : references) {
+ if (!columns.contains(reference)) {
+ throw new IllegalSemanticModelException(
+ "Dataset %s source has no column %s", dataset, reference);
+ }
+ }
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 0e316ef41d..16df186d96 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -40,6 +40,7 @@ import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
@@ -60,6 +61,8 @@ import org.apache.gravitino.catalog.ViewDispatcher;
import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
import org.apache.gravitino.dto.requests.SchemaCreateRequest;
+import org.apache.gravitino.dto.requests.SemanticModelCreateRequest;
+import org.apache.gravitino.dto.requests.SemanticModelUpdatesRequest;
import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
import org.apache.gravitino.dto.responses.ErrorConstants;
import org.apache.gravitino.dto.responses.ErrorResponse;
@@ -81,6 +84,7 @@ import org.apache.gravitino.server.web.rest.CatalogOperations;
import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
import org.apache.gravitino.server.web.rest.SchemaOperations;
import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
+import org.apache.gravitino.server.web.rest.SemanticModelOperations;
import org.apache.gravitino.server.web.rest.TableOperations;
import org.apache.gravitino.server.web.rest.ViewOperations;
import org.apache.gravitino.tag.TagDispatcher;
@@ -145,6 +149,113 @@ public class TestGravitinoInterceptionService {
}
}
+ @Test
+ public void testSemanticModelOperationsIsRegisteredForInterception() {
+ Descriptor descriptor = mock(Descriptor.class);
+
when(descriptor.getImplementation()).thenReturn(SemanticModelOperations.class.getName());
+ Assertions.assertTrue(
+ new
GravitinoInterceptionService().getDescriptorFilter().matches(descriptor));
+ }
+
+ @Test
+ public void testSemanticModelEndpointAuthorization() throws Throwable {
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> providers =
+ mockStatic(GravitinoAuthorizerProvider.class);
+ MockedStatic<AuthorizationUtils> authUtils =
mockStatic(AuthorizationUtils.class);
+ MockedStatic<GravitinoEnv> environments =
mockStatic(GravitinoEnv.class)) {
+ principalUtils
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+
providers.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ environments.when(GravitinoEnv::getInstance).thenReturn(env);
+ when(env.eventBus()).thenReturn(mock(EventBus.class));
+ Method create =
+ SemanticModelOperations.class.getMethod(
+ "createSemanticModel",
+ String.class,
+ String.class,
+ String.class,
+ SemanticModelCreateRequest.class);
+ Method load =
+ SemanticModelOperations.class.getMethod(
+ "loadSemanticModel", String.class, String.class, String.class,
String.class);
+ Method list =
+ SemanticModelOperations.class.getMethod(
+ "listSemanticModels", String.class, String.class, String.class);
+ Method alter =
+ SemanticModelOperations.class.getMethod(
+ "alterSemanticModel",
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ SemanticModelUpdatesRequest.class);
+ Method drop =
+ SemanticModelOperations.class.getMethod(
+ "dropSemanticModel", String.class, String.class, String.class,
String.class);
+ Method importOssie =
+ SemanticModelOperations.class.getMethod(
+ "importOssieDocument",
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ HttpHeaders.class);
+ Method exportOssie =
+ SemanticModelOperations.class.getMethod(
+ "exportOssieDocument",
+ String.class,
+ String.class,
+ String.class,
+ String.class,
+ String.class);
+ for (Method method : List.of(create, load, list, alter, drop,
importOssie, exportOssie)) {
+ MethodInvocation invocation = mock(MethodInvocation.class);
+ when(invocation.getMethod()).thenReturn(method);
+ Object[] args;
+ if (method.equals(list)) {
+ args = new Object[] {"metalake", "catalog", "schema"};
+ } else if (method.equals(importOssie)) {
+ args =
+ new Object[] {"metalake", "catalog", "schema", "document",
mock(HttpHeaders.class)};
+ } else if (method.equals(exportOssie)) {
+ args = new Object[] {"metalake", "catalog", "schema", "sales",
"yaml"};
+ } else if (method.equals(alter)) {
+ args =
+ new Object[] {
+ "metalake", "catalog", "schema", "sales",
mock(SemanticModelUpdatesRequest.class)
+ };
+ } else {
+ args =
+ new Object[] {
+ "metalake",
+ "catalog",
+ "schema",
+ method.equals(create) ? mock(SemanticModelCreateRequest.class)
: "sales"
+ };
+ }
+ when(invocation.getArguments()).thenReturn(args);
+ MethodInterceptor interceptor =
+ new
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+ Response denied = (Response) interceptor.invoke(invocation);
+ assertEquals(403, denied.getStatus());
+ verify(invocation, never()).proceed();
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+ when(invocation.proceed()).thenReturn(Response.ok().build());
+ Response allowed = (Response) interceptor.invoke(invocation);
+ assertEquals(200, allowed.getStatus());
+ verify(invocation).proceed();
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(false);
+ }
+ }
+ }
+
@Test
public void testViewOperationsIsRegisteredForInterception() {
Descriptor descriptor = mock(Descriptor.class);
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
index 0b4e117641..3fd2028c54 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
@@ -25,9 +25,11 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@@ -37,14 +39,23 @@ import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Application;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.catalog.SemanticModelDispatcher;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.catalog.ViewDispatcher;
import org.apache.gravitino.dto.requests.SemanticModelCreateRequest;
import org.apache.gravitino.dto.requests.SemanticModelUpdateRequest;
import org.apache.gravitino.dto.requests.SemanticModelUpdatesRequest;
@@ -61,10 +72,14 @@ import
org.apache.gravitino.exceptions.IllegalSemanticModelException;
import org.apache.gravitino.exceptions.MetalakeNotInUseException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchSemanticModelException;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.exceptions.SemanticModelAlreadyExistsException;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.SemanticModelEntity;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
import org.apache.gravitino.rest.RESTUtils;
import org.apache.gravitino.semantic.AIContext;
import org.apache.gravitino.semantic.CustomExtension;
@@ -75,11 +90,13 @@ import org.apache.gravitino.semantic.Dialects;
import org.apache.gravitino.semantic.Expression;
import org.apache.gravitino.semantic.Field;
import org.apache.gravitino.semantic.Metric;
-import org.apache.gravitino.semantic.OssieDocument;
import org.apache.gravitino.semantic.OssieFormat;
import org.apache.gravitino.semantic.SemanticModel;
import org.apache.gravitino.semantic.SemanticModelChange;
import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
+import org.apache.gravitino.server.authorization.PassThroughAuthorizer;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
import org.glassfish.jersey.internal.inject.AbstractBinder;
@@ -89,8 +106,12 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
/** Tests Semantic Model REST lifecycle operations and their error mappings. */
public class TestSemanticModelOperations extends BaseOperationsTest {
@@ -106,6 +127,10 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
}
private final SemanticModelDispatcher dispatcher =
mock(SemanticModelDispatcher.class);
+ private final TableDispatcher tables = mock(TableDispatcher.class);
+ private final ViewDispatcher views = mock(ViewDispatcher.class);
+ private boolean viewsSupported = true;
+ private GravitinoAuthorizer sourceAuthorizer = new PassThroughAuthorizer();
private final String metalake = "semantic_model_metalake";
private final String catalog = "semantic_model_catalog";
private final String schema = "semantic_model_schema";
@@ -128,6 +153,9 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
@Override
protected void configure() {
bind(dispatcher).to(SemanticModelDispatcher.class).ranked(2);
+ bind(new SemanticModelSourceValidator(
+ tables, views, () -> sourceAuthorizer, ident ->
viewsSupported))
+ .to(SemanticModelSourceValidator.class);
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class);
}
});
@@ -136,8 +164,14 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
@BeforeEach
void resetDispatcher() {
- reset(dispatcher);
- doCallRealMethod().when(dispatcher).importOssieDocument(any(), any());
+ reset(dispatcher, tables, views);
+ viewsSupported = true;
+ sourceAuthorizer = new PassThroughAuthorizer();
+ Table table = mock(Table.class);
+ Column column = mock(Column.class);
+ when(column.name()).thenReturn("order_id");
+ when(table.columns()).thenReturn(new Column[] {column});
+ when(tables.loadTable(any())).thenReturn(table);
doCallRealMethod().when(dispatcher).exportOssieDocument(any(), any());
}
@@ -165,6 +199,93 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
"schema is missing");
}
+ @Test
+ void testListSemanticModelsFiltersUnauthorizedEntries() throws
IllegalAccessException {
+ NameIdentifier visible = semanticModelIdentifier("visible");
+ NameIdentifier hidden = semanticModelIdentifier("hidden");
+ NameIdentifier[] listed = {visible, hidden};
+ NameIdentifier[] filtered = {visible};
+ when(dispatcher.listSemanticModels(namespace)).thenReturn(listed);
+ SemanticModelOperations operations =
+ new SemanticModelOperations(
+ dispatcher,
+ new SemanticModelSourceValidator(tables, views, () ->
sourceAuthorizer, ident -> true));
+ FieldUtils.writeField(operations, "httpRequest",
mock(HttpServletRequest.class), true);
+ try (MockedStatic<MetadataAuthzHelper> helper =
mockStatic(MetadataAuthzHelper.class)) {
+ helper
+ .when(
+ () ->
+ MetadataAuthzHelper.filterByExpression(
+ metalake,
+ AuthorizationExpressionConstants
+ .FILTER_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ EntityType.SEMANTIC_MODEL,
+ listed))
+ .thenReturn(filtered);
+ Response response = operations.listSemanticModels(metalake, catalog,
schema);
+ Assertions.assertEquals(200, response.getStatus());
+ Assertions.assertArrayEquals(
+ filtered, ((EntityListResponse) response.getEntity()).identifiers());
+ helper.verify(
+ () ->
+ MetadataAuthzHelper.filterByExpression(
+ metalake,
+
AuthorizationExpressionConstants.FILTER_SEMANTIC_MODEL_AUTHORIZATION_EXPRESSION,
+ EntityType.SEMANTIC_MODEL,
+ listed));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"missing", "denied", "connection", "column"})
+ void testOssieImportValidatesSourcesBeforePersistence(String failure) {
+ String yaml =
+ "version: 0.2.0.dev0\nname: sales\ndatasets:\n"
+ + " - name: orders\n source: catalog.schema.orders\n
primary_key: [order_id]\n";
+ Response.Status status = Response.Status.BAD_REQUEST;
+ int code = ErrorConstants.ILLEGAL_ARGUMENTS_CODE;
+ Class<? extends Exception> type = IllegalSemanticModelException.class;
+ String message = "does not exist";
+ switch (failure) {
+ case "missing":
+ viewsSupported = false;
+ when(tables.loadTable(any())).thenThrow(new
NoSuchTableException("missing"));
+ break;
+ case "denied":
+ sourceAuthorizer = mock(GravitinoAuthorizer.class);
+ status = Response.Status.FORBIDDEN;
+ code = ErrorConstants.FORBIDDEN_CODE;
+ type = ForbiddenException.class;
+ message = "Not authorized";
+ break;
+ case "connection":
+ when(tables.loadTable(any())).thenThrow(new
ConnectionFailedException("offline"));
+ status = Response.Status.BAD_GATEWAY;
+ code = ErrorConstants.CONNECTION_FAILED_CODE;
+ type = ConnectionFailedException.class;
+ message = "offline";
+ break;
+ case "column":
+ Table table = mock(Table.class);
+ when(table.columns()).thenReturn(new Column[0]);
+ when(tables.loadTable(any())).thenReturn(table);
+ message = "order_id";
+ break;
+ default:
+ throw new AssertionError(failure);
+ }
+ assertError(
+ postDocument(semanticModelPath() + "/ossie", yaml, "application/yaml"),
+ status,
+ code,
+ type.getSimpleName(),
+ message);
+ verifyNoInteractions(dispatcher, views);
+ if (failure.equals("denied")) {
+ verifyNoInteractions(tables);
+ }
+ }
+
@Test
void testLoadSemanticModelReturnsCompleteDefinitionWithoutWrites() {
NameIdentifier ident = semanticModelIdentifier("sales");
@@ -178,6 +299,7 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
body.validate();
Assertions.assertEquals("sales", body.getSemanticModel().name());
Assertions.assertEquals("Sales definitions",
body.getSemanticModel().comment());
+ verifyNoInteractions(tables, views);
Assertions.assertEquals(semanticModel.definition(),
body.getSemanticModel().definition());
Assertions.assertEquals("orders",
body.getSemanticModel().definition().datasets()[0].name());
Assertions.assertEquals(
@@ -543,6 +665,230 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
"sales changed in this transaction");
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSourceValidationRunsWithAuthorizationDisabled(boolean
replaceDefinition) {
+ when(tables.loadTable(any())).thenThrow(new NoSuchTableException("missing
table"));
+ when(views.loadView(any())).thenThrow(new NoSuchViewException("missing
view"));
+ assertError(
+ writeDefinition(replaceDefinition, semanticModelDefinition()),
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalSemanticModelException.class.getSimpleName(),
+ "does not exist");
+ verifyNoInteractions(dispatcher);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+ void testMissingSourceWithoutViewSupport(
+ boolean replaceDefinition, boolean authorizationEnabled) {
+ viewsSupported = false;
+ if (authorizationEnabled) {
+ sourceAuthorizer = mock(GravitinoAuthorizer.class);
+ when(sourceAuthorizer.authorize(any(), any(), any(), any(),
any())).thenReturn(true);
+ }
+ when(tables.loadTable(any())).thenThrow(new NoSuchTableException("missing
table"));
+ when(views.loadView(any()))
+ .thenThrow(new UnsupportedOperationException("Catalog does not support
view operations"));
+ assertError(
+ writeDefinition(replaceDefinition, semanticModelDefinition()),
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalSemanticModelException.class.getSimpleName(),
+ "does not exist");
+ verifyNoInteractions(dispatcher, views);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "create,false",
+ "replace,false",
+ "import,false",
+ "create,true",
+ "replace,true",
+ "import,true"
+ })
+ void testMissingSourceForTableOnlyReader(String operation, boolean
catalogSupportsViews) {
+ viewsSupported = catalogSupportsViews;
+ sourceAuthorizer = mock(GravitinoAuthorizer.class);
+ when(sourceAuthorizer.authorize(any(), any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ Privilege.Name privilege = invocation.getArgument(3);
+ return privilege == Privilege.Name.USE_CATALOG
+ || privilege == Privilege.Name.USE_SCHEMA
+ || (privilege == Privilege.Name.SELECT_TABLE
+ && ((MetadataObject) invocation.getArgument(2)).type()
+ == MetadataObject.Type.SCHEMA);
+ });
+ when(tables.loadTable(any())).thenThrow(new NoSuchTableException("missing
table"));
+ Response response;
+ if (operation.equals("import")) {
+ String yaml =
+ "version: 0.2.0.dev0\nname: sales\ndatasets:\n"
+ + " - name: orders\n source: "
+ + catalog
+ + "."
+ + schema
+ + ".orders\n";
+ response = postDocument(semanticModelPath() + "/ossie", yaml,
"application/yaml");
+ } else {
+ response = writeDefinition(operation.equals("replace"),
semanticModelDefinition());
+ }
+ if (catalogSupportsViews) {
+ assertError(
+ response,
+ Response.Status.FORBIDDEN,
+ ErrorConstants.FORBIDDEN_CODE,
+ ForbiddenException.class.getSimpleName(),
+ "Not authorized");
+ } else {
+ assertError(
+ response,
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalSemanticModelException.class.getSimpleName(),
+ "does not exist");
+ }
+ verify(tables).loadTable(NameIdentifier.of(metalake, catalog, schema,
"orders"));
+ verifyNoInteractions(views, dispatcher);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testMissingColumnWithAuthorizationDisabled(boolean replaceDefinition) {
+ Table table = mock(Table.class);
+ when(table.columns()).thenReturn(new Column[0]);
+ when(tables.loadTable(any())).thenReturn(table);
+ assertError(
+ writeDefinition(replaceDefinition, semanticModelDefinition()),
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalSemanticModelException.class.getSimpleName(),
+ "order_id");
+ verifyNoInteractions(dispatcher);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testDeniedSourceIsNotResolvedOrPersisted(boolean replaceDefinition) {
+ sourceAuthorizer = mock(GravitinoAuthorizer.class);
+ assertError(
+ writeDefinition(replaceDefinition, semanticModelDefinition()),
+ Response.Status.FORBIDDEN,
+ ErrorConstants.FORBIDDEN_CODE,
+ ForbiddenException.class.getSimpleName(),
+ "Not authorized");
+ verifyNoInteractions(tables, views, dispatcher);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSourceConnectionFailureIsBadGateway(boolean replaceDefinition) {
+ when(tables.loadTable(any())).thenThrow(new
ConnectionFailedException("source unavailable"));
+ assertError(
+ writeDefinition(replaceDefinition, semanticModelDefinition()),
+ Response.Status.BAD_GATEWAY,
+ ErrorConstants.CONNECTION_FAILED_CODE,
+ ConnectionFailedException.class.getSimpleName(),
+ "source unavailable");
+ verifyNoInteractions(views, dispatcher);
+ }
+
+ @ParameterizedTest
+ @MethodSource("whitespaceSources")
+ void testWhitespaceSourceValidation(
+ int segment, boolean replaceDefinition, boolean authorizationEnabled,
boolean sourceExists) {
+ if (authorizationEnabled) {
+ sourceAuthorizer = mock(GravitinoAuthorizer.class);
+ when(sourceAuthorizer.authorize(any(), any(), any(), any(),
any())).thenReturn(true);
+ }
+ String[] parts = {catalog, schema, "orders"};
+ parts[segment] = " ";
+ NameIdentifier source = NameIdentifier.of(parts);
+ NameIdentifier fullSource = NameIdentifier.of(metalake, parts[0],
parts[1], parts[2]);
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder()
+ .withDatasets(
+ new Dataset[] {
+ Dataset.builder()
+ .withName("orders")
+ .withSource(source)
+ .withPrimaryKey(new String[] {"order_id"})
+ .build()
+ })
+ .build();
+ if (!sourceExists) {
+ when(tables.loadTable(fullSource)).thenThrow(new
NoSuchTableException("missing table"));
+ when(views.loadView(fullSource)).thenThrow(new
NoSuchViewException("missing view"));
+ }
+ when(dispatcher.createSemanticModel(any(), any(), any(), any()))
+ .thenReturn(semanticModel("sales", null));
+ when(dispatcher.alterSemanticModel(any(),
any(SemanticModelChange[].class)))
+ .thenReturn(semanticModel("sales", null));
+ Response response = writeDefinition(replaceDefinition, definition);
+ if (segment == 0) {
+ assertError(
+ response,
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalArgumentException.class.getSimpleName(),
+ "Metadata object full name cannot be blank");
+ verifyNoInteractions(tables, views, dispatcher);
+ return;
+ }
+ if (sourceExists) {
+ // Characterize existing behavior: resolvable names are not rejected
solely for whitespace.
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
response.getStatus());
+ if (replaceDefinition) {
+ verify(dispatcher).alterSemanticModel(any(),
any(SemanticModelChange[].class));
+ } else {
+ verify(dispatcher).createSemanticModel(any(), any(), eq(definition),
any());
+ }
+ verifyNoInteractions(views);
+ } else {
+ assertError(
+ response,
+ Response.Status.BAD_REQUEST,
+ ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+ IllegalSemanticModelException.class.getSimpleName(),
+ "does not exist");
+ verify(views).loadView(fullSource);
+ verifyNoInteractions(dispatcher);
+ }
+ verify(tables).loadTable(fullSource);
+ }
+
+ private static Stream<Arguments> whitespaceSources() {
+ return IntStream.range(0, 3)
+ .boxed()
+ .flatMap(
+ segment ->
+ Stream.of(false, true)
+ .flatMap(
+ replace ->
+ Stream.of(false, true)
+ .flatMap(
+ authorized ->
+ Stream.of(false, true)
+ .map(
+ exists ->
+ Arguments.of(
+ segment, replace,
authorized, exists)))));
+ }
+
+ private Response writeDefinition(boolean replaceDefinition,
SemanticModelDefinition definition) {
+ return replaceDefinition
+ ? put(
+ semanticModelPath() + "/sales",
+ new SemanticModelUpdatesRequest(
+ List.of(
+ new
SemanticModelUpdateRequest.ReplaceSemanticModelDefinitionRequest(
+
SemanticModelDefinitionDTO.fromDefinition(definition)))))
+ : post(semanticModelPath(), createRequest("sales", null, definition));
+ }
+
@ParameterizedTest
@ValueSource(strings = {"application/json", "application/json;
charset=UTF-8"})
void testImportOssieYamlAndJsonDocuments(String jsonMediaType) {
@@ -609,8 +955,6 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
jsonBody.validate();
Assertions.assertEquals("inventory", jsonBody.getSemanticModel().name());
- verify(dispatcher).importOssieDocument(namespace,
OssieDocument.yaml(yaml));
- verify(dispatcher).importOssieDocument(namespace,
OssieDocument.json(json));
ArgumentCaptor<SemanticModelDefinition> definitionCaptor =
ArgumentCaptor.forClass(SemanticModelDefinition.class);
verify(dispatcher)
@@ -660,7 +1004,6 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
SemanticModelResponse body =
response.readEntity(SemanticModelResponse.class);
body.validate();
Assertions.assertEquals("marketing", body.getSemanticModel().name());
- verify(dispatcher).importOssieDocument(namespace,
OssieDocument.yaml(yaml));
verify(dispatcher)
.createSemanticModel(
eq(ident),
@@ -730,7 +1073,6 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
IllegalSemanticModelException.class.getSimpleName(),
"query sources are not supported");
- verify(dispatcher).importOssieDocument(namespace,
OssieDocument.yaml(querySource));
assertError(
target(semanticModelPath() + "/sales/ossie").queryParam("format",
"csv").request().get(),
@@ -767,7 +1109,6 @@ public class TestSemanticModelOperations extends
BaseOperationsTest {
ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
IllegalSemanticModelException.class.getSimpleName(),
"Cannot parse Apache Ossie JSON");
- verify(dispatcher).importOssieDocument(namespace,
OssieDocument.json(yaml));
verifyNoMoreInteractions(dispatcher);
}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelSourceValidator.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelSourceValidator.java
new file mode 100644
index 0000000000..aa7544f830
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelSourceValidator.java
@@ -0,0 +1,318 @@
+/*
+ * 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.rest;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import java.util.function.Predicate;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.CatalogOperations;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalSemanticModelException;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewCatalog;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.server.authorization.PassThroughAuthorizer;
+import org.apache.gravitino.utils.ThrowableFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+/** Tests catalog-backed validation independently of model persistence. */
+public class TestSemanticModelSourceValidator {
+ private static final NameIdentifier SOURCE =
+ NameIdentifier.of("other_catalog", "schema", "orders");
+ private static final NameIdentifier FULL_SOURCE =
+ NameIdentifier.of("metalake", "other_catalog", "schema", "orders");
+
+ private final TableDispatcher tables = mock(TableDispatcher.class);
+ private final ViewDispatcher views = mock(ViewDispatcher.class);
+ private GravitinoAuthorizer authorizer = new PassThroughAuthorizer();
+ private final SemanticModelSourceValidator validator =
+ new SemanticModelSourceValidator(tables, views, () -> authorizer, ident
-> true);
+
+ @Test
+ void testCrossCatalogSourceAndKeysWithDisabledAuthorization() {
+ table("id", "unique_id");
+ validator.validate(
+ "metalake",
+ definition(dataset("orders", new String[] {"id"}, new String[][]
{{"id", "unique_id"}})));
+ verify(tables).loadTable(FULL_SOURCE);
+ verifyNoInteractions(views);
+ }
+
+ @Test
+ void testViewFallbackAndColumns() {
+ when(tables.loadTable(FULL_SOURCE)).thenThrow(new
NoSuchTableException("missing"));
+ View view = mock(View.class);
+ doReturn(columns("id")).when(view).columns();
+ when(views.loadView(FULL_SOURCE)).thenReturn(view);
+ validator.validate("metalake", definition(dataset("orders", new String[]
{"id"}, null)));
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () ->
+ validator.validate(
+ "metalake", definition(dataset("orders", new String[]
{"missing"}, null))));
+ }
+
+ @Test
+ void testMissingUniqueKeyAndUnavailableColumns() {
+ Table table = table("id");
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () ->
+ validator.validate(
+ "metalake", definition(dataset("orders", null, new String[][]
{{"missing"}}))));
+ when(table.columns()).thenReturn(null);
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ }
+
+ @Test
+ void testRelationshipColumnsAndRepeatedSource() {
+ table("id");
+ Dataset first = dataset("orders", null, null);
+ Dataset second = dataset("customers", null, null);
+ SemanticModelDefinition valid = related(first, second, "id", "id");
+ validator.validate("metalake", valid);
+ verify(tables, times(1)).loadTable(FULL_SOURCE);
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> validator.validate("metalake", related(first, second, "missing",
"id")));
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> validator.validate("metalake", related(first, second, "id",
"missing")));
+ }
+
+ @Test
+ void testDeniedTableCanResolveAuthorizedViewWithoutTableLookup() {
+ authorizeOnly(MetadataObject.Type.VIEW, Privilege.Name.SELECT_VIEW);
+ View view = mock(View.class);
+ doReturn(columns("id")).when(view).columns();
+ when(views.loadView(FULL_SOURCE)).thenReturn(view);
+ validator.validate("metalake", definition(dataset("orders", null, null)));
+ verifyNoInteractions(tables);
+ verify(views).loadView(FULL_SOURCE);
+ }
+
+ @Test
+ void testAuthorizedTableDoesNotRequireViewAccess() {
+ authorizeOnly(MetadataObject.Type.TABLE, Privilege.Name.SELECT_TABLE);
+ table("id");
+ validator.validate("metalake", definition(dataset("orders", new String[]
{"id"}, null)));
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () ->
+ validator.validate(
+ "metalake", definition(dataset("orders", new String[]
{"missing"}, null))));
+ verifyNoInteractions(views);
+ }
+
+ @Test
+ void testMissingAuthorizedTypeDoesNotProbeDeniedType() {
+ authorizeOnly(MetadataObject.Type.TABLE, Privilege.Name.SELECT_TABLE);
+ when(tables.loadTable(FULL_SOURCE)).thenThrow(new
NoSuchTableException("missing"));
+ assertThrows(
+ ForbiddenException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ verifyNoInteractions(views);
+ }
+
+ @Test
+ void testDenyOverridesSelect() {
+ authorizeOnly(MetadataObject.Type.TABLE, Privilege.Name.SELECT_TABLE);
+ when(authorizer.deny(any(), anyString(), any(),
eq(Privilege.Name.SELECT_TABLE), any()))
+ .thenReturn(true);
+ assertThrows(
+ ForbiddenException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ verifyNoInteractions(tables, views);
+ }
+
+ @Test
+ void testMissingAndConnectionFailureForView() {
+ when(tables.loadTable(FULL_SOURCE)).thenThrow(new
NoSuchTableException("missing"));
+ when(views.loadView(FULL_SOURCE)).thenThrow(new
NoSuchViewException("missing"));
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ doThrow(new
ConnectionFailedException("offline")).when(views).loadView(FULL_SOURCE);
+ assertThrows(
+ ConnectionFailedException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ }
+
+ @Test
+ void testInvalidSourceShapeDoesNotLookupMetadata() {
+ Dataset dataset =
+ Dataset.builder()
+ .withName("orders")
+ .withSource(NameIdentifier.of("foreign_metalake", "catalog",
"schema", "orders"))
+ .build();
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> validator.validate("metalake", definition(dataset)));
+ verifyNoInteractions(tables, views);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testViewSupportCheckedBeforeFallback(boolean supportsViews) {
+ CatalogManager catalogs = mock(CatalogManager.class);
+ BaseCatalog catalog = mock(BaseCatalog.class);
+ CatalogOperations operations =
+ supportsViews
+ ? mock(CatalogOperations.class,
withSettings().extraInterfaces(ViewCatalog.class))
+ : mock(CatalogOperations.class);
+ when(catalog.ops()).thenReturn(operations);
+ when(catalogs.doWithCatalog(eq(NameIdentifier.of("metalake",
"other_catalog")), any()))
+ .thenAnswer(
+ invocation -> {
+ ThrowableFunction<BaseCatalog, Boolean> callback =
invocation.getArgument(1);
+ return callback.apply(catalog);
+ });
+ when(tables.loadTable(FULL_SOURCE)).thenThrow(new
NoSuchTableException("missing"));
+ when(views.loadView(FULL_SOURCE)).thenThrow(new
NoSuchViewException("missing"));
+ try (MockedStatic<GravitinoEnv> environments =
mockStatic(GravitinoEnv.class)) {
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ environments.when(GravitinoEnv::getInstance).thenReturn(env);
+ when(env.catalogManager()).thenReturn(catalogs);
+ SemanticModelSourceValidator checking =
+ new SemanticModelSourceValidator(tables, views, () -> authorizer);
+ assertThrows(
+ IllegalSemanticModelException.class,
+ () -> checking.validate("metalake", definition(dataset("orders",
null, null))));
+ if (supportsViews) {
+ verify(views).loadView(FULL_SOURCE);
+ } else {
+ verifyNoInteractions(views);
+ }
+ verify(catalogs).doWithCatalog(eq(NameIdentifier.of("metalake",
"other_catalog")), any());
+ }
+ }
+
+ @Test
+ void testSupportedViewOperationFailureIsNotHidden() {
+ when(tables.loadTable(FULL_SOURCE)).thenThrow(new
NoSuchTableException("missing"));
+ when(views.loadView(FULL_SOURCE))
+ .thenThrow(new UnsupportedOperationException("connector failure"));
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> validator.validate("metalake", definition(dataset("orders",
null, null))));
+ }
+
+ @Test
+ void testDeniedSourcesDoNotProbeCatalogCapabilities() {
+ authorizer = mock(GravitinoAuthorizer.class);
+ Predicate<NameIdentifier> supportsViews = mock(Predicate.class);
+ SemanticModelSourceValidator checking =
+ new SemanticModelSourceValidator(tables, views, () -> authorizer,
supportsViews);
+ assertThrows(
+ ForbiddenException.class,
+ () -> checking.validate("metalake", definition(dataset("orders", null,
null))));
+ verifyNoInteractions(tables, views, supportsViews);
+ }
+
+ private void authorizeOnly(MetadataObject.Type type, Privilege.Name
privilege) {
+ authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.authorize(any(), anyString(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ Privilege.Name requested = invocation.getArgument(3);
+ MetadataObject object = invocation.getArgument(2);
+ return requested == Privilege.Name.USE_CATALOG
+ || requested == Privilege.Name.USE_SCHEMA
+ || (requested == privilege && object.type() == type);
+ });
+ }
+
+ private Table table(String... names) {
+ Table table = mock(Table.class);
+ doReturn(columns(names)).when(table).columns();
+ when(tables.loadTable(FULL_SOURCE)).thenReturn(table);
+ return table;
+ }
+
+ private Column[] columns(String... names) {
+ Column[] columns = new Column[names.length];
+ for (int i = 0; i < names.length; i++) {
+ columns[i] = mock(Column.class);
+ when(columns[i].name()).thenReturn(names[i]);
+ }
+ return columns;
+ }
+
+ private Dataset dataset(String name, String[] primaryKey, String[][]
uniqueKeys) {
+ return Dataset.builder()
+ .withName(name)
+ .withSource(SOURCE)
+ .withPrimaryKey(primaryKey)
+ .withUniqueKeys(uniqueKeys)
+ .build();
+ }
+
+ private SemanticModelDefinition definition(Dataset... datasets) {
+ return SemanticModelDefinition.builder().withDatasets(datasets).build();
+ }
+
+ private SemanticModelDefinition related(Dataset first, Dataset second,
String from, String to) {
+ return SemanticModelDefinition.builder()
+ .withDatasets(new Dataset[] {first, second})
+ .withRelationships(
+ new Relationship[] {
+ Relationship.builder()
+ .withName("customer_orders")
+ .withFrom(first.name())
+ .withTo(second.name())
+ .withFromColumns(new String[] {from})
+ .withToColumns(new String[] {to})
+ .build()
+ })
+ .build();
+ }
+}