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();
+  }
+}

Reply via email to