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