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 294dbf8ff9 [#12614] feat(client-python): Support Semantic Model tags 
and owners (#13794)
294dbf8ff9 is described below

commit 294dbf8ff9d886b54e35cbfcbde017c84e6ceaa0
Author: Akshay Thorat <[email protected]>
AuthorDate: Wed Oct 14 01:51:27 2026 -0700

    [#12614] feat(client-python): Support Semantic Model tags and owners 
(#13794)
    
    ### What changes were proposed in this pull request?
    
    Add `SemanticModel.supports_tags()` and preserve tag context across
    create, load, alter, and import. Add tests for tag operations,
    authorization, and user/group ownership through the existing generic
    owner API.
    
    Include the server authorization fix requested in review: wrap the
    `CAN_ACCESS_METADATA` expansion in outer parentheses so metadata access
    cannot bypass tag permissions. Add regression coverage for both tag-read
    REST routes with tables and Semantic Models. The Python denied-read
    assertion remains unchanged.
    
    ### Why are the changes needed?
    
    Enable tag support for Semantic Models in the Python SDK and fix the
    server authorization bypass exposed by PythonIT. Part of #12209.
    
    Fix: #12614
    
    The same ungrouped expression and affected route are present on
    branch-1.3. After this PR merges, backport the server fix and applicable
    regression coverage to branch-1.3. Tracked in #13839, which remains open
    until the branch-1.3 backport is complete.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Semantic Models expose tag operations and work with the existing generic
    `get_owner()` and `set_owner()` APIs. Metadata-object tag reads now
    enforce tag permissions as intended.
    
    ### How was this patch tested?
    
    - Reproduced the denied-tag-read failure through the live SDK; the same
    assertion failed in PythonIT before this fix.
    - Python 3.11 unit suite: **1,234 passed**.
    - Combined PR build: **25 live Python integration tests passed**,
    including all tag/owner tests and the denied-read assertion.
    - **10 focused Java authorization tests passed**. All **484
    server-common tests passed on serial rerun**; the first run hit the
    previously observed unrelated mTLS exception-shape failure.
    - `spotlessApply --no-parallel` and `git diff --check` passed; the
    server fix received an independent review with no findings.
    - [PythonIT rerun for the combined
    commit](https://github.com/apache/gravitino/actions/runs/38516192947).
---
 .../gravitino/api/semantic/semantic_model.py       |  15 +
 .../gravitino/client/generic_semantic_model.py     |  49 +++-
 .../client/semantic_model_catalog_operations.py    |  15 +-
 .../test_semantic_model_tags_and_owner.py          | 319 +++++++++++++++++++++
 .../test_semantic_model_tags_and_owner.py          | 208 ++++++++++++++
 .../AuthorizationExpressionConverter.java          |   4 +-
 ...stMetadataObjectTagAuthorizationExpression.java |  69 +++++
 7 files changed, 668 insertions(+), 11 deletions(-)

diff --git a/clients/client-python/gravitino/api/semantic/semantic_model.py 
b/clients/client-python/gravitino/api/semantic/semantic_model.py
index 29501a847f..d7d1cdd9b4 100644
--- a/clients/client-python/gravitino/api/semantic/semantic_model.py
+++ b/clients/client-python/gravitino/api/semantic/semantic_model.py
@@ -20,6 +20,8 @@ from typing import Optional
 
 from gravitino.api.auditable import Auditable
 from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.exceptions.base import UnsupportedOperationException
 
 
 class SemanticModel(Auditable):
@@ -45,3 +47,16 @@ class SemanticModel(Auditable):
     def properties(self) -> dict[str, str]:
         """Returns the Gravitino-specific Semantic Model properties."""
         return {}
+
+    def supports_tags(self) -> SupportsTags:
+        """Return tag operations if the Semantic Model supports tags.
+
+        Raises:
+            UnsupportedOperationException: If tag operations are not supported.
+
+        Returns:
+            SupportsTags: The tag operations supported by the Semantic Model.
+        """
+        raise UnsupportedOperationException(
+            "Semantic Model does not support tag operations."
+        )
diff --git a/clients/client-python/gravitino/client/generic_semantic_model.py 
b/clients/client-python/gravitino/client/generic_semantic_model.py
index 78cc36a483..950618d5c6 100644
--- a/clients/client-python/gravitino/client/generic_semantic_model.py
+++ b/clients/client-python/gravitino/client/generic_semantic_model.py
@@ -18,25 +18,46 @@
 from typing import Optional
 
 from gravitino.api.audit import Audit
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.semantic.semantic_model import SemanticModel
 from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient
 
 
-class GenericSemanticModel(SemanticModel):
+class GenericSemanticModel(SemanticModel, SupportsTags):
     """An immutable client-side Semantic Model returned by the Gravitino REST 
API."""
 
-    def __init__(self, semantic_model_dto: SemanticModelDTO):
+    def __init__(
+        self,
+        semantic_model_dto: SemanticModelDTO,
+        rest_client: HTTPClient,
+        semantic_model_ns: Namespace,
+    ):
         """Create a GenericSemanticModel from a SemanticModelDTO.
 
         Args:
             semantic_model_dto (SemanticModelDTO): The Semantic Model DTO.
+            rest_client (HTTPClient): The REST client for tag operations.
+            semantic_model_ns (Namespace): The full metalake.catalog.schema 
namespace.
         """
         self._name = semantic_model_dto.name()
         self._comment = semantic_model_dto.comment()
         self._definition = semantic_model_dto.definition()
         self._properties = dict(semantic_model_dto.properties())
         self._audit = semantic_model_dto.audit_info()
+        model_object = MetadataObjects.of(
+            [semantic_model_ns.level(1), semantic_model_ns.level(2), 
self._name],
+            MetadataObject.Type.SEMANTIC_MODEL,
+        )
+        self._model_tag_operations = MetadataObjectTagOperations(
+            semantic_model_ns.level(0), model_object, rest_client
+        )
 
     def name(self) -> str:
         return self._name
@@ -73,3 +94,27 @@ class GenericSemanticModel(SemanticModel):
             f"definition={self._definition!r}, 
properties={self._properties!r}, "
             f"audit={self._audit!r})"
         )
+
+    def supports_tags(self) -> SupportsTags:
+        return self
+
+    def list_tags(self) -> list[str]:
+        return self._model_tag_operations.list_tags()
+
+    def list_tags_info(self) -> list[Tag]:
+        return self._model_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._model_tag_operations.get_tag(name)
+
+    def assign_tags(
+        self,
+        tags_to_add: list[str | dict[str, str | None]] | None = None,
+        tags_to_remove: list[str | dict[str, str | None]] | None = None,
+    ) -> list[str]:
+        return self._model_tag_operations.assign_tags(tags_to_add, 
tags_to_remove)
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        return self._model_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git 
a/clients/client-python/gravitino/client/semantic_model_catalog_operations.py 
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
index 5cb820f3e5..6e90451fef 100644
--- 
a/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
+++ 
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
@@ -94,7 +94,7 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
             f"/{encode_string(identifier.name())}",
             error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
         )
-        return self._to_semantic_model(resp)
+        return self._to_semantic_model(resp, full_namespace)
 
     def create_semantic_model(
         self,
@@ -121,7 +121,7 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
             json=req,
             error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
         )
-        return self._to_semantic_model(resp)
+        return self._to_semantic_model(resp, full_namespace)
 
     def import_ossie_document(
         self, namespace: Namespace, document: str, document_format: str = 
"yaml"
@@ -137,7 +137,7 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
             headers={"Content-Type": media_type},
             error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
         )
-        return self._to_semantic_model(resp)
+        return self._to_semantic_model(resp, full_namespace)
 
     def export_ossie_document(
         self, identifier: NameIdentifier, document_format: str = "yaml"
@@ -172,7 +172,7 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
             json=updates_request,
             error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
         )
-        return self._to_semantic_model(resp)
+        return self._to_semantic_model(resp, full_namespace)
 
     def drop_semantic_model(self, identifier: NameIdentifier) -> bool:
         self._check_semantic_model_name_identifier(identifier)
@@ -194,13 +194,14 @@ class 
SemanticModelCatalogOperations(SemanticModelCatalog):
             )
         return f"application/{document_format}"
 
-    @staticmethod
-    def _to_semantic_model(resp) -> SemanticModel:
+    def _to_semantic_model(self, resp, namespace: Namespace) -> SemanticModel:
         semantic_model_resp = SemanticModelResponse.from_json(
             resp.body, infer_missing=True
         )
         semantic_model_resp.validate()
-        return GenericSemanticModel(semantic_model_resp.semantic_model())
+        return GenericSemanticModel(
+            semantic_model_resp.semantic_model(), self._rest_client, namespace
+        )
 
     @staticmethod
     def _to_semantic_model_update_request(
diff --git 
a/clients/client-python/tests/integration/test_semantic_model_tags_and_owner.py 
b/clients/client-python/tests/integration/test_semantic_model_tags_and_owner.py
new file mode 100644
index 0000000000..e7bc663f82
--- /dev/null
+++ 
b/clients/client-python/tests/integration/test_semantic_model_tags_and_owner.py
@@ -0,0 +1,319 @@
+# 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.
+
+import json
+import os
+import shutil
+import tempfile
+import uuid
+from unittest.mock import patch
+
+from gravitino import Catalog, GravitinoClient, NameIdentifier
+from gravitino.api.authorization.owner import Owner
+from gravitino.api.authorization.privileges import Privileges
+from gravitino.api.authorization.securable_objects import SecurableObjects
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.rel.column import Column
+from gravitino.api.rel.types.types import Types
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.auth.simple_auth_provider import SimpleAuthProvider
+from gravitino.exceptions.base import (
+    ForbiddenException,
+    NoSuchMetadataObjectException,
+    NoSuchTagException,
+    NotFoundException,
+    TagAlreadyAssociatedException,
+)
+from tests.integration.integration_test_env import 
AuthorizationIntegrationTestEnv
+
+
+class TestSemanticModelTagsAndOwner(AuthorizationIntegrationTestEnv):
+    """Exercise Semantic Model tags and generic ownership with authorization 
enabled."""
+
+    def setUp(self):
+        self._metalake_name = f"semantic_tags_{uuid.uuid4().hex[:8]}"
+        super().setUp()
+        self.addCleanup(self._gravitino_client.close)
+        warehouse = tempfile.mkdtemp(prefix="semantic_tags_")
+        self.addCleanup(shutil.rmtree, warehouse)
+        self.addCleanup(super().tearDown)
+        catalog = self._gravitino_client.create_catalog(
+            "iceberg",
+            Catalog.Type.RELATIONAL,
+            "lakehouse-iceberg",
+            "",
+            properties={
+                "catalog-backend": "memory",
+                "warehouse": warehouse,
+                "uri": "http://localhost";,
+            },
+        )
+        catalog.as_schemas().create_schema("semantic", "", {})
+        catalog.as_table_catalog().create_table(
+            NameIdentifier.of("semantic", "orders"),
+            [Column.of("id", Types.LongType.get())],
+            properties={},
+        )
+        definition = SemanticModelDefinition(
+            [Dataset("orders", NameIdentifier.of("iceberg", "semantic", 
"orders"))]
+        )
+        self._models = catalog.as_semantic_model_catalog()
+        self._ident = NameIdentifier.of("semantic", "sales")
+        self._model = self._models.create_semantic_model(
+            self._ident, "", definition, {}
+        )
+        self._gravitino_client.create_tag(
+            "reviewed", "review status", {"team": "finance"}
+        )
+        self._gravitino_client.create_tag("hidden", "", {})
+        self._object = MetadataObjects.of(
+            ["iceberg", "semantic", "sales"], 
MetadataObject.Type.SEMANTIC_MODEL
+        )
+
+    def tearDown(self):
+        # Registered cleanups also run when setup fails.
+        pass
+
+    def test_tag_lifecycle(self):
+        tags = self._model.supports_tags()
+        self.assertEqual([], tags.list_tags())
+        self.assertEqual([], tags.list_tags_info())
+        self.assertEqual(["reviewed"], tags.associate_tags(["reviewed"], []))
+        loaded = self._models.load_semantic_model(self._ident).supports_tags()
+        self.assertEqual(["reviewed"], loaded.list_tags())
+        details = loaded.list_tags_info()
+        self.assertEqual(["reviewed"], [tag.name() for tag in details])
+        tag = loaded.get_tag("reviewed")
+        self.assertEqual("review status", tag.comment())
+        self.assertEqual({"team": "finance"}, tag.properties())
+        self.assertEqual(tag.name(), details[0].name())
+        self.assertEqual(
+            [(self._object.type(), self._object.full_name())],
+            [
+                (obj.type(), obj.full_name())
+                for obj in tag.associated_objects().objects()
+            ],
+        )
+
+        renamed = self._models.alter_semantic_model(
+            self._ident, SemanticModelChange.rename("renamed")
+        ).supports_tags()
+        self.assertEqual(["reviewed"], renamed.list_tags())
+        self.assertEqual([], renamed.associate_tags([], ["reviewed"]))
+        self.assertEqual([], renamed.list_tags_info())
+
+    def test_imported_model_tag_context(self):
+        document = json.loads(self._models.export_ossie_document(self._ident, 
"json"))
+        document["name"] = "imported"
+        imported = self._models.import_ossie_document(
+            self._ident.namespace(), json.dumps(document), "json"
+        )
+        tags = imported.supports_tags()
+        self.assertEqual(["reviewed"], tags.associate_tags(["reviewed"], []))
+        self.assertEqual(["reviewed"], tags.list_tags())
+        self.assertEqual("reviewed", tags.get_tag("reviewed").name())
+        self.assertEqual([], self._model.supports_tags().list_tags())
+        self.assertEqual([], tags.associate_tags([], ["reviewed"]))
+
+    def test_tag_values(self):
+        tags = self._model.supports_tags()
+        self.assertEqual(
+            ["reviewed"], tags.assign_tags([{"name": "reviewed", "value": 
"yes"}])
+        )
+        self.assertEqual(["reviewed"], tags.list_tags())
+        self.assertEqual(
+            [], tags.assign_tags(tags_to_remove=[{"name": "reviewed", "value": 
"yes"}])
+        )
+        self.assertEqual([], tags.list_tags())
+
+    def test_tag_errors(self):
+        tags = self._model.supports_tags()
+        for name in ("missing", "reviewed"):
+            with self.subTest(tag=name), self.assertRaises(NoSuchTagException):
+                tags.get_tag(name)
+        tags.associate_tags(["reviewed"], [])
+        with self.assertRaises(TagAlreadyAssociatedException):
+            tags.associate_tags(["reviewed"], [])
+        self.assertEqual(["reviewed"], tags.list_tags())
+        # Missing tag additions/removals are ignored by the existing tag API.
+        self.assertEqual(["reviewed"], tags.associate_tags(["missing"], 
["absent"]))
+        tags.associate_tags([], ["reviewed"])
+        with self.assertRaises(NoSuchTagException):
+            tags.get_tag("reviewed")
+        self._models.drop_semantic_model(self._ident)
+        with self.assertRaises(NotFoundException):
+            tags.list_tags()
+
+    def test_tag_authorization(self):
+        reader = self._reader()
+        model_privilege = Privileges.allow("USE_SEMANTIC_MODEL")
+        self._grant(self._object, model_privilege)
+        tags = (
+            reader.load_catalog("iceberg")
+            .as_semantic_model_catalog()
+            .load_semantic_model(self._ident)
+            .supports_tags()
+        )
+        self._model.supports_tags().associate_tags(["reviewed", "hidden"], [])
+        self.assertEqual([], tags.list_tags())
+        self.assertEqual([], tags.list_tags_info())
+        with self.assertRaises(ForbiddenException):
+            tags.get_tag("reviewed")
+        with self.assertRaises(ForbiddenException):
+            tags.associate_tags([], ["reviewed"])
+
+        tag_object = SecurableObjects.of_tag("reviewed", [])
+        self._grant(tag_object, Privileges.allow("VIEW_TAG"))
+        self.assertEqual(["reviewed"], tags.list_tags())
+        self.assertEqual(["reviewed"], [tag.name() for tag in 
tags.list_tags_info()])
+        self.assertEqual("reviewed", tags.get_tag("reviewed").name())
+        with self.assertRaises(ForbiddenException):
+            tags.associate_tags([], ["reviewed"])
+        apply = Privileges.allow("APPLY_TAG")
+        self._grant(tag_object, apply)
+        tags.associate_tags([], ["reviewed"])
+        tags.associate_tags(["reviewed"], [])
+        self.assertEqual(["reviewed"], tags.list_tags())
+
+        denied = Privileges.deny("APPLY_TAG")
+        metalake = SecurableObjects.of_metalake(self._metalake_name, [])
+        self._grant(metalake, denied)
+        with self.assertRaises(ForbiddenException):
+            tags.associate_tags([], ["reviewed"])
+        self._revoke(metalake, denied)
+        self._revoke(tag_object, apply)
+        with self.assertRaises(ForbiddenException):
+            tags.associate_tags([], ["reviewed"])
+        self._grant(tag_object, apply)
+        self._revoke(self._object, model_privilege)
+        for operation in (
+            tags.list_tags,
+            tags.list_tags_info,
+            lambda: tags.get_tag("reviewed"),
+            lambda: tags.associate_tags([], ["reviewed"]),
+            lambda: tags.assign_tags(tags_to_remove=["reviewed"]),
+        ):
+            with self.subTest(operation=operation), self.assertRaises(
+                ForbiddenException
+            ):
+                operation()
+        self.assertCountEqual(
+            ["reviewed", "hidden"], self._model.supports_tags().list_tags()
+        )
+
+    def test_owner_assignment_and_retrieval(self):
+        owner = self._gravitino_client.get_owner(self._object)
+        self.assertEqual("anonymous", owner.name())
+        self.assertEqual(Owner.Type.USER, owner.type())
+        self._gravitino_client.add_user("model_owner")
+        self._gravitino_client.add_group("model_owners")
+        for name, owner_type in (
+            ("model_owner", Owner.Type.USER),
+            ("model_owners", Owner.Type.GROUP),
+        ):
+            with self.subTest(owner_type=owner_type):
+                self._gravitino_client.set_owner(self._object, name, 
owner_type)
+                owner = self._gravitino_client.get_owner(self._object)
+                self.assertEqual(name, owner.name())
+                self.assertEqual(owner_type, owner.type())
+
+    def test_owner_errors(self):
+        for owner_type in (Owner.Type.USER, Owner.Type.GROUP):
+            with self.subTest(owner_type=owner_type), self.assertRaises(
+                NotFoundException
+            ):
+                self._gravitino_client.set_owner(self._object, "missing", 
owner_type)
+        self.assertEqual(
+            "anonymous", self._gravitino_client.get_owner(self._object).name()
+        )
+        self._gravitino_client.add_user("model_owner")
+        self._models.drop_semantic_model(self._ident)
+        with self.assertRaises(NoSuchMetadataObjectException):
+            self._gravitino_client.get_owner(self._object)
+        with self.assertRaises(NoSuchMetadataObjectException):
+            self._gravitino_client.set_owner(
+                self._object, "model_owner", Owner.Type.USER
+            )
+
+    def test_owner_authorization(self):
+        reader = self._reader()
+        with self.assertRaises(ForbiddenException):
+            reader.get_owner(self._object)
+        with self.assertRaises(ForbiddenException):
+            reader.set_owner(self._object, "semantic_reader", Owner.Type.USER)
+        privilege = Privileges.allow("USE_SEMANTIC_MODEL")
+        self._grant(self._object, privilege)
+        self.assertEqual("anonymous", reader.get_owner(self._object).name())
+        with self.assertRaises(ForbiddenException):
+            reader.set_owner(self._object, "semantic_reader", Owner.Type.USER)
+        self._revoke(self._object, privilege)
+        self._gravitino_client.set_owner(
+            self._object, "semantic_reader", Owner.Type.USER
+        )
+        self.assertEqual("semantic_reader", 
reader.get_owner(self._object).name())
+        models = reader.load_catalog("iceberg").as_semantic_model_catalog()
+        self.assertEqual(
+            "owner edit",
+            models.alter_semantic_model(
+                self._ident, SemanticModelChange.update_comment("owner edit")
+            ).comment(),
+        )
+        self._gravitino_client.add_user("next_owner")
+        reader.set_owner(self._object, "next_owner", Owner.Type.USER)
+        self.assertEqual(
+            "next_owner", self._gravitino_client.get_owner(self._object).name()
+        )
+        with self.assertRaises(ForbiddenException):
+            reader.get_owner(self._object)
+        with self.assertRaises(ForbiddenException):
+            models.alter_semantic_model(
+                self._ident, SemanticModelChange.update_comment("denied")
+            )
+
+    def _reader(self):
+        self._gravitino_client.add_user("semantic_reader")
+        self._gravitino_client.create_role(
+            "reader",
+            securable_objects=[
+                SecurableObjects.of_catalog(
+                    "iceberg", [Privileges.allow("USE_CATALOG")]
+                ),
+                SecurableObjects.of_schema(
+                    SecurableObjects.of_catalog("iceberg", []),
+                    "semantic",
+                    [Privileges.allow("USE_SCHEMA")],
+                ),
+            ],
+        )
+        self._gravitino_client.grant_roles_to_user(["reader"], 
"semantic_reader")
+        with patch.dict(os.environ, {"GRAVITINO_USER": "semantic_reader"}):
+            reader = GravitinoClient(
+                "http://localhost:8090";,
+                self._metalake_name,
+                auth_data_provider=SimpleAuthProvider(),
+            )
+        self.addCleanup(reader.close)
+        return reader
+
+    def _grant(self, obj, privilege):
+        self._gravitino_client.grant_privileges_to_role("reader", obj, 
[privilege])
+
+    def _revoke(self, obj, privilege):
+        self._gravitino_client.revoke_privileges_from_role("reader", obj, 
[privilege])
diff --git 
a/clients/client-python/tests/unittests/test_semantic_model_tags_and_owner.py 
b/clients/client-python/tests/unittests/test_semantic_model_tags_and_owner.py
new file mode 100644
index 0000000000..cee1888e7c
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/test_semantic_model_tags_and_owner.py
@@ -0,0 +1,208 @@
+# 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.
+
+import unittest
+from unittest.mock import Mock
+
+from gravitino.api.authorization.owner import Owner
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.client.generic_semantic_model import GenericSemanticModel
+from gravitino.client.gravitino_metalake import GravitinoMetalake
+from gravitino.client.semantic_model_catalog_operations import (
+    SemanticModelCatalogOperations,
+)
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.metalake_dto import MetalakeDTO
+from gravitino.dto.responses.semantic_model_response import 
SemanticModelResponse
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from gravitino.exceptions.base import (
+    IllegalArgumentException,
+    UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.owner_error_handler import 
OWNER_ERROR_HANDLER
+from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient
+
+
+class TestSemanticModelTagsAndOwner(unittest.TestCase):
+    tag_path = 
"api/metalakes/lake%20%2F/objects/semantic_model/catalog%20%2F.schema%20%2F.returned%20%2F/tags"
+
+    def setUp(self):
+        self.rest = Mock(spec=HTTPClient)
+        self.namespace = Namespace.of("lake /", "catalog /", "schema /")
+        self.definition = SemanticModelDefinition(
+            [Dataset("orders", NameIdentifier.of("catalog", "schema", 
"orders"))]
+        )
+        self.dto = SemanticModelDTO(
+            _name="returned /",
+            
_definition=SemanticModelDefinitionDTO.from_definition(self.definition),
+            _audit=AuditDTO("creator", "2022-01-01T00:00:00Z"),
+        )
+
+    def test_default_tag_support_is_unsupported(self):
+        with self.assertRaises(UnsupportedOperationException):
+            self.dto.supports_tags()
+
+    def test_model_operations_preserve_tag_context(self):
+        catalog = SemanticModelCatalogOperations(
+            self.rest, Namespace.of("lake /"), "catalog /"
+        )
+        ident = NameIdentifier.of("schema /", "original")
+        response = Mock(body=SemanticModelResponse(0, self.dto).to_json())
+        for verb, operation in (
+            ("post", lambda: catalog.create_semantic_model(ident, "", 
self.definition)),
+            ("get", lambda: catalog.load_semantic_model(ident)),
+            (
+                "post",
+                lambda: catalog.import_ossie_document(
+                    Namespace.of("schema /"), '{"name":"returned /"}', "json"
+                ),
+            ),
+            (
+                "put",
+                lambda: catalog.alter_semantic_model(
+                    ident, SemanticModelChange.rename("returned /")
+                ),
+            ),
+        ):
+            with self.subTest(operation=verb):
+                self.rest.reset_mock()
+                getattr(self.rest, verb).return_value = response
+                model = operation()
+                self.assertIsInstance(model, GenericSemanticModel)
+                self.assertIsInstance(model.supports_tags(), SupportsTags)
+                self.assertIs(model, model.supports_tags())
+                self.rest.get.return_value = Mock(
+                    body='{"code":0,"names":["reviewed"]}'
+                )
+                self.assertEqual(["reviewed"], 
model.supports_tags().list_tags())
+                self.rest.get.assert_called_with(
+                    self.tag_path, params={}, error_handler=TAG_ERROR_HANDLER
+                )
+
+    def test_tag_operations_use_the_shared_client_and_encoded_identity(self):
+        tags = GenericSemanticModel(self.dto, self.rest, 
self.namespace).supports_tags()
+        self.rest.get.return_value = Mock(
+            
body='{"code":0,"tags":[{"name":"reviewed","comment":"","properties":{},'
+            
'"audit":{"creator":"creator","createTime":"2022-01-01T00:00:00Z"}}]}'
+        )
+        self.assertEqual(["reviewed"], [tag.name() for tag in 
tags.list_tags_info()])
+        self.rest.get.assert_called_with(
+            self.tag_path, params={"details": "true"}, 
error_handler=TAG_ERROR_HANDLER
+        )
+        self.rest.get.return_value = Mock(
+            body='{"code":0,"tag":{"name":"reviewed 
/","comment":"","properties":{},'
+            
'"audit":{"creator":"creator","createTime":"2022-01-01T00:00:00Z"}}}'
+        )
+        self.assertEqual("reviewed /", tags.get_tag("reviewed /").name())
+        self.rest.get.assert_called_with(
+            self.tag_path + "/reviewed%20%2F",
+            params={},
+            error_handler=TAG_ERROR_HANDLER,
+        )
+        self.rest.post.return_value = 
Mock(body='{"code":0,"names":["reviewed"]}')
+        self.assertEqual(["reviewed"], tags.associate_tags(["reviewed"], 
["old"]))
+        call = self.rest.post.call_args
+        self.assertEqual(self.tag_path, call.args[0])
+        self.assertEqual(
+            {"tagsToAdd": ["reviewed"], "tagsToRemove": ["old"]},
+            call.kwargs["json"].to_dict(),
+        )
+        self.assertIs(TAG_ERROR_HANDLER, call.kwargs["error_handler"])
+        self.rest.post.return_value = Mock(body='{"code":0,"names":[]}')
+        self.assertEqual([], tags.associate_tags([], ["reviewed"]))
+        self.assertEqual(
+            {"tagsToAdd": [], "tagsToRemove": ["reviewed"]},
+            self.rest.post.call_args.kwargs["json"].to_dict(),
+        )
+
+    def test_tag_value_assignment(self):
+        tags = GenericSemanticModel(self.dto, self.rest, 
self.namespace).supports_tags()
+        self.rest.post.return_value = 
Mock(body='{"code":0,"names":["reviewed"]}')
+        self.assertEqual(
+            ["reviewed"], tags.assign_tags([{"name": "reviewed", "value": 
"yes"}])
+        )
+        call = self.rest.post.call_args
+        self.assertEqual(self.tag_path, call.args[0])
+        self.assertEqual(
+            {"tagsToAdd": [{"name": "reviewed", "value": "yes"}], 
"tagsToRemove": None},
+            call.kwargs["json"].to_dict(),
+        )
+        self.assertEqual(
+            "application/vnd.gravitino.v2+json", 
call.kwargs["headers"]["Content-Type"]
+        )
+        self.assertIs(TAG_ERROR_HANDLER, call.kwargs["error_handler"])
+        self.rest.post.return_value = Mock(body='{"code":0,"names":[]}')
+        self.assertEqual([], tags.assign_tags(tags_to_remove=["reviewed"]))
+        self.assertEqual(
+            {"tagsToAdd": None, "tagsToRemove": [{"name": "reviewed", "value": 
None}]},
+            self.rest.post.call_args.kwargs["json"].to_dict(),
+        )
+
+    def test_invalid_tag_names_do_not_send_requests(self):
+        tags = GenericSemanticModel(self.dto, self.rest, 
self.namespace).supports_tags()
+        for name in (None, "", " "):
+            with self.subTest(name=name), 
self.assertRaises(IllegalArgumentException):
+                tags.get_tag(name)
+        self.rest.get.assert_not_called()
+
+    def test_client_context_does_not_change_value_equality(self):
+        model = GenericSemanticModel(self.dto, self.rest, self.namespace)
+        other = GenericSemanticModel(self.dto, Mock(spec=HTTPClient), 
self.namespace)
+        self.assertEqual(model, other)
+        self.assertEqual(hash(model), hash(other))
+
+    def test_generic_owner_api_uses_semantic_model_type(self):
+        metalake = GravitinoMetalake(
+            MetalakeDTO("lake /", "", {}, AuditDTO("creator")), self.rest
+        )
+        obj = MetadataObjects.of(
+            ["catalog /", "schema /", "returned /"], 
MetadataObject.Type.SEMANTIC_MODEL
+        )
+        path = 
"api/metalakes/lake%20%2F/owners/semantic_model/catalog%20%2F.schema%20%2F.returned%20%2F"
+        for owner_type in (Owner.Type.USER, Owner.Type.GROUP):
+            with self.subTest(owner_type=owner_type):
+                self.rest.put.return_value = Mock(body='{"code":0,"set":true}')
+                metalake.set_owner(obj, "finance", owner_type)
+                call = self.rest.put.call_args
+                self.assertEqual(path, call.args[0])
+                self.assertEqual(
+                    {"name": "finance", "type": owner_type},
+                    call.kwargs["json"].to_dict(),
+                )
+                self.assertIs(OWNER_ERROR_HANDLER, 
call.kwargs["error_handler"])
+                self.rest.get.return_value = Mock(
+                    body='{"code":0,"owner":{"name":"finance","type":"'
+                    + owner_type.name
+                    + '"}}'
+                )
+                owner = metalake.get_owner(obj)
+                self.assertEqual("finance", owner.name())
+                self.assertEqual(owner_type, owner.type())
+                self.rest.get.assert_called_with(
+                    path, error_handler=OWNER_ERROR_HANDLER
+                )
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
index ca6fdb448a..51f3b13adf 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java
@@ -172,7 +172,7 @@ public class AuthorizationExpressionConverter {
     return expression.replaceAll(
         AuthorizationExpressionConstants.CAN_ACCESS_METADATA,
         """
-              ( entityType == 'CATALOG' && (%s)) ||
+              (( entityType == 'CATALOG' && (%s)) ||
               ( entityType == 'SCHEMA' && (%s)) ||
               ( entityType == 'TABLE' && (%s)) ||
               ( entityType == 'VIEW' && (%s)) ||
@@ -187,7 +187,7 @@ public class AuthorizationExpressionConverter {
               ( entityType == 'JOB_TEMPLATE' && (%s)) ||
               ( entityType == 'COLUMN' && (%s)) ||
               ( entityType == 'FUNCTION' && (%s)) ||
-              ( entityType == 'SEMANTIC_MODEL' && (%s))
+              ( entityType == 'SEMANTIC_MODEL' && (%s)))
               """
             .formatted(
                 LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestMetadataObjectTagAuthorizationExpression.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestMetadataObjectTagAuthorizationExpression.java
new file mode 100644
index 0000000000..5ce2430fd1
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/authorization/TestMetadataObjectTagAuthorizationExpression.java
@@ -0,0 +1,69 @@
+/*
+ * 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.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Method;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConverter;
+import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
+import org.apache.gravitino.server.web.rest.TagOperations;
+import org.junit.jupiter.api.Test;
+
+/** Tests that metadata access cannot bypass tag read authorization. */
+public class TestMetadataObjectTagAuthorizationExpression {
+
+  /** Verify tag reads require both metadata access and tag permission on both 
REST routes. */
+  @Test
+  public void testGetTagRequiresTagPermission() throws Exception {
+    for (Class<?> resource :
+        new Class<?>[] {MetadataObjectTagOperations.class, 
TagOperations.class}) {
+      Method method =
+          resource.getMethod(
+              "getTagForObject", String.class, String.class, String.class, 
String.class);
+      String expression = 
method.getAnnotation(AuthorizationExpression.class).expression();
+      for (String type : new String[] {"TABLE", "SEMANTIC_MODEL"}) {
+        MockAuthorizationExpressionEvaluator evaluator =
+            new MockAuthorizationExpressionEvaluator(
+                
AuthorizationExpressionConverter.replaceCanAccessMetadataPrivilege(expression)
+                    .replace("entityType", "'" + type + "'"));
+        String access =
+            type.equals("TABLE") ? "TABLE::SELECT_TABLE" : 
"SEMANTIC_MODEL::USE_SEMANTIC_MODEL";
+        ImmutableSet<String> metadataAccess =
+            ImmutableSet.of("CATALOG::USE_CATALOG", "SCHEMA::USE_SCHEMA", 
access);
+        assertFalse(evaluator.getResult(metadataAccess), 
resource.getSimpleName() + ": " + type);
+        assertFalse(evaluator.getResult(ImmutableSet.of("TAG::VIEW_TAG")));
+        for (String tagPrivilege : new String[] {"TAG::VIEW_TAG", 
"TAG::APPLY_TAG", "TAG::OWNER"}) {
+          assertTrue(
+              evaluator.getResult(
+                  
ImmutableSet.<String>builder().addAll(metadataAccess).add(tagPrivilege).build()));
+        }
+        assertFalse(
+            evaluator.getResult(
+                ImmutableSet.<String>builder()
+                    .addAll(metadataAccess)
+                    .add("TAG::VIEW_TAG", "TAG::DENY_VIEW_TAG")
+                    .build()));
+      }
+    }
+  }
+}

Reply via email to