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 0817563e8b [#13808] fix(client-python): Complete Semantic Model
authorization mappings (#13793)
0817563e8b is described below
commit 0817563e8b418a2399eb83ef5d1425235b88520e
Author: Akshay Thorat <[email protected]>
AuthorDate: Mon Oct 12 21:11:56 2026 -0700
[#13808] fix(client-python): Complete Semantic Model authorization mappings
(#13793)
### What changes were proposed in this pull request?
Add `SEMANTIC_MODEL` metadata parsing, schema parent mapping, and
securable-object construction to the Python SDK. Add
`CREATE_SEMANTIC_MODEL`, `USE_SEMANTIC_MODEL`, and
`MODIFY_SEMANTIC_MODEL` with Java-compatible bit values and binding
rules, including `MANAGE_GRANTS` on Semantic Models.
The existing DTO converters and grant/revoke paths now round-trip
Semantic Model types and privileges. Align SDK privilege hashing with
equal response DTOs so round-tripped privileges deduplicate correctly.
Add unit coverage and live-server integration tests for public SDK
grant/revoke, role creation/retrieval and bindings, list filtering,
create/load/alter access, parent access prerequisites, revocation, and
explicit deny precedence. Assert that modify permission does not
authorize drop.
### Why are the changes needed?
The Python SDK rejects Semantic Model types and privileges before it can
call the existing server authorization APIs.
Fixes #13808.
Part of #12209. Based directly on upstream/main at `b837e07a9`. #12860
is already merged, including its REST-envelope changes. There is no
dependency on the closed, unmerged #13768 or on the separate tag/owner
and Ossie work.
### Does this PR introduce _any_ user-facing change?
Yes. Python callers can construct `MetadataObject.Type.SEMANTIC_MODEL`,
use `SecurableObjects.of_semantic_model`, and parse/grant/revoke the
three Semantic Model privileges. Server authorization behavior is
unchanged. Production changes are confined to four Python SDK files.
### How was this patch tested?
- Before the fix, all four new integration tests reached the live server
fixture and failed because the SDK rejected `CREATE_SEMANTIC_MODEL`,
`USE_SEMANTIC_MODEL`, or `MODIFY_SEMANTIC_MODEL`.
- Python 3.11 unit suite: **1,218 passed** (`python -m unittest discover
-s clients/client-python/tests/unittests`, with
`PYTHONPATH=clients/client-python`).
- Current-main Java server with authorization enabled and a local
Iceberg memory catalog: **11 passed**, comprising all four new Semantic
Model tests and seven existing role-management tests (`python -m
unittest tests.integration.test_semantic_model_authorization
tests.integration.test_role_management`, with `GRAVITINO_HOME` set to
the test server, `START_EXTERNAL_GRAVITINO=true`, and
`PYTHONPATH=clients/client-python`).
- Changed-file Pylint: **10.00/10**. Black check and `git diff --check`:
passed.
- `./gradlew spotlessApply -PskipWeb`: passed. Server and Iceberg
catalog built from current main using Java 17.
All newly added integration tests ran. The full repository test suite,
Docker/cloud integration suites, and Python 3.10/3.12 matrix were not
run.
---
.../gravitino/api/authorization/privileges.py | 107 ++++++++-
.../api/authorization/securable_objects.py | 24 ++
.../client-python/gravitino/api/metadata_object.py | 3 +
.../gravitino/api/metadata_objects.py | 4 +-
.../test_semantic_model_authorization.py | 266 +++++++++++++++++++++
.../unittests/authorization/test_privileges.py | 12 +
.../test_semantic_model_authorization.py | 169 +++++++++++++
.../tests/unittests/test_metadata_objects.py | 2 +-
8 files changed, 583 insertions(+), 4 deletions(-)
diff --git a/clients/client-python/gravitino/api/authorization/privileges.py
b/clients/client-python/gravitino/api/authorization/privileges.py
index d76be10510..c368167a46 100644
--- a/clients/client-python/gravitino/api/authorization/privileges.py
+++ b/clients/client-python/gravitino/api/authorization/privileges.py
@@ -207,6 +207,15 @@ class Privilege(ABC):
INCLUDE_CREDENTIAL_SECRETS = (0, 1 << 38)
"""With USE_SECRETS, include cloud credential secrets in the
getSecrets result."""
+ CREATE_SEMANTIC_MODEL = (0, 1 << 39)
+ """The privilege to create a Semantic Model."""
+
+ USE_SEMANTIC_MODEL = (0, 1 << 40)
+ """The privilege to discover a Semantic Model and load its
definition."""
+
+ MODIFY_SEMANTIC_MODEL = (0, 1 << 41)
+ """The privilege to rename a Semantic Model or alter its definition
and metadata."""
+
def __init__(self, high_bits: int, low_bits: int) -> None:
"""
Initialize the Name with the high and low bits.
@@ -277,7 +286,7 @@ class GenericPrivilege(Privilege):
return f"{self._condition.name} {self._name.name.lower().replace('_',
' ')}"
def __hash__(self) -> int:
- return hash((self._condition, self._name))
+ return hash((self._name, self._condition))
def __eq__(self, value: object) -> bool:
if not isinstance(value, Privilege):
@@ -947,7 +956,7 @@ class ManageGrants(GenericPrivilege):
The privilege to grant or revoke privileges on securable objects. If bound
on the metalake,
we can grant or revoke the role for users or groups. Unlike most
privileges,
this can be bound at any level of the object hierarchy — METALAKE,
- CATALOG, SCHEMA, TABLE, VIEW, TOPIC, FILESET, FUNCTION, or MODEL.
+ CATALOG, SCHEMA, TABLE, VIEW, TOPIC, FILESET, FUNCTION, MODEL, or
SEMANTIC_MODEL.
A grant at a parent level implicitly covers all descendants within it.
"""
@@ -1657,6 +1666,90 @@ class ModifyFunction(GenericPrivilege):
return obj_type in Privileges.FUNCTION_SUPPORTED_TYPES
+class CreateSemanticModel(GenericPrivilege):
+ """The privilege to create a Semantic Model."""
+
+ _ALLOW_INSTANCE: Optional[CreateSemanticModel] = None
+ _DENY_INSTANCE: Optional[CreateSemanticModel] = None
+
+ @staticmethod
+ def allow() -> CreateSemanticModel:
+ """Return the privilege with the allow condition."""
+ if CreateSemanticModel._ALLOW_INSTANCE is None:
+ CreateSemanticModel._ALLOW_INSTANCE = CreateSemanticModel(
+ Privilege.Condition.ALLOW, Privilege.Name.CREATE_SEMANTIC_MODEL
+ )
+ return CreateSemanticModel._ALLOW_INSTANCE
+
+ @staticmethod
+ def deny() -> CreateSemanticModel:
+ """Return the privilege with the deny condition."""
+ if CreateSemanticModel._DENY_INSTANCE is None:
+ CreateSemanticModel._DENY_INSTANCE = CreateSemanticModel(
+ Privilege.Condition.DENY, Privilege.Name.CREATE_SEMANTIC_MODEL
+ )
+ return CreateSemanticModel._DENY_INSTANCE
+
+ def can_bind_to(self, obj_type: MetadataObject.Type) -> bool:
+ return obj_type in Privileges.SCHEMA_SUPPORTED_TYPES
+
+
+class UseSemanticModel(GenericPrivilege):
+ """The privilege to discover a Semantic Model and load its definition."""
+
+ _ALLOW_INSTANCE: Optional[UseSemanticModel] = None
+ _DENY_INSTANCE: Optional[UseSemanticModel] = None
+
+ @staticmethod
+ def allow() -> UseSemanticModel:
+ """Return the privilege with the allow condition."""
+ if UseSemanticModel._ALLOW_INSTANCE is None:
+ UseSemanticModel._ALLOW_INSTANCE = UseSemanticModel(
+ Privilege.Condition.ALLOW, Privilege.Name.USE_SEMANTIC_MODEL
+ )
+ return UseSemanticModel._ALLOW_INSTANCE
+
+ @staticmethod
+ def deny() -> UseSemanticModel:
+ """Return the privilege with the deny condition."""
+ if UseSemanticModel._DENY_INSTANCE is None:
+ UseSemanticModel._DENY_INSTANCE = UseSemanticModel(
+ Privilege.Condition.DENY, Privilege.Name.USE_SEMANTIC_MODEL
+ )
+ return UseSemanticModel._DENY_INSTANCE
+
+ def can_bind_to(self, obj_type: MetadataObject.Type) -> bool:
+ return obj_type in Privileges.SEMANTIC_MODEL_SUPPORTED_TYPES
+
+
+class ModifySemanticModel(GenericPrivilege):
+ """The privilege to rename a Semantic Model or alter its definition and
metadata."""
+
+ _ALLOW_INSTANCE: Optional[ModifySemanticModel] = None
+ _DENY_INSTANCE: Optional[ModifySemanticModel] = None
+
+ @staticmethod
+ def allow() -> ModifySemanticModel:
+ """Return the privilege with the allow condition."""
+ if ModifySemanticModel._ALLOW_INSTANCE is None:
+ ModifySemanticModel._ALLOW_INSTANCE = ModifySemanticModel(
+ Privilege.Condition.ALLOW, Privilege.Name.MODIFY_SEMANTIC_MODEL
+ )
+ return ModifySemanticModel._ALLOW_INSTANCE
+
+ @staticmethod
+ def deny() -> ModifySemanticModel:
+ """Return the privilege with the deny condition."""
+ if ModifySemanticModel._DENY_INSTANCE is None:
+ ModifySemanticModel._DENY_INSTANCE = ModifySemanticModel(
+ Privilege.Condition.DENY, Privilege.Name.MODIFY_SEMANTIC_MODEL
+ )
+ return ModifySemanticModel._DENY_INSTANCE
+
+ def can_bind_to(self, obj_type: MetadataObject.Type) -> bool:
+ return obj_type in Privileges.SEMANTIC_MODEL_SUPPORTED_TYPES
+
+
class ViewSecretProviders(GenericPrivilege):
"""The privilege to list configured secrets providers."""
@@ -1772,6 +1865,9 @@ class Privileges:
Privilege.Name.REGISTER_FUNCTION: RegisterFunction,
Privilege.Name.EXECUTE_FUNCTION: ExecuteFunction,
Privilege.Name.MODIFY_FUNCTION: ModifyFunction,
+ Privilege.Name.CREATE_SEMANTIC_MODEL: CreateSemanticModel,
+ Privilege.Name.USE_SEMANTIC_MODEL: UseSemanticModel,
+ Privilege.Name.MODIFY_SEMANTIC_MODEL: ModifySemanticModel,
Privilege.Name.USE_SECRETS: UseSecrets,
Privilege.Name.INCLUDE_CREDENTIAL_SECRETS: IncludeCredentialSecrets,
}
@@ -1812,6 +1908,12 @@ class Privileges:
MetadataObject.Type.SCHEMA,
MetadataObject.Type.FUNCTION,
}
+ SEMANTIC_MODEL_SUPPORTED_TYPES = {
+ MetadataObject.Type.METALAKE,
+ MetadataObject.Type.CATALOG,
+ MetadataObject.Type.SCHEMA,
+ MetadataObject.Type.SEMANTIC_MODEL,
+ }
SECRET_SUPPORTED_TYPES = {
MetadataObject.Type.METALAKE,
MetadataObject.Type.CATALOG,
@@ -1832,6 +1934,7 @@ class Privileges:
MetadataObject.Type.MODEL,
MetadataObject.Type.VIEW,
MetadataObject.Type.FUNCTION,
+ MetadataObject.Type.SEMANTIC_MODEL,
}
SCHEMA_SUPPORTED_TYPES = {
MetadataObject.Type.METALAKE,
diff --git
a/clients/client-python/gravitino/api/authorization/securable_objects.py
b/clients/client-python/gravitino/api/authorization/securable_objects.py
index 4c00463b70..f82430c921 100644
--- a/clients/client-python/gravitino/api/authorization/securable_objects.py
+++ b/clients/client-python/gravitino/api/authorization/securable_objects.py
@@ -41,6 +41,7 @@ class SecurableObject(MetadataObject, ABC):
- FILESET
- TOPIC
- FUNCTION
+ - SEMANTIC_MODEL
- METALAKE
Use the helper class `SecurableObjects` to construct the securable object
you need.
@@ -352,6 +353,29 @@ class SecurableObjects:
privileges,
)
+ @staticmethod
+ def of_semantic_model(
+ schema: SecurableObject,
+ semantic_model: str,
+ privileges: list[Privilege],
+ ) -> SecurableObjectImpl:
+ """Create a Semantic Model securable object.
+
+ Args:
+ schema (SecurableObject): The schema securable object.
+ semantic_model (str): The Semantic Model name.
+ privileges (list[Privilege]): The privileges of the Semantic Model.
+
+ Returns:
+ SecurableObjectImpl: The created Semantic Model securable object.
+ """
+ names = [*schema.full_name().split("."), semantic_model]
+ return SecurableObjects.of(
+ MetadataObject.Type.SEMANTIC_MODEL,
+ names,
+ privileges,
+ )
+
@staticmethod
def of_function(
schema: SecurableObject,
diff --git a/clients/client-python/gravitino/api/metadata_object.py
b/clients/client-python/gravitino/api/metadata_object.py
index a46a9ba44a..a68153ffa1 100644
--- a/clients/client-python/gravitino/api/metadata_object.py
+++ b/clients/client-python/gravitino/api/metadata_object.py
@@ -87,6 +87,9 @@ class MetadataObject(ABC):
JOB_TEMPLATE = "job_template"
"""A job template represents a reusable template for creating jobs in
Gravitino."""
+ SEMANTIC_MODEL = "semantic_model"
+ """A Semantic Model defines business semantics over catalog data."""
+
FUNCTION = "function"
"""A function represents a user-defined function registered in
Gravitino."""
diff --git a/clients/client-python/gravitino/api/metadata_objects.py
b/clients/client-python/gravitino/api/metadata_objects.py
index c84aacc1b6..e98f5d786b 100644
--- a/clients/client-python/gravitino/api/metadata_objects.py
+++ b/clients/client-python/gravitino/api/metadata_objects.py
@@ -49,6 +49,7 @@ class MetadataObjects:
MetadataObject.Type.MODEL,
MetadataObject.Type.VIEW,
MetadataObject.Type.FUNCTION,
+ MetadataObject.Type.SEMANTIC_MODEL,
},
4: {MetadataObject.Type.COLUMN},
}
@@ -94,7 +95,7 @@ class MetadataObjects:
)
Precondition.check_argument(
names_len != 3 or type_ in MetadataObjects._NAMES_LEN_CONDS[3],
- "If the length of names is 3, it must be FILESET, TABLE, TOPIC,
MODEL, VIEW or FUNCTION",
+ "If the length of names is 3, it must be FILESET, TABLE, TOPIC,
MODEL, VIEW, FUNCTION or SEMANTIC_MODEL",
)
Precondition.check_argument(
names_len != 4 or type_ in MetadataObjects._NAMES_LEN_CONDS[4],
@@ -141,6 +142,7 @@ class MetadataObjects:
MetadataObject.Type.TOPIC,
MetadataObject.Type.MODEL,
MetadataObject.Type.FUNCTION,
+ MetadataObject.Type.SEMANTIC_MODEL,
MetadataObject.Type.VIEW,
}:
parent_type = MetadataObject.Type.SCHEMA
diff --git
a/clients/client-python/tests/integration/test_semantic_model_authorization.py
b/clients/client-python/tests/integration/test_semantic_model_authorization.py
new file mode 100644
index 0000000000..e12c7ec13c
--- /dev/null
+++
b/clients/client-python/tests/integration/test_semantic_model_authorization.py
@@ -0,0 +1,266 @@
+# 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 os
+import shutil
+import tempfile
+import uuid
+from unittest.mock import patch
+
+from gravitino import Catalog, GravitinoClient, NameIdentifier
+from gravitino.api.authorization.privileges import Privileges
+from gravitino.api.authorization.securable_objects import SecurableObjects
+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.client.metadata_object_role_operations import (
+ MetadataObjectRoleOperations,
+)
+from gravitino.exceptions.base import ForbiddenException
+from gravitino.namespace import Namespace
+from tests.integration.integration_test_env import
AuthorizationIntegrationTestEnv
+
+
+class TestSemanticModelAuthorization(AuthorizationIntegrationTestEnv):
+ """Exercise SDK privileges against a server with authorization enabled."""
+
+ def setUp(self):
+ self._metalake_name = f"semantic_auth_{uuid.uuid4().hex[:8]}"
+ super().setUp()
+ warehouse = tempfile.mkdtemp(prefix="semantic_auth_")
+ 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={},
+ )
+ self._definition = SemanticModelDefinition(
+ [Dataset("orders", NameIdentifier.of("iceberg", "semantic",
"orders"))]
+ )
+ models = catalog.as_semantic_model_catalog()
+ self._ident = NameIdentifier.of("semantic", "sales")
+ models.create_semantic_model(self._ident, "sales", self._definition,
{})
+ models.create_semantic_model(
+ NameIdentifier.of("semantic", "hidden"), "hidden",
self._definition, {}
+ )
+ self._schema = SecurableObjects.of_schema(
+ SecurableObjects.of_catalog("iceberg", []), "semantic", []
+ )
+ 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"),
Privileges.allow("SELECT_TABLE")],
+ ),
+ ],
+ )
+ 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)
+ self._reader_models =
reader.load_catalog("iceberg").as_semantic_model_catalog()
+
+ def tearDown(self):
+ self._gravitino_client.close()
+
+ def test_grant_revoke_use_and_role_round_trip(self):
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.load_semantic_model(self._ident)
+ self.assertEqual(
+ [],
self._reader_models.list_semantic_models(Namespace.of("semantic"))
+ )
+
+ privilege = Privileges.allow("USE_SEMANTIC_MODEL")
+ model = SecurableObjects.of_semantic_model(self._schema, "sales", [])
+ role = self._gravitino_client.grant_privileges_to_role(
+ "reader", model, [privilege]
+ )
+ self._assert_model_privileges(role, model, [privilege])
+ self._assert_model_privileges(
+ self._gravitino_client.get_role("reader"), model, [privilege]
+ )
+ bindings = MetadataObjectRoleOperations(
+ self._metalake_name,
+ model,
+ self._gravitino_client.get_metalake().rest_client,
+ )
+ self.assertEqual(["reader"], bindings.list_binding_role_names())
+ self.assertEqual(
+ "sales",
self._reader_models.load_semantic_model(self._ident).name()
+ )
+ self.assertEqual(
+ ["sales"],
+ [
+ ident.name()
+ for ident in self._reader_models.list_semantic_models(
+ Namespace.of("semantic")
+ )
+ ],
+ )
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.alter_semantic_model(
+ self._ident, SemanticModelChange.update_comment("forbidden")
+ )
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.drop_semantic_model(self._ident)
+
+ role = self._gravitino_client.revoke_privileges_from_role(
+ "reader", model, [privilege]
+ )
+ self._assert_model_privileges(role, model, [])
+ self.assertEqual([], bindings.list_binding_role_names())
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.load_semantic_model(self._ident)
+ self.assertEqual(
+ [],
self._reader_models.list_semantic_models(Namespace.of("semantic"))
+ )
+
+ def test_create_requires_privilege_and_parent_access(self):
+ ident = NameIdentifier.of("semantic", "created")
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.create_semantic_model(ident, "",
self._definition, {})
+ privilege = Privileges.allow("CREATE_SEMANTIC_MODEL")
+ self._gravitino_client.grant_privileges_to_role(
+ "reader", self._schema, [privilege]
+ )
+ for obj, required in (
+ (
+ SecurableObjects.of_catalog("iceberg", []),
+ Privileges.allow("USE_CATALOG"),
+ ),
+ (self._schema, Privileges.allow("USE_SCHEMA")),
+ ):
+ with self.subTest(required=required.name()):
+ self._gravitino_client.revoke_privileges_from_role(
+ "reader", obj, [required]
+ )
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.create_semantic_model(
+ ident, "", self._definition, {}
+ )
+ self._gravitino_client.grant_privileges_to_role(
+ "reader", obj, [required]
+ )
+ created = self._reader_models.create_semantic_model(
+ ident, "", self._definition, {}
+ )
+ self.assertEqual("created", created.name())
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.load_semantic_model(self._ident)
+ self._gravitino_client.revoke_privileges_from_role(
+ "reader", self._schema, [privilege]
+ )
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.create_semantic_model(
+ NameIdentifier.of("semantic", "after_revoke"), "",
self._definition, {}
+ )
+
+ def test_modify_allows_load_and_alter_but_not_drop(self):
+ privilege = Privileges.allow("MODIFY_SEMANTIC_MODEL")
+ model = SecurableObjects.of_semantic_model(self._schema, "sales",
[privilege])
+ role = self._gravitino_client.create_role("editor",
securable_objects=[model])
+ self._assert_model_privileges(role, model, [privilege])
+ self._gravitino_client.grant_roles_to_user(["editor"],
"semantic_reader")
+ self.assertEqual(
+ "sales",
self._reader_models.load_semantic_model(self._ident).name()
+ )
+ updated = self._reader_models.alter_semantic_model(
+ self._ident, SemanticModelChange.update_comment("updated")
+ )
+ self.assertEqual("updated", updated.comment())
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.drop_semantic_model(self._ident)
+ denied = Privileges.deny("MODIFY_SEMANTIC_MODEL")
+ self._gravitino_client.grant_privileges_to_role("reader", model,
[denied])
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.alter_semantic_model(
+ self._ident, SemanticModelChange.update_comment("denied")
+ )
+ self._gravitino_client.revoke_privileges_from_role("reader", model,
[denied])
+ self.assertEqual(
+ "updated",
self._reader_models.load_semantic_model(self._ident).comment()
+ )
+ self._gravitino_client.revoke_privileges_from_role("editor", model,
[privilege])
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.alter_semantic_model(
+ self._ident, SemanticModelChange.update_comment("revoked")
+ )
+
+ def test_explicit_deny_overrides_inherited_use(self):
+ allowed = Privileges.allow("USE_SEMANTIC_MODEL")
+ denied = Privileges.deny("USE_SEMANTIC_MODEL")
+ model = SecurableObjects.of_semantic_model(self._schema, "sales", [])
+ self._gravitino_client.grant_privileges_to_role(
+ "reader", self._schema, [allowed]
+ )
+ self.assertEqual(
+ "sales",
self._reader_models.load_semantic_model(self._ident).name()
+ )
+ role = self._gravitino_client.grant_privileges_to_role(
+ "reader", model, [denied]
+ )
+ self._assert_model_privileges(role, model, [denied])
+ with self.assertRaises(ForbiddenException):
+ self._reader_models.load_semantic_model(self._ident)
+ self.assertEqual(
+ ["hidden"],
+ [
+ ident.name()
+ for ident in self._reader_models.list_semantic_models(
+ Namespace.of("semantic")
+ )
+ ],
+ )
+ self._gravitino_client.revoke_privileges_from_role("reader", model,
[denied])
+ self.assertEqual(
+ "sales",
self._reader_models.load_semantic_model(self._ident).name()
+ )
+
+ def _assert_model_privileges(self, role, model, expected):
+ actual = [
+ privilege
+ for obj in role.securable_objects()
+ if obj.type() == model.type() and obj.full_name() ==
model.full_name()
+ for privilege in obj.privileges()
+ ]
+ self.assertCountEqual(expected, actual)
diff --git
a/clients/client-python/tests/unittests/authorization/test_privileges.py
b/clients/client-python/tests/unittests/authorization/test_privileges.py
index e50d137035..dee4aec1de 100644
--- a/clients/client-python/tests/unittests/authorization/test_privileges.py
+++ b/clients/client-python/tests/unittests/authorization/test_privileges.py
@@ -58,6 +58,7 @@ from gravitino.api.authorization.privileges import (
WriteFileset,
)
from gravitino.api.metadata_object import MetadataObject
+from gravitino.dto.authorization.privilege_dto import PrivilegeDTO
from gravitino.exceptions.base import IllegalArgumentException
@@ -76,6 +77,17 @@ class TestPrivileges(unittest.TestCase):
self.assertEqual(Privilege.Condition.DENY,
deny_privilege.condition())
self.assertIs(deny_privilege, Privileges.deny(name.name))
+ def test_privilege_dto_equality_and_hash(self) -> None:
+ for name in Privilege.Name:
+ for factory in (Privileges.allow, Privileges.deny):
+ with self.subTest(privilege=name.name, factory=factory):
+ privilege = factory(name.name)
+ dto = PrivilegeDTO(privilege.name(), privilege.condition())
+ self.assertEqual(privilege, dto)
+ self.assertEqual(dto, privilege)
+ self.assertEqual(hash(privilege), hash(dto))
+ self.assertEqual(1, len({privilege, dto}))
+
def test_allow_raises_illegal_argument_exception_for_unknown_privilege(
self,
) -> None:
diff --git
a/clients/client-python/tests/unittests/authorization/test_semantic_model_authorization.py
b/clients/client-python/tests/unittests/authorization/test_semantic_model_authorization.py
new file mode 100644
index 0000000000..bfe5b3dbe6
--- /dev/null
+++
b/clients/client-python/tests/unittests/authorization/test_semantic_model_authorization.py
@@ -0,0 +1,169 @@
+# 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 unittest
+
+from gravitino.api.authorization.privileges import Privilege, 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.client.dto_converters import DTOConverters
+from gravitino.dto.authorization.privilege_dto import PrivilegeDTO
+from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO
+from gravitino.dto.metadata_object_dto import MetadataObjectDTO
+from gravitino.dto.requests.privilege_grant_request import
PrivilegeGrantRequest
+from gravitino.dto.requests.privilege_revoke_request import
PrivilegeRevokeRequest
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+class TestSemanticModelAuthorization(unittest.TestCase):
+ def test_metadata_mapping_and_parent(self):
+ model_type = MetadataObject.Type.SEMANTIC_MODEL
+ self.assertEqual("semantic_model", model_type.value)
+ model = MetadataObjects.parse("catalog.schema.sales", model_type)
+ self.assertEqual("sales", model.name())
+ self.assertEqual("catalog.schema", model.parent())
+ self.assertEqual(model_type, model.type())
+ self.assertEqual(
+ model, MetadataObjects.of(["catalog", "schema", "sales"],
model_type)
+ )
+ self.assertEqual(
+ model, MetadataObjects.of("sales", model_type, "catalog.schema")
+ )
+ schema = MetadataObjects.parent(model)
+ self.assertEqual(MetadataObject.Type.SCHEMA, schema.type())
+ self.assertEqual("catalog.schema", schema.full_name())
+ self.assertEqual(
+ MetadataObject.Type.CATALOG, MetadataObjects.parent(schema).type()
+ )
+ dto = MetadataObjectDTO.from_json(
+ '{"type":"semantic_model","fullName":"catalog.schema.sales"}'
+ )
+ self.assertEqual(model_type, dto.type())
+ self.assertEqual(model.full_name(), dto.full_name())
+ self.assertEqual("semantic_model", json.loads(dto.to_json())["type"])
+
+ def test_invalid_semantic_model_names(self):
+ for name in ("", "sales", "schema.sales", "a.b.c.d",
"catalog.*.sales"):
+ with self.subTest(name=name),
self.assertRaises(IllegalArgumentException):
+ MetadataObjects.parse(name, MetadataObject.Type.SEMANTIC_MODEL)
+
+ def test_privileges_match_java_contract(self):
+ parents = {
+ MetadataObject.Type.METALAKE,
+ MetadataObject.Type.CATALOG,
+ MetadataObject.Type.SCHEMA,
+ }
+ for name, bit in (
+ ("CREATE_SEMANTIC_MODEL", 39),
+ ("USE_SEMANTIC_MODEL", 40),
+ ("MODIFY_SEMANTIC_MODEL", 41),
+ ):
+ expected_types = set(parents)
+ if name != "CREATE_SEMANTIC_MODEL":
+ expected_types.add(MetadataObject.Type.SEMANTIC_MODEL)
+ for factory, condition in (
+ (Privileges.allow, Privilege.Condition.ALLOW),
+ (Privileges.deny, Privilege.Condition.DENY),
+ ):
+ with self.subTest(name=name, condition=condition):
+ privilege = factory(name)
+ self.assertIs(privilege, factory(name))
+ self.assertEqual(name, privilege.name().name)
+ self.assertEqual(0, privilege.name().high_bits)
+ self.assertEqual(1 << bit, privilege.name().low_bits)
+ self.assertEqual(condition, privilege.condition())
+ self.assertEqual(
+ f"{condition.value} {name.lower().replace('_', ' ')}",
+ privilege.simple_string(),
+ )
+ dto = DTOConverters.to_privilege_dto(privilege)
+ wire = {"name": name.lower(), "condition":
condition.value.lower()}
+ self.assertEqual(wire, json.loads(dto.to_json()))
+ for casing in (str.lower, str.upper):
+ decoded = PrivilegeDTO.from_json(
+ json.dumps(
+ {key: casing(value) for key, value in
wire.items()}
+ )
+ )
+ self.assertEqual(privilege, decoded)
+ self.assertEqual(hash(privilege), hash(decoded))
+ for obj_type in MetadataObject.Type:
+ self.assertEqual(
+ obj_type in expected_types,
+ decoded.can_bind_to(obj_type),
+ )
+ self.assertEqual(
+ obj_type in expected_types,
+ privilege.can_bind_to(obj_type),
+ )
+ for request_type in (PrivilegeGrantRequest,
PrivilegeRevokeRequest):
+ request = request_type([dto])
+ request.validate()
+ self.assertEqual(
+ {"privileges": [wire]},
json.loads(request.to_json())
+ )
+
+ def test_manage_grants_and_unrelated_privileges(self):
+ for factory in (Privileges.allow, Privileges.deny):
+ self.assertTrue(
+
factory("MANAGE_GRANTS").can_bind_to(MetadataObject.Type.SEMANTIC_MODEL)
+ )
+ for name in (
+ "USE_MODEL",
+ "SELECT_TABLE",
+ "USE_SECRETS",
+ "INCLUDE_CREDENTIAL_SECRETS",
+ ):
+ with self.subTest(name=name):
+ self.assertFalse(
+
factory(name).can_bind_to(MetadataObject.Type.SEMANTIC_MODEL)
+ )
+
+ def test_securable_object_round_trip(self):
+ privileges = [
+ Privileges.allow("USE_SEMANTIC_MODEL"),
+ Privileges.deny("MODIFY_SEMANTIC_MODEL"),
+ ]
+ schema = SecurableObjects.of_schema(
+ SecurableObjects.of_catalog("catalog", []), "schema", []
+ )
+ model = SecurableObjects.of_semantic_model(schema, "sales", privileges)
+ self.assertEqual("catalog.schema.sales", model.full_name())
+ self.assertEqual("catalog.schema", model.parent())
+ self.assertEqual("sales", model.name())
+ self.assertEqual(MetadataObject.Type.SEMANTIC_MODEL, model.type())
+ self.assertEqual(
+ model,
+ SecurableObjects.parse(model.full_name(), model.type(),
privileges),
+ )
+ dto = DTOConverters.to_securable_object_dto(model)
+ wire = json.loads(dto.to_json())
+ self.assertEqual("semantic_model", wire["type"])
+ self.assertEqual("catalog.schema.sales", wire["fullName"])
+ self.assertCountEqual(
+ [
+ {"name": "use_semantic_model", "condition": "allow"},
+ {"name": "modify_semantic_model", "condition": "deny"},
+ ],
+ wire["privileges"],
+ )
+ decoded = SecurableObjectDTO.from_json(dto.to_json())
+ self.assertEqual(model.type(), decoded.type())
+ self.assertEqual(model.full_name(), decoded.full_name())
+ self.assertCountEqual(privileges, decoded.privileges())
diff --git a/clients/client-python/tests/unittests/test_metadata_objects.py
b/clients/client-python/tests/unittests/test_metadata_objects.py
index 7e1a8c97d3..5eb49cbbe0 100644
--- a/clients/client-python/tests/unittests/test_metadata_objects.py
+++ b/clients/client-python/tests/unittests/test_metadata_objects.py
@@ -117,7 +117,7 @@ class TestMetadataObjects(unittest.TestCase):
with self.assertRaisesRegex(
IllegalArgumentException,
- "If the length of names is 3, it must be FILESET, TABLE, TOPIC,
MODEL, VIEW or FUNCTION",
+ "If the length of names is 3, it must be FILESET, TABLE, TOPIC,
MODEL, VIEW, FUNCTION or SEMANTIC_MODEL",
):
MetadataObjects.of(
["catalog1", "schema1", "table1"], MetadataObject.Type.COLUMN