This is an automated email from the ASF dual-hosted git repository.

jerryshao 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 4b478373bd [#12464] improvement(client-python): Support metadata 
object role operations (#12465)
4b478373bd is described below

commit 4b478373bd07e0a6f3e91917b291ca8acad4b81b
Author: Zhiguo Wu <[email protected]>
AuthorDate: Tue Aug 25 17:07:28 2026 +0800

    [#12464] improvement(client-python): Support metadata object role 
operations (#12465)
    
    ### What changes were proposed in this pull request?
    
    Add metadata object role operations to the Python client.
    
    This change:
    
    - Adds `supports_roles()` to metalake, catalog, schema, table, fileset,
    and model APIs.
    - Supports listing directly bound role names for metalakes, catalogs,
    schemas, tables, and filesets.
    - Adds view and function helpers to `SecurableObjects`.
    - Adds unit and integration test coverage.
    
    ### Why are the changes needed?
    
    The Java client exposes role operations through metadata objects, but
    the Python client does not provide the equivalent API. This change
    brings the Python client into alignment with the Java client.
    
    Fix: #12464
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Python client users can call:
    
    ```python
    metadata_object.supports_roles().list_binding_role_names()
    ```
    
    The Python client also provides `SecurableObjects.of_view()` and
    `SecurableObjects.of_function()`.
    
    ### How was this patch tested?
    
    Added unit tests for the public APIs, client implementations, and
    securable object helpers.
    
    Added an integration test covering role bindings for metalakes,
    catalogs, schemas, tables, and filesets.
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
---
 .../api/authorization/securable_objects.py         |  60 ++++++++
 clients/client-python/gravitino/api/catalog.py     |  13 +-
 .../client-python/gravitino/api/file/fileset.py    |  12 ++
 clients/client-python/gravitino/api/metalake.py    |  15 ++
 clients/client-python/gravitino/api/model/model.py |  12 ++
 clients/client-python/gravitino/api/rel/table.py   |  12 ++
 clients/client-python/gravitino/api/schema.py      |  12 ++
 .../gravitino/client/base_schema_catalog.py        |  16 ++-
 .../gravitino/client/generic_fileset.py            |  14 ++
 .../gravitino/client/generic_model.py              |   5 +
 .../gravitino/client/generic_schema.py             |  16 +++
 .../gravitino/client/gravitino_metalake.py         |  20 ++-
 .../gravitino/client/relational_table.py           |  14 ++
 .../tests/integration/test_supports_roles.py       | 150 +++++++++++++++++++
 .../authorization/test_securable_objects.py        |  52 +++++++
 .../tests/unittests/test_generic_model.py          |  11 ++
 .../tests/unittests/test_supports_roles.py         | 158 +++++++++++++++++++++
 17 files changed, 588 insertions(+), 4 deletions(-)

diff --git 
a/clients/client-python/gravitino/api/authorization/securable_objects.py 
b/clients/client-python/gravitino/api/authorization/securable_objects.py
index 491d637d5a..4c00463b70 100644
--- a/clients/client-python/gravitino/api/authorization/securable_objects.py
+++ b/clients/client-python/gravitino/api/authorization/securable_objects.py
@@ -37,8 +37,10 @@ class SecurableObject(MetadataObject, ABC):
         - CATALOG
         - SCHEMA
         - TABLE
+        - VIEW
         - FILESET
         - TOPIC
+        - FUNCTION
         - METALAKE
 
     Use the helper class `SecurableObjects` to construct the securable object 
you need.
@@ -65,6 +67,12 @@ class SecurableObject(MetadataObject, ABC):
         - REST API:
             full_name="catalog1.schema1.table1", type="TABLE"
 
+    View:
+        - Python code:
+            SecurableObjects.of_view(schema, "view1", privileges)
+        - REST API:
+            full_name="catalog1.schema1.view1", type="VIEW"
+
     Topic:
         - Python code:
             SecurableObjects.topic("catalog1", "schema1", "topic1")
@@ -77,6 +85,12 @@ class SecurableObject(MetadataObject, ABC):
         - REST API:
             full_name="catalog1.schema1.fileset1", type="FILESET"
 
+    Function:
+        - Python code:
+            SecurableObjects.of_function(schema, "function1", privileges)
+        - REST API:
+            full_name="catalog1.schema1.function1", type="FUNCTION"
+
     Metalake:
         - Python code:
             SecurableObjects.metalake("metalake1")
@@ -243,6 +257,29 @@ class SecurableObjects:
             privileges,
         )
 
+    @staticmethod
+    def of_view(
+        schema: SecurableObject,
+        view: str,
+        privileges: list[Privilege],
+    ) -> SecurableObjectImpl:
+        """Create a view securable object.
+
+        Args:
+            schema (SecurableObject): The schema securable object.
+            view (str): The view name.
+            privileges (list[Privilege]): The privileges of the view.
+
+        Returns:
+            SecurableObjectImpl: The created view securable object.
+        """
+        names = [*schema.full_name().split("."), view]
+        return SecurableObjects.of(
+            MetadataObject.Type.VIEW,
+            names,
+            privileges,
+        )
+
     @staticmethod
     def of_topic(
         schema: SecurableObject,
@@ -315,6 +352,29 @@ class SecurableObjects:
             privileges,
         )
 
+    @staticmethod
+    def of_function(
+        schema: SecurableObject,
+        function: str,
+        privileges: list[Privilege],
+    ) -> SecurableObjectImpl:
+        """Create a function securable object.
+
+        Args:
+            schema (SecurableObject): The schema securable object.
+            function (str): The function name.
+            privileges (list[Privilege]): The privileges of the function.
+
+        Returns:
+            SecurableObjectImpl: The created function securable object.
+        """
+        names = [*schema.full_name().split("."), function]
+        return SecurableObjects.of(
+            MetadataObject.Type.FUNCTION,
+            names,
+            privileges,
+        )
+
     @staticmethod
     def of_tag(
         tag_name: str,
diff --git a/clients/client-python/gravitino/api/catalog.py 
b/clients/client-python/gravitino/api/catalog.py
index 27d6688415..a3156080ae 100644
--- a/clients/client-python/gravitino/api/catalog.py
+++ b/clients/client-python/gravitino/api/catalog.py
@@ -20,8 +20,10 @@ from enum import Enum
 from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.supports_schemas import SupportsSchemas
 from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.exceptions.base import UnsupportedOperationException
 
 
 class Catalog(Auditable):
@@ -224,6 +226,13 @@ class Catalog(Auditable):
         """
         raise UnsupportedOperationException("Catalog does not support tag 
operations")
 
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this catalog.
 
-class UnsupportedOperationException(Exception):
-    pass
+        Returns:
+            SupportsRoles: The role operations supported by this catalog.
+
+        Raises:
+            UnsupportedOperationException: If this catalog does not support 
role operations.
+        """
+        raise UnsupportedOperationException("Catalog does not support role 
operations")
diff --git a/clients/client-python/gravitino/api/file/fileset.py 
b/clients/client-python/gravitino/api/file/fileset.py
index 7639fe49a4..cda622ceaf 100644
--- a/clients/client-python/gravitino/api/file/fileset.py
+++ b/clients/client-python/gravitino/api/file/fileset.py
@@ -20,6 +20,7 @@ from enum import Enum
 from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -214,3 +215,14 @@ class Fileset(Auditable):
 
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Fileset does not support tag 
operations.")
+
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this fileset.
+
+        Returns:
+            SupportsRoles: The role operations supported by this fileset.
+
+        Raises:
+            UnsupportedOperationException: If this fileset does not support 
role operations.
+        """
+        raise UnsupportedOperationException("Fileset does not support role 
operations.")
diff --git a/clients/client-python/gravitino/api/metalake.py 
b/clients/client-python/gravitino/api/metalake.py
index 0684774526..418ab74b05 100644
--- a/clients/client-python/gravitino/api/metalake.py
+++ b/clients/client-python/gravitino/api/metalake.py
@@ -19,6 +19,8 @@ from abc import abstractmethod
 from typing import Optional, Dict
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.exceptions.base import UnsupportedOperationException
 
 
 class Metalake(Auditable):
@@ -55,3 +57,16 @@ class Metalake(Auditable):
             Optional[Dict[str, str]]: The properties of the metalake.
         """
         pass
+
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this metalake.
+
+        Returns:
+            SupportsRoles: The role operations supported by this metalake.
+
+        Raises:
+            UnsupportedOperationException: If this metalake does not support 
role operations.
+        """
+        raise UnsupportedOperationException(
+            "Metalake does not support role operations."
+        )
diff --git a/clients/client-python/gravitino/api/model/model.py 
b/clients/client-python/gravitino/api/model/model.py
index 8e21982478..0311ff4825 100644
--- a/clients/client-python/gravitino/api/model/model.py
+++ b/clients/client-python/gravitino/api/model/model.py
@@ -19,6 +19,7 @@ from abc import abstractmethod
 from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -77,3 +78,14 @@ class Model(Auditable):
 
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Model does not support tag 
operations.")
+
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this model.
+
+        Returns:
+            SupportsRoles: The role operations supported by this model.
+
+        Raises:
+            UnsupportedOperationException: If this model does not support role 
operations.
+        """
+        raise UnsupportedOperationException("Model does not support role 
operations.")
diff --git a/clients/client-python/gravitino/api/rel/table.py 
b/clients/client-python/gravitino/api/rel/table.py
index a32f244b20..060b83b613 100644
--- a/clients/client-python/gravitino/api/rel/table.py
+++ b/clients/client-python/gravitino/api/rel/table.py
@@ -19,6 +19,7 @@ from abc import abstractmethod
 from typing import Optional
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.rel.column import Column
 from gravitino.api.rel.expressions.distributions.distribution import 
Distribution
 from gravitino.api.rel.expressions.distributions.distributions import 
Distributions
@@ -117,6 +118,17 @@ class Table(Auditable):
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Table does not support tag 
operations.")
 
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this table.
+
+        Returns:
+            SupportsRoles: The role operations supported by this table.
+
+        Raises:
+            UnsupportedOperationException: If this table does not support role 
operations.
+        """
+        raise UnsupportedOperationException("Table does not support role 
operations.")
+
     def supports_statistics(self) -> SupportsStatistics:
         raise UnsupportedOperationException(
             "Table does not support statistics operations."
diff --git a/clients/client-python/gravitino/api/schema.py 
b/clients/client-python/gravitino/api/schema.py
index 5a7ec0a088..78705f5bd9 100644
--- a/clients/client-python/gravitino/api/schema.py
+++ b/clients/client-python/gravitino/api/schema.py
@@ -19,6 +19,7 @@ from abc import abstractmethod
 from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -48,3 +49,14 @@ class Schema(Auditable):
 
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Schema does not support tag 
operations.")
+
+    def supports_roles(self) -> SupportsRoles:
+        """Return role operations supported by this schema.
+
+        Returns:
+            SupportsRoles: The role operations supported by this schema.
+
+        Raises:
+            UnsupportedOperationException: If this schema does not support 
role operations.
+        """
+        raise UnsupportedOperationException("Schema does not support role 
operations.")
diff --git a/clients/client-python/gravitino/client/base_schema_catalog.py 
b/clients/client-python/gravitino/client/base_schema_catalog.py
index e0fb8c20ce..88ab8f72cd 100644
--- a/clients/client-python/gravitino/client/base_schema_catalog.py
+++ b/clients/client-python/gravitino/client/base_schema_catalog.py
@@ -18,6 +18,7 @@
 import logging
 from typing import Dict, List, Optional
 
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.catalog import Catalog
 from gravitino.api.function.function import Function
 from gravitino.api.function.function_catalog import FunctionCatalog
@@ -36,6 +37,9 @@ from gravitino.client.generic_schema import GenericSchema
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
 from gravitino.client.metadata_object_secret_operations import (
     MetadataObjectSecretOperations,
 )
@@ -62,8 +66,9 @@ class BaseSchemaCatalog(
     CatalogDTO,
     SupportsSchemas,
     FunctionCatalog,
+    SupportsRoles,
     SupportsTags,
-):
+):  # pylint: disable=too-many-ancestors
     """
     BaseSchemaCatalog is the base abstract class for all the catalog with 
schema. It provides the
     common methods for managing schemas in a catalog. With BaseSchemaCatalog, 
users can list,
@@ -119,6 +124,9 @@ class BaseSchemaCatalog(
         self._object_tag_operations = MetadataObjectTagOperations(
             catalog_namespace.level(0), metadata_object, rest_client
         )
+        self._object_role_operations = MetadataObjectRoleOperations(
+            catalog_namespace.level(0), metadata_object, rest_client
+        )
 
         self.validate()
 
@@ -389,3 +397,9 @@ class BaseSchemaCatalog(
 
     def supports_tags(self) -> SupportsTags:
         return self
+
+    def supports_roles(self) -> SupportsRoles:
+        return self
+
+    def list_binding_role_names(self) -> List[str]:
+        return self._object_role_operations.list_binding_role_names()
diff --git a/clients/client-python/gravitino/client/generic_fileset.py 
b/clients/client-python/gravitino/client/generic_fileset.py
index 9516cea807..750627288e 100644
--- a/clients/client-python/gravitino/client/generic_fileset.py
+++ b/clients/client-python/gravitino/client/generic_fileset.py
@@ -16,6 +16,7 @@
 # under the License.
 from typing import Dict, List, Optional
 
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.credential.credential import Credential
 from gravitino.api.credential.supports_credentials import SupportsCredentials
 from gravitino.api.secret.supports_secrets import SupportsSecrets
@@ -27,6 +28,9 @@ from gravitino.api.tag.tag import Tag
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
 from gravitino.client.metadata_object_secret_operations import (
     MetadataObjectSecretOperations,
 )
@@ -40,6 +44,7 @@ from gravitino.utils import HTTPClient
 class GenericFileset(
     Fileset,
     SupportsCredentials,
+    SupportsRoles,
     SupportsSecrets,
     SupportsTags,
 ):
@@ -69,6 +74,9 @@ class GenericFileset(
         self._object_tag_operations = MetadataObjectTagOperations(
             full_namespace.level(0), metadata_object, rest_client
         )
+        self._object_role_operations = MetadataObjectRoleOperations(
+            full_namespace.level(0), metadata_object, rest_client
+        )
 
     def name(self) -> str:
         return self._fileset.name()
@@ -94,9 +102,15 @@ class GenericFileset(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def supports_roles(self) -> SupportsRoles:
+        return self
+
     def get_credentials(self) -> List[Credential]:
         return self._object_credential_operations.get_credentials()
 
+    def list_binding_role_names(self) -> List[str]:
+        return self._object_role_operations.list_binding_role_names()
+
     def support_secrets(self) -> SupportsSecrets:
         return self
 
diff --git a/clients/client-python/gravitino/client/generic_model.py 
b/clients/client-python/gravitino/client/generic_model.py
index 41aba4562b..6305f0e4a3 100644
--- a/clients/client-python/gravitino/client/generic_model.py
+++ b/clients/client-python/gravitino/client/generic_model.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 from typing import Optional
 
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.model.model import Model
@@ -27,6 +28,7 @@ from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.model_dto import ModelDTO
+from gravitino.exceptions.base import UnsupportedOperationException
 from gravitino.namespace import Namespace
 from gravitino.utils import HTTPClient
 
@@ -94,3 +96,6 @@ class GenericModel(Model, SupportsTags):
 
     def supports_tags(self) -> SupportsTags:
         return self
+
+    def supports_roles(self) -> SupportsRoles:
+        raise UnsupportedOperationException("Not supported yet.")
diff --git a/clients/client-python/gravitino/client/generic_schema.py 
b/clients/client-python/gravitino/client/generic_schema.py
index 52ade4c630..a14944d29a 100644
--- a/clients/client-python/gravitino/client/generic_schema.py
+++ b/clients/client-python/gravitino/client/generic_schema.py
@@ -20,12 +20,16 @@ from __future__ import annotations
 from typing import Dict
 
 from gravitino.api.audit import Audit
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.schema import Schema
 from gravitino.api.secret.supports_secrets import SupportsSecrets
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.api.tag.tag import Tag
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
 from gravitino.client.metadata_object_secret_operations import (
     MetadataObjectSecretOperations,
 )
@@ -36,6 +40,7 @@ from gravitino.utils.http_client import HTTPClient
 
 class GenericSchema(
     Schema,
+    SupportsRoles,
     SupportsTags,
     SupportsSecrets,
 ):
@@ -59,6 +64,11 @@ class GenericSchema(
             metadata_object,
             rest_client,
         )
+        self._metadata_object_role_operations = MetadataObjectRoleOperations(
+            metalake,
+            metadata_object,
+            rest_client,
+        )
         self._object_secret_operations = MetadataObjectSecretOperations(
             metalake,
             metadata_object,
@@ -114,6 +124,12 @@ class GenericSchema(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def supports_roles(self) -> SupportsRoles:
+        return self
+
+    def list_binding_role_names(self) -> list[str]:
+        return self._metadata_object_role_operations.list_binding_role_names()
+
     def support_secrets(self) -> SupportsSecrets:
         return self
 
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py 
b/clients/client-python/gravitino/client/gravitino_metalake.py
index 1fcb75cddb..5e279ba629 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -23,6 +23,7 @@ from gravitino.api.authorization.owner import Owner
 from gravitino.api.authorization.privileges import Privilege
 from gravitino.api.authorization.role import Role
 from gravitino.api.authorization.securable_objects import SecurableObject
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.authorization.user import User
 from gravitino.api.catalog import Catalog
 from gravitino.api.catalog_change import CatalogChange
@@ -31,11 +32,15 @@ from gravitino.api.job.job_template import JobTemplate
 from gravitino.api.job.job_template_change import JobTemplateChange
 from gravitino.api.job.supports_jobs import SupportsJobs
 from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.tag.tag import Tag
 from gravitino.api.tag.tag_operations import TagOperations
 from gravitino.client.dto_converters import DTOConverters
 from gravitino.client.generic_job_handle import GenericJobHandle
 from gravitino.client.generic_tag import GenericTag
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
 from gravitino.dto.metalake_dto import MetalakeDTO
 from gravitino.dto.requests.catalog_create_request import CatalogCreateRequest
 from gravitino.dto.requests.catalog_set_request import CatalogSetRequest
@@ -108,8 +113,9 @@ logger = logging.getLogger(__name__)
 class GravitinoMetalake(
     MetalakeDTO,
     SupportsJobs,
+    SupportsRoles,
     TagOperations,
-):
+):  # pylint: disable=too-many-ancestors
     """
     Gravitino Metalake is the top-level metadata repository for users. It 
contains a list of catalogs
     as sub-level metadata collections. With GravitinoMetalake, users can list, 
create, load,
@@ -151,6 +157,12 @@ class GravitinoMetalake(
             _audit=metalake.audit_info(),
         )
         self.rest_client = client
+        metalake_object = MetadataObjects.of(
+            [self.name()], MetadataObject.Type.METALAKE
+        )
+        self._metadata_object_role_operations = MetadataObjectRoleOperations(
+            self.name(), metalake_object, client
+        )
 
     def list_catalogs(self) -> List[str]:
         """List all the catalogs under this metalake.
@@ -1037,6 +1049,9 @@ class GravitinoMetalake(
     # Role operations
     #####################
 
+    def supports_roles(self) -> SupportsRoles:
+        return self
+
     def create_role(
         self,
         role_name: str,
@@ -1136,6 +1151,9 @@ class GravitinoMetalake(
         resp.validate()
         return resp.names()
 
+    def list_binding_role_names(self) -> List[str]:
+        return self._metadata_object_role_operations.list_binding_role_names()
+
     def grant_roles_to_user(self, role_names: List[str], user_name: str) -> 
User:
         """Grant roles to a user.
 
diff --git a/clients/client-python/gravitino/client/relational_table.py 
b/clients/client-python/gravitino/client/relational_table.py
index 17e59ad7f9..51f4e463c3 100644
--- a/clients/client-python/gravitino/client/relational_table.py
+++ b/clients/client-python/gravitino/client/relational_table.py
@@ -18,6 +18,7 @@
 from typing import Any, Optional, cast
 
 from gravitino.api.audit import Audit
+from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.rel.column import Column
@@ -33,6 +34,9 @@ from gravitino.api.stats.supports_statistics import 
SupportsStatistics
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.api.tag.tag import Tag
 from gravitino.client.generic_column import GenericColumn
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
 from gravitino.client.metadata_object_statistics_operations import (
     MetadataObjectStatisticsOperations,
 )
@@ -57,6 +61,7 @@ from gravitino.utils import HTTPClient
 
 class RelationalTable(
     Table,
+    SupportsRoles,
     SupportsStatistics,
     SupportsTags,
 ):
@@ -75,6 +80,9 @@ class RelationalTable(
         self._object_tag_operations = MetadataObjectTagOperations(
             namespace.level(0), table_object, rest_client
         )
+        self._object_role_operations = MetadataObjectRoleOperations(
+            namespace.level(0), table_object, rest_client
+        )
         self._object_statistics_operations = 
MetadataObjectStatisticsOperations(
             namespace.level(0), table_object, rest_client
         )
@@ -267,6 +275,12 @@ class RelationalTable(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def supports_roles(self) -> SupportsRoles:
+        return self
+
+    def list_binding_role_names(self) -> list[str]:
+        return self._object_role_operations.list_binding_role_names()
+
     def list_statistics(self) -> list[Statistic]:
         return self._object_statistics_operations.list_statistics()
 
diff --git a/clients/client-python/tests/integration/test_supports_roles.py 
b/clients/client-python/tests/integration/test_supports_roles.py
new file mode 100644
index 0000000000..103af31912
--- /dev/null
+++ b/clients/client-python/tests/integration/test_supports_roles.py
@@ -0,0 +1,150 @@
+# 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 uuid
+
+from gravitino import Catalog, Fileset, NameIdentifier
+from gravitino.api.authorization.privileges import Privileges
+from gravitino.api.authorization.securable_objects import SecurableObjects
+from gravitino.api.rel.types.types import Types
+from gravitino.dto.rel.column_dto import ColumnDTO
+from tests.integration.containers.hdfs_container import HDFSContainer
+from tests.integration.integration_test_env import 
AuthorizationIntegrationTestEnv
+
+
+class TestSupportsRoles(AuthorizationIntegrationTestEnv):
+    _metalake_name = f"test_supports_roles_{uuid.uuid4().hex[:8]}"
+    _metalake_comment = "test metadata object role operations"
+    _fileset_catalog_name = "fileset_catalog"
+    _fileset_schema_name = "fileset_schema"
+    _fileset_name = "fileset"
+    _relational_catalog_name = "relational_catalog"
+    _relational_schema_name = "relational_schema"
+    _table_name = "table"
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        super().setUpClass()
+        cls._hdfs_container = HDFSContainer()
+
+    @classmethod
+    def tearDownClass(cls) -> None:
+        try:
+            cls._hdfs_container.close()
+        finally:
+            super().tearDownClass()
+
+    def setUp(self) -> None:
+        super().setUp()
+        self._metalake = self._gravitino_client.get_metalake()
+        self._fileset_catalog = self._gravitino_client.create_catalog(
+            name=self._fileset_catalog_name,
+            catalog_type=Catalog.Type.FILESET,
+            provider=None,
+            comment="test fileset catalog",
+            properties={"location": 
f"/tmp/{self._metalake_name}/test_supports_roles"},
+        )
+        self._fileset_schema = 
self._fileset_catalog.as_schemas().create_schema(
+            schema_name=self._fileset_schema_name,
+            comment="test fileset schema",
+            properties={},
+        )
+        self._fileset = 
self._fileset_catalog.as_fileset_catalog().create_fileset(
+            ident=NameIdentifier.of(self._fileset_schema_name, 
self._fileset_name),
+            comment="test fileset",
+            fileset_type=Fileset.Type.MANAGED,
+            
storage_location=f"/tmp/{self._metalake_name}/test_supports_roles/fileset",
+            properties={},
+        )
+        self._relational_catalog = self._gravitino_client.create_catalog(
+            name=self._relational_catalog_name,
+            catalog_type=Catalog.Type.RELATIONAL,
+            provider="hive",
+            comment="test relational catalog",
+            properties={
+                "metastore.uris": 
f"thrift://{self._hdfs_container.get_ip()}:9083"
+            },
+        )
+        self._relational_schema = 
self._relational_catalog.as_schemas().create_schema(
+            schema_name=self._relational_schema_name,
+            comment="test relational schema",
+            properties={},
+        )
+        self._table = self._relational_catalog.as_table_catalog().create_table(
+            identifier=NameIdentifier.of(
+                self._relational_schema_name, self._table_name
+            ),
+            columns=[
+                ColumnDTO.builder()
+                .with_name("id")
+                .with_data_type(Types.IntegerType.get())
+                .build()
+            ],
+        )
+
+    def test_list_binding_roles_for_metadata_objects(self) -> None:
+        catalog_object = SecurableObjects.of_catalog(
+            self._fileset_catalog_name, [Privileges.allow("USE_CATALOG")]
+        )
+        schema_object = SecurableObjects.of_schema(
+            catalog_object,
+            self._fileset_schema_name,
+            [Privileges.allow("USE_SCHEMA")],
+        )
+        fileset_object = SecurableObjects.of_fileset(
+            schema_object, self._fileset_name, 
[Privileges.allow("READ_FILESET")]
+        )
+        relational_catalog_object = SecurableObjects.of_catalog(
+            self._relational_catalog_name, [Privileges.allow("USE_CATALOG")]
+        )
+        relational_schema_object = SecurableObjects.of_schema(
+            relational_catalog_object,
+            self._relational_schema_name,
+            [Privileges.allow("USE_SCHEMA")],
+        )
+        table_object = SecurableObjects.of_table(
+            relational_schema_object,
+            self._table_name,
+            [Privileges.allow("SELECT_TABLE")],
+        )
+
+        bindings = [
+            (
+                "metalake_role",
+                SecurableObjects.of_metalake(
+                    self._metalake_name, [Privileges.allow("CREATE_CATALOG")]
+                ),
+                self._metalake,
+            ),
+            ("catalog_role", catalog_object, self._fileset_catalog),
+            ("schema_role", schema_object, self._fileset_schema),
+            ("fileset_role", fileset_object, self._fileset),
+            ("table_role", table_object, self._table),
+        ]
+
+        for role_name, securable_object, metadata_object in bindings:
+            with self.subTest(metadata_type=securable_object.type()):
+                self.assertEqual(
+                    [], 
metadata_object.supports_roles().list_binding_role_names()
+                )
+                self._gravitino_client.create_role(
+                    role_name, securable_objects=[securable_object]
+                )
+                self.assertEqual(
+                    [role_name],
+                    metadata_object.supports_roles().list_binding_role_names(),
+                )
diff --git 
a/clients/client-python/tests/unittests/authorization/test_securable_objects.py 
b/clients/client-python/tests/unittests/authorization/test_securable_objects.py
index 35a1963b5c..c3de92d254 100644
--- 
a/clients/client-python/tests/unittests/authorization/test_securable_objects.py
+++ 
b/clients/client-python/tests/unittests/authorization/test_securable_objects.py
@@ -156,6 +156,31 @@ class TestSecurableObject(unittest.TestCase):
         self.assertEqual(MetadataObject.Type.TABLE, table_object.type())
         self.assertEqual(table_object, another_table_object)
 
+    def test_view_object(self) -> None:
+        catalog_object = SecurableObjects.of_catalog(
+            "catalog",
+            [MockPrivilege(Privilege.Name.USE_CATALOG, 
Privilege.Condition.ALLOW)],
+        )
+        schema_object = SecurableObjects.of_schema(
+            catalog_object,
+            "schema",
+            [MockPrivilege(Privilege.Name.USE_SCHEMA, 
Privilege.Condition.ALLOW)],
+        )
+        view_privilege = MockPrivilege(
+            Privilege.Name.SELECT_VIEW, Privilege.Condition.ALLOW
+        )
+
+        view_object = SecurableObjects.of_view(schema_object, "view", 
[view_privilege])
+        another_view_object = SecurableObjects.of(
+            MetadataObject.Type.VIEW,
+            ["catalog", "schema", "view"],
+            [view_privilege],
+        )
+
+        self.assertEqual("catalog.schema.view", view_object.full_name())
+        self.assertEqual(MetadataObject.Type.VIEW, view_object.type())
+        self.assertEqual(view_object, another_view_object)
+
     def test_fileset_object(self) -> None:
         catalog_object = SecurableObjects.of_catalog(
             "catalog",
@@ -198,6 +223,33 @@ class TestSecurableObject(unittest.TestCase):
         self.assertEqual(MetadataObject.Type.FILESET, fileset_object.type())
         self.assertEqual(fileset_object, another_fileset_object)
 
+    def test_function_object(self) -> None:
+        catalog_object = SecurableObjects.of_catalog(
+            "catalog",
+            [MockPrivilege(Privilege.Name.USE_CATALOG, 
Privilege.Condition.ALLOW)],
+        )
+        schema_object = SecurableObjects.of_schema(
+            catalog_object,
+            "schema",
+            [MockPrivilege(Privilege.Name.USE_SCHEMA, 
Privilege.Condition.ALLOW)],
+        )
+        function_privilege = MockPrivilege(
+            Privilege.Name.EXECUTE_FUNCTION, Privilege.Condition.ALLOW
+        )
+
+        function_object = SecurableObjects.of_function(
+            schema_object, "function", [function_privilege]
+        )
+        another_function_object = SecurableObjects.of(
+            MetadataObject.Type.FUNCTION,
+            ["catalog", "schema", "function"],
+            [function_privilege],
+        )
+
+        self.assertEqual("catalog.schema.function", 
function_object.full_name())
+        self.assertEqual(MetadataObject.Type.FUNCTION, function_object.type())
+        self.assertEqual(function_object, another_function_object)
+
     def test_topic_object(self) -> None:
         catalog_object = SecurableObjects.of_catalog(
             "catalog",
diff --git a/clients/client-python/tests/unittests/test_generic_model.py 
b/clients/client-python/tests/unittests/test_generic_model.py
index c674530751..d95c516149 100644
--- a/clients/client-python/tests/unittests/test_generic_model.py
+++ b/clients/client-python/tests/unittests/test_generic_model.py
@@ -19,6 +19,7 @@ import unittest
 
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.client.generic_model import GenericModel
+from gravitino.exceptions.base import UnsupportedOperationException
 from gravitino.name_identifier import NameIdentifier
 from gravitino.namespace import Namespace
 from gravitino.utils.http_client import HTTPClient
@@ -68,3 +69,13 @@ class TestGenericModel(unittest.TestCase):
                 for method in expected_methods
             )
         )
+
+    def test_supports_roles_is_not_supported_yet(self) -> None:
+        generic_model = GenericModel(
+            build_model_dto(),
+            TestGenericModel._rest_client,
+            TestGenericModel._model_ident.namespace(),
+        )
+
+        with self.assertRaisesRegex(UnsupportedOperationException, "Not 
supported yet"):
+            generic_model.supports_roles()
diff --git a/clients/client-python/tests/unittests/test_supports_roles.py 
b/clients/client-python/tests/unittests/test_supports_roles.py
new file mode 100644
index 0000000000..2f29161b92
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_supports_roles.py
@@ -0,0 +1,158 @@
+# 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, patch
+
+from gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.api.catalog import Catalog
+from gravitino.api.file.fileset import Fileset
+from gravitino.api.metalake import Metalake
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.model.model import Model
+from gravitino.api.rel.table import Table
+from gravitino.api.schema import Schema
+from gravitino.client.generic_fileset import GenericFileset
+from gravitino.client.generic_schema import GenericSchema
+from gravitino.client.gravitino_metalake import GravitinoMetalake
+from gravitino.client.relational_catalog import RelationalCatalog
+from gravitino.client.relational_table import RelationalTable
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.fileset_dto import FilesetDTO
+from gravitino.dto.metalake_dto import MetalakeDTO
+from gravitino.dto.rel.table_dto import TableDTO
+from gravitino.dto.responses.name_list_response import NameListResponse
+from gravitino.exceptions.base import UnsupportedOperationException
+from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient
+from tests.unittests import mock_base
+from tests.unittests.fixtures.table_fixtures import TABLE_DTO_JSON_STRING
+
+
+class TestSupportsRoles(unittest.TestCase):
+    METALAKE_NAME = "metalake"
+    CATALOG_NAME = "catalog"
+    SCHEMA_NAME = "schema"
+    REST_CLIENT = HTTPClient("http://localhost:8090";)
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        audit = AuditDTO(_creator="test")
+        cls.metalake = GravitinoMetalake(
+            MetalakeDTO(cls.METALAKE_NAME, "comment", {}, audit), 
cls.REST_CLIENT
+        )
+        cls.catalog = RelationalCatalog(
+            catalog_namespace=Namespace.of(cls.METALAKE_NAME),
+            name=cls.CATALOG_NAME,
+            catalog_type=Catalog.Type.RELATIONAL,
+            provider="test",
+            audit=audit,
+            rest_client=cls.REST_CLIENT,
+        )
+        cls.schema = GenericSchema(
+            mock_base.build_schema_dto(name=cls.SCHEMA_NAME),
+            cls.REST_CLIENT,
+            cls.METALAKE_NAME,
+            cls.CATALOG_NAME,
+        )
+        cls.table = RelationalTable(
+            Namespace.of(cls.METALAKE_NAME, cls.CATALOG_NAME, cls.SCHEMA_NAME),
+            TableDTO.from_json(TABLE_DTO_JSON_STRING),
+            cls.REST_CLIENT,
+        )
+        cls.fileset = GenericFileset(
+            FilesetDTO(
+                _name="fileset",
+                _comment="comment",
+                _type=Fileset.Type.EXTERNAL,
+                _properties={},
+                _storage_locations={Fileset.LOCATION_NAME_UNKNOWN: 
"/tmp/fileset"},
+                _audit=audit,
+            ),
+            cls.REST_CLIENT,
+            Namespace.of(cls.METALAKE_NAME, cls.CATALOG_NAME, cls.SCHEMA_NAME),
+        )
+
+    def test_list_roles_for_metalake(self) -> None:
+        self._test_list_roles(
+            self.metalake.supports_roles(),
+            MetadataObjects.of([self.METALAKE_NAME], 
MetadataObject.Type.METALAKE),
+        )
+
+    def test_list_roles_for_catalog(self) -> None:
+        self._test_list_roles(
+            self.catalog.supports_roles(),
+            MetadataObjects.of([self.CATALOG_NAME], 
MetadataObject.Type.CATALOG),
+        )
+
+    def test_list_roles_for_schema(self) -> None:
+        self._test_list_roles(
+            self.schema.supports_roles(),
+            MetadataObjects.of(
+                [self.CATALOG_NAME, self.SCHEMA_NAME], 
MetadataObject.Type.SCHEMA
+            ),
+        )
+
+    def test_list_roles_for_table(self) -> None:
+        self._test_list_roles(
+            self.table.supports_roles(),
+            MetadataObjects.of(
+                [self.CATALOG_NAME, self.SCHEMA_NAME, self.table.name()],
+                MetadataObject.Type.TABLE,
+            ),
+        )
+
+    def test_list_roles_for_fileset(self) -> None:
+        self._test_list_roles(
+            self.fileset.supports_roles(),
+            MetadataObjects.of(
+                [self.CATALOG_NAME, self.SCHEMA_NAME, self.fileset.name()],
+                MetadataObject.Type.FILESET,
+            ),
+        )
+
+    def test_default_supports_roles_raises_unsupported_operation(self) -> None:
+        metadata_object_types = [Metalake, Catalog, Schema, Table, Fileset, 
Model]
+
+        for metadata_object_type in metadata_object_types:
+            with 
self.subTest(metadata_object_type=metadata_object_type.__name__):
+                metadata_object = Mock(spec=metadata_object_type)
+                with self.assertRaises(UnsupportedOperationException):
+                    metadata_object_type.supports_roles(metadata_object)
+
+    def _test_list_roles(
+        self, supports_roles: SupportsRoles, metadata_object: MetadataObject
+    ) -> None:
+        expected_roles = ["role1", "role2"]
+        mock_response = mock_base.mock_http_response(
+            NameListResponse(0, expected_roles).to_json()
+        )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_response,
+        ) as mock_get:
+            self.assertEqual(expected_roles, 
supports_roles.list_binding_role_names())
+            mock_get.assert_called_once_with(
+                "api/metalakes/metalake/objects/"
+                f"{metadata_object.type().name.lower()}/"
+                f"{metadata_object.full_name()}/roles",
+                params={},
+                error_handler=ROLE_ERROR_HANDLER,
+            )

Reply via email to