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 6550ca2460 [#14622] feat(client-python): add policy client operations 
(#14623)
6550ca2460 is described below

commit 6550ca246035d3dd19e80aaed5a673121f073f85
Author: karmendra8386 <[email protected]>
AuthorDate: Wed Nov 4 08:40:34 2026 -0500

    [#14622] feat(client-python): add policy client operations (#14623)
    
    ### What changes were proposed in this pull request?
    
    Add the policy client operations to the Python client, mirroring the
    Java client and following the existing tag wiring in the Python client.
    
    `GravitinoMetalake` and `GravitinoClient` gain the `PolicyOperations`
    methods: list/get/create/alter/delete/enable/disable policies, plus the
    tag associations in both directions (list/add/remove policies for a tag,
    list tag associations for a policy). The tag operations gain the policy
    association defaults, `MetadataObjectPolicyOperations` implements
    `SupportsPolicies` for metadata objects, and `GenericPolicy` /
    `GenericPolicyTagAssociation` wrap the DTOs. This also adds the policy
    error handler and the policy conversions in `DTOConverters`.
    
    This is part 2 of the work for #7897, tracked as #14622, and follows the
    merged #14544, which added the policy API types and DTOs this builds on.
    
    ### Why are the changes needed?
    
    The server, the Java client and, since #14544, the Python API/DTO layer
    support policies, but the Python client still cannot call any policy
    operation, so Python users cannot work with policies.
    
    Fixes #14622. Together with #14544 this completes the Python client
    policy support from #7897.
    
    ### Does this PR introduce _any_ user-facing change?
    
    It wires up the new public policy operations on the Python client and
    metalake. No existing APIs are changed.
    
    ### How was this patch tested?
    
    Added unit tests for the policy client API, `GenericPolicy`, the DTO
    converters and the metadata object policy operations, using the same
    mock style as the tag operation tests.
    
    Ran the full client-python unit suite on this branch:
    
    ```
    1395 passed, 1022 subtests passed
    ```
    
    Pylint on the changed files is 10.00/10, ruff check and format check and
    black check are clean, and `git diff --check` passes.
---
 clients/client-python/gravitino/api/catalog.py     |  15 +
 .../client-python/gravitino/api/file/fileset.py    |   6 +
 clients/client-python/gravitino/api/model/model.py |   4 +
 clients/client-python/gravitino/api/rel/table.py   |   4 +
 clients/client-python/gravitino/api/schema.py      |   6 +
 .../gravitino/api/semantic/semantic_model.py       |  14 +
 .../gravitino/api/tag/tag_operations.py            |  63 +++
 .../gravitino/client/base_schema_catalog.py        |  27 +-
 .../gravitino/client/dto_converters.py             | 167 +++++++
 .../gravitino/client/generic_fileset.py            |  27 +-
 .../gravitino/client/generic_model.py              |  26 +-
 .../gravitino/client/generic_policy.py             | 103 ++++
 .../client/generic_policy_tag_association.py       |  72 +++
 .../gravitino/client/generic_schema.py             |  29 ++
 .../gravitino/client/generic_semantic_model.py     |  26 +-
 .../gravitino/client/gravitino_client.py           | 188 +++++++-
 .../gravitino/client/gravitino_metalake.py         | 404 ++++++++++++++++
 .../client/metadata_object_policy_operations.py    |  97 ++++
 .../gravitino/client/relational_table.py           |  27 +-
 ...ag_error_handler.py => policy_error_handler.py} |  22 +-
 .../exceptions/handlers/tag_error_handler.py       |  13 +-
 .../test_metadata_object_policy_operations.py      | 268 +++++++++++
 .../unittests/client/test_policy_dto_converters.py | 125 +++++
 clients/client-python/tests/unittests/mock_base.py | 145 ++++++
 .../unittests/test_code_mapping_error_handler.py   |  14 +
 .../tests/unittests/test_generic_policy.py         |  87 ++++
 .../tests/unittests/test_policy_client_api.py      | 533 +++++++++++++++++++++
 27 files changed, 2495 insertions(+), 17 deletions(-)

diff --git a/clients/client-python/gravitino/api/catalog.py 
b/clients/client-python/gravitino/api/catalog.py
index 7beae70abb..2ea176c5da 100644
--- a/clients/client-python/gravitino/api/catalog.py
+++ b/clients/client-python/gravitino/api/catalog.py
@@ -21,6 +21,7 @@ from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
 from gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.supports_schemas import SupportsSchemas
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
@@ -241,6 +242,20 @@ class Catalog(Auditable):
         """
         raise UnsupportedOperationException("Catalog does not support tag 
operations")
 
+    def supports_policies(self) -> SupportsPolicies:
+        """
+        Return the {@link SupportsPolicies} if the catalog supports policy 
operations.
+
+        Raises:
+            UnsupportedOperationException: if the catalog does not support 
policy operations.
+
+        Returns:
+            SupportsPolicies: the {@link SupportsPolicies} instance
+        """
+        raise UnsupportedOperationException(
+            "Catalog does not support policy operations"
+        )
+
     def supports_roles(self) -> SupportsRoles:
         """Return role operations supported by this catalog.
 
diff --git a/clients/client-python/gravitino/api/file/fileset.py 
b/clients/client-python/gravitino/api/file/fileset.py
index 5cb75a873c..a490c731eb 100644
--- a/clients/client-python/gravitino/api/file/fileset.py
+++ b/clients/client-python/gravitino/api/file/fileset.py
@@ -21,6 +21,7 @@ from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
 from gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -220,6 +221,11 @@ class Fileset(Auditable):
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Fileset does not support tag 
operations.")
 
+    def supports_policies(self) -> SupportsPolicies:
+        raise UnsupportedOperationException(
+            "Fileset does not support policy operations."
+        )
+
     def supports_roles(self) -> SupportsRoles:
         """Return role operations supported by this fileset.
 
diff --git a/clients/client-python/gravitino/api/model/model.py 
b/clients/client-python/gravitino/api/model/model.py
index 399c94e0f8..d6d9432e3b 100644
--- a/clients/client-python/gravitino/api/model/model.py
+++ b/clients/client-python/gravitino/api/model/model.py
@@ -21,6 +21,7 @@ from typing import Dict, Optional
 from gravitino.api.auditable import Auditable
 from gravitino.api.authorization.supports_roles import SupportsRoles
 from gravitino.api.secret.supports_secrets import SupportsSecrets
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -80,6 +81,9 @@ class Model(Auditable):
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Model does not support tag 
operations.")
 
+    def supports_policies(self) -> SupportsPolicies:
+        raise UnsupportedOperationException("Model does not support policy 
operations.")
+
     def supports_roles(self) -> SupportsRoles:
         """Return role operations supported by this model.
 
diff --git a/clients/client-python/gravitino/api/rel/table.py 
b/clients/client-python/gravitino/api/rel/table.py
index 728fab6633..5bb1b27933 100644
--- a/clients/client-python/gravitino/api/rel/table.py
+++ b/clients/client-python/gravitino/api/rel/table.py
@@ -30,6 +30,7 @@ from gravitino.api.rel.indexes.index import Index
 from gravitino.api.rel.indexes.indexes import Indexes
 from gravitino.api.secret.supports_secrets import SupportsSecrets
 from gravitino.api.stats.supports_statistics import SupportsStatistics
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -119,6 +120,9 @@ class Table(Auditable):
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Table does not support tag 
operations.")
 
+    def supports_policies(self) -> SupportsPolicies:
+        raise UnsupportedOperationException("Table does not support policy 
operations.")
+
     def supports_roles(self) -> SupportsRoles:
         """Return role operations supported by this table.
 
diff --git a/clients/client-python/gravitino/api/schema.py 
b/clients/client-python/gravitino/api/schema.py
index 78705f5bd9..7cd67a51f7 100644
--- a/clients/client-python/gravitino/api/schema.py
+++ b/clients/client-python/gravitino/api/schema.py
@@ -20,6 +20,7 @@ from typing import Dict, Optional
 
 from gravitino.api.auditable import Auditable
 from gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -50,6 +51,11 @@ class Schema(Auditable):
     def supports_tags(self) -> SupportsTags:
         raise UnsupportedOperationException("Schema does not support tag 
operations.")
 
+    def supports_policies(self) -> SupportsPolicies:
+        raise UnsupportedOperationException(
+            "Schema does not support policy operations."
+        )
+
     def supports_roles(self) -> SupportsRoles:
         """Return role operations supported by this schema.
 
diff --git a/clients/client-python/gravitino/api/semantic/semantic_model.py 
b/clients/client-python/gravitino/api/semantic/semantic_model.py
index d7d1cdd9b4..98f4faf3ab 100644
--- a/clients/client-python/gravitino/api/semantic/semantic_model.py
+++ b/clients/client-python/gravitino/api/semantic/semantic_model.py
@@ -20,6 +20,7 @@ from typing import Optional
 
 from gravitino.api.auditable import Auditable
 from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.exceptions.base import UnsupportedOperationException
 
@@ -60,3 +61,16 @@ class SemanticModel(Auditable):
         raise UnsupportedOperationException(
             "Semantic Model does not support tag operations."
         )
+
+    def supports_policies(self) -> SupportsPolicies:
+        """Return policy operations if the Semantic Model supports policies.
+
+        Raises:
+            UnsupportedOperationException: If policy operations are not 
supported.
+
+        Returns:
+            SupportsPolicies: The policy operations supported by the Semantic 
Model.
+        """
+        raise UnsupportedOperationException(
+            "Semantic Model does not support policy operations."
+        )
diff --git a/clients/client-python/gravitino/api/tag/tag_operations.py 
b/clients/client-python/gravitino/api/tag/tag_operations.py
index 1ce28dfd84..050b70b232 100644
--- a/clients/client-python/gravitino/api/tag/tag_operations.py
+++ b/clients/client-python/gravitino/api/tag/tag_operations.py
@@ -18,9 +18,13 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
+from typing import Optional
 
+from gravitino.api.policy.policy_association_selector import 
PolicyAssociationSelector
+from gravitino.api.policy.policy_tag_association import PolicyTagAssociation
 from gravitino.api.tag.tag import Tag
 from gravitino.api.tag.tag_change import TagChange
+from gravitino.exceptions.base import UnsupportedOperationException
 
 
 class TagOperations(ABC):
@@ -137,3 +141,62 @@ class TagOperations(ABC):
             NoSuchMetalakeException: If the metalake does not exist.
         """
         pass
+
+    def list_policy_associations_for_tag(
+        self, tag_name: str
+    ) -> list[PolicyTagAssociation]:
+        """
+        List the policy associations of a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+
+        Returns:
+            list[PolicyTagAssociation]: The policy associations of the tag.
+
+        Raises:
+            UnsupportedOperationException: If the implementation does not
+                support policy associations for tags.
+        """
+        raise UnsupportedOperationException(
+            "listPolicyAssociationsForTag is not supported"
+        )
+
+    def add_policy_for_tag(
+        self,
+        tag_name: str,
+        policy_name: str,
+        selector: Optional[PolicyAssociationSelector] = None,
+    ) -> PolicyTagAssociation:
+        """
+        Associate a policy with a tag under a metalake. When no selector is
+        given, the association applies to all values of the tag.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+            selector (PolicyAssociationSelector): The selector scoping the
+                association, or None for all tag values.
+
+        Returns:
+            PolicyTagAssociation: The created policy association.
+
+        Raises:
+            UnsupportedOperationException: If the implementation does not
+                support policy associations for tags.
+        """
+        raise UnsupportedOperationException("addPolicyForTag is not supported")
+
+    def remove_policy_from_tag(self, tag_name: str, policy_name: str) -> None:
+        """
+        Remove the association between a policy and a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+
+        Raises:
+            UnsupportedOperationException: If the implementation does not
+                support policy associations for tags.
+        """
+        raise UnsupportedOperationException("removePolicyFromTag is not 
supported")
diff --git a/clients/client-python/gravitino/client/base_schema_catalog.py 
b/clients/client-python/gravitino/client/base_schema_catalog.py
index 9bc73213e0..b6343dcb68 100644
--- a/clients/client-python/gravitino/client/base_schema_catalog.py
+++ b/clients/client-python/gravitino/client/base_schema_catalog.py
@@ -28,6 +28,9 @@ from gravitino.api.function.function_definition import 
FunctionDefinition
 from gravitino.api.function.function_type import FunctionType
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.schema import Schema
 from gravitino.api.schema_change import SchemaChange
 from gravitino.api.secret import SecretBinding, SecretReference
@@ -40,6 +43,9 @@ from gravitino.client.generic_schema import GenericSchema
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 from gravitino.client.metadata_object_role_operations import (
     MetadataObjectRoleOperations,
 )
@@ -74,7 +80,8 @@ class BaseSchemaCatalog(
     FunctionCatalog,
     SupportsRoles,
     SupportsTags,
-):  # pylint: disable=too-many-ancestors
+    SupportsPolicies,
+):  # pylint: disable=too-many-ancestors,too-many-instance-attributes
     """
     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,
@@ -130,6 +137,9 @@ class BaseSchemaCatalog(
         self._object_tag_operations = MetadataObjectTagOperations(
             catalog_namespace.level(0), metadata_object, rest_client
         )
+        self._object_policy_operations = MetadataObjectPolicyOperations(
+            catalog_namespace.level(0), metadata_object, rest_client
+        )
         self._object_role_operations = MetadataObjectRoleOperations(
             catalog_namespace.level(0), metadata_object, rest_client
         )
@@ -437,6 +447,21 @@ class BaseSchemaCatalog(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> List[str]:
+        return self._object_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return self._object_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> List[Policy]:
+        return self._object_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return 
self._object_policy_operations.list_policy_infos_with_visibility()
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def supports_roles(self) -> SupportsRoles:
         return self
 
diff --git a/clients/client-python/gravitino/client/dto_converters.py 
b/clients/client-python/gravitino/client/dto_converters.py
index 801e91b584..488d15efff 100644
--- a/clients/client-python/gravitino/client/dto_converters.py
+++ b/clients/client-python/gravitino/client/dto_converters.py
@@ -34,6 +34,18 @@ from gravitino.api.job.job_template_change import (
 from gravitino.api.job.shell_job_template import ShellJobTemplate
 from gravitino.api.job.spark_job_template import SparkJobTemplate
 from gravitino.api.metalake_change import MetalakeChange
+from gravitino.api.policy.policy_change import PolicyChange
+from gravitino.api.policy.policy_content import (
+    AccessControlContent,
+    ColumnMaskContent,
+    IcebergDataCompactionContent,
+    IcebergOrphanFileRemovalContent,
+    IcebergRewriteManifestsContent,
+    IcebergSnapshotExpirationContent,
+    PolicyContent,
+    RowFilterContent,
+)
+from gravitino.api.policy.policy_contents import PolicyContents
 from gravitino.api.tag.tag_change import TagChange
 from gravitino.client.fileset_catalog import FilesetCatalog
 from gravitino.client.generic_model_catalog import GenericModelCatalog
@@ -47,6 +59,17 @@ from gravitino.dto.job.shell_template_update_dto import 
ShellTemplateUpdateDTO
 from gravitino.dto.job.spark_job_template_dto import SparkJobTemplateDTO
 from gravitino.dto.job.spark_template_update_dto import SparkTemplateUpdateDTO
 from gravitino.dto.job.template_update_dto import TemplateUpdateDTO
+from gravitino.dto.policy_dto import (
+    AccessControlContentDTO,
+    ColumnMaskContentDTO,
+    CustomContentDTO,
+    IcebergCompactionContentDTO,
+    IcebergOrphanFileRemovalContentDTO,
+    IcebergRewriteManifestsContentDTO,
+    IcebergSnapshotExpirationContentDTO,
+    PolicyContentDTO,
+    RowFilterContentDTO,
+)
 from gravitino.dto.requests.catalog_update_request import CatalogUpdateRequest
 from gravitino.dto.requests.job_template_update_request import (
     JobTemplateUpdateRequest,
@@ -55,6 +78,10 @@ from gravitino.dto.requests.job_template_update_request 
import (
     UpdateJobTemplateContentRequest,
 )
 from gravitino.dto.requests.metalake_update_request import 
MetalakeUpdateRequest
+from gravitino.dto.requests.policy_update_request import (
+    PolicyUpdateRequest,
+    PolicyUpdateRequestBase,
+)
 from gravitino.dto.requests.tag_update_request import (
     TagUpdateRequest,
     TagUpdateRequestBase,
@@ -340,6 +367,146 @@ class DTOConverters:
 
         raise IllegalArgumentException(f"Unknown change type: {type(change)}")
 
+    @staticmethod
+    def to_policy_update_request(
+        change: PolicyChange,
+    ) -> PolicyUpdateRequestBase:
+        """
+        Converts a PolicyChange to a PolicyUpdateRequestBase.
+
+        Args:
+            change (PolicyChange): the PolicyChange to convert.
+
+        Raises:
+            IllegalArgumentException: if the change is not a valid 
PolicyChange.
+
+        Returns:
+            PolicyUpdateRequestBase: the PolicyUpdateRequestBase corresponding
+                to the change.
+        """
+        if isinstance(change, PolicyChange.RenamePolicy):
+            return PolicyUpdateRequest.RenamePolicyRequest(change.new_name)
+
+        if isinstance(change, PolicyChange.UpdatePolicyComment):
+            return 
PolicyUpdateRequest.UpdatePolicyCommentRequest(change.new_comment)
+
+        if isinstance(change, PolicyChange.UpdateContent):
+            return PolicyUpdateRequest.UpdatePolicyContentRequest(
+                change.policy_type,
+                DTOConverters.to_policy_content_dto(change.content),
+            )
+
+        raise IllegalArgumentException(f"Unknown change type: {type(change)}")
+
+    @staticmethod
+    def to_policy_content_dto(content: PolicyContent) -> PolicyContentDTO:
+        """
+        Converts a domain PolicyContent to its PolicyContentDTO form.
+
+        Args:
+            content (PolicyContent): the policy content to convert.
+
+        Raises:
+            IllegalArgumentException: if the content type is not supported.
+
+        Returns:
+            PolicyContentDTO: the DTO corresponding to the content.
+        """
+        if isinstance(content, PolicyContents.CustomContent):
+            return CustomContentDTO(
+                _custom_rules=content.rules(),
+                _properties=content.properties(),
+                _supported_object_types=content.supported_object_types(),
+            )
+
+        if isinstance(content, IcebergDataCompactionContent):
+            spec = content.execution_spec()
+            return IcebergCompactionContentDTO(
+                _min_data_file_mse=content.min_data_file_mse(),
+                _min_delete_file_number=content.min_delete_file_number(),
+                _data_file_mse_weight=content.data_file_mse_weight(),
+                _delete_file_number_weight=content.delete_file_number_weight(),
+                _max_partition_num=content.max_partition_num(),
+                _rewrite_strategy=content.rewrite_strategy(),
+                _sort_order=content.sort_order(),
+                _rewrite_options=content.rewrite_options(),
+                _time_trigger=spec.time_trigger(),
+                _commit_trigger=spec.commit_trigger(),
+                _min_interval_ms=spec.min_interval_ms(),
+                _job_options=spec.job_options(),
+                _updater_options=spec.updater_options(),
+            )
+
+        if isinstance(content, IcebergOrphanFileRemovalContent):
+            spec = content.execution_spec()
+            return IcebergOrphanFileRemovalContentDTO(
+                _older_than_days=content.older_than_days(),
+                _location=content.location(),
+                _dry_run=content.dry_run(),
+                _time_trigger=spec.time_trigger(),
+                _commit_trigger=spec.commit_trigger(),
+                _min_interval_ms=spec.min_interval_ms(),
+                _job_options=spec.job_options(),
+                _updater_options=spec.updater_options(),
+            )
+
+        if isinstance(content, IcebergRewriteManifestsContent):
+            spec = content.execution_spec()
+            return IcebergRewriteManifestsContentDTO(
+                _manifest_count_critical=content.manifest_count_critical(),
+                _manifest_count_warning=content.manifest_count_warning(),
+                _avg_manifest_size_threshold_bytes=(
+                    content.avg_manifest_size_threshold_bytes()
+                ),
+                _spec_id=content.spec_id(),
+                _use_caching=content.use_caching(),
+                _time_trigger=spec.time_trigger(),
+                _commit_trigger=spec.commit_trigger(),
+                _min_interval_ms=spec.min_interval_ms(),
+                _job_options=spec.job_options(),
+                _updater_options=spec.updater_options(),
+            )
+
+        if isinstance(content, IcebergSnapshotExpirationContent):
+            spec = content.execution_spec()
+            return IcebergSnapshotExpirationContentDTO(
+                _older_than_days=content.older_than_days(),
+                _retain_last=content.retain_last(),
+                _stream_results=content.stream_results(),
+                _time_trigger=spec.time_trigger(),
+                _commit_trigger=spec.commit_trigger(),
+                _min_interval_ms=spec.min_interval_ms(),
+                _job_options=spec.job_options(),
+                _updater_options=spec.updater_options(),
+            )
+
+        if isinstance(content, RowFilterContent):
+            return RowFilterContentDTO(_expression=content.expression())
+
+        if isinstance(content, ColumnMaskContent):
+            return ColumnMaskContentDTO(_expression=content.expression())
+
+        if isinstance(content, AccessControlContent):
+            return AccessControlContentDTO(
+                _privileges=content.privileges(),
+                _applicable_roles=content.applicable_roles(),
+            )
+
+        raise IllegalArgumentException(f"Unknown policy content type: 
{type(content)}")
+
+    @staticmethod
+    def from_policy_content_dto(dto: PolicyContentDTO) -> PolicyContent:
+        """
+        Converts a PolicyContentDTO to its domain PolicyContent form.
+
+        Args:
+            dto (PolicyContentDTO): the policy content DTO to convert.
+
+        Returns:
+            PolicyContent: the domain content corresponding to the DTO.
+        """
+        return dto.to_domain_content()
+
     @staticmethod
     def to_privilege_dto(privilege: Privilege) -> PrivilegeDTO:
         return PrivilegeDTO(privilege.name(), privilege.condition())
diff --git a/clients/client-python/gravitino/client/generic_fileset.py 
b/clients/client-python/gravitino/client/generic_fileset.py
index c4ed5569b2..5d7c079fde 100644
--- a/clients/client-python/gravitino/client/generic_fileset.py
+++ b/clients/client-python/gravitino/client/generic_fileset.py
@@ -22,6 +22,9 @@ from gravitino.api.credential.supports_credentials import 
SupportsCredentials
 from gravitino.api.file.fileset import Fileset
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.secret.supports_secrets import SupportsSecrets
 from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.api.tag.tag import Tag
@@ -29,6 +32,9 @@ from gravitino.api.tag.tag_list import TagList
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 from gravitino.client.metadata_object_role_operations import (
     MetadataObjectRoleOperations,
 )
@@ -48,7 +54,8 @@ class GenericFileset(
     SupportsRoles,
     SupportsSecrets,
     SupportsTags,
-):
+    SupportsPolicies,
+):  # pylint: disable=too-many-ancestors
     _fileset: FilesetDTO
     """The fileset data transfer object"""
 
@@ -75,6 +82,9 @@ class GenericFileset(
         self._object_tag_operations = MetadataObjectTagOperations(
             full_namespace.level(0), metadata_object, rest_client
         )
+        self._object_policy_operations = MetadataObjectPolicyOperations(
+            full_namespace.level(0), metadata_object, rest_client
+        )
         self._object_role_operations = MetadataObjectRoleOperations(
             full_namespace.level(0), metadata_object, rest_client
         )
@@ -103,6 +113,21 @@ class GenericFileset(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> List[str]:
+        return self._object_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return self._object_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> List[Policy]:
+        return self._object_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return 
self._object_policy_operations.list_policy_infos_with_visibility()
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def supports_roles(self) -> SupportsRoles:
         return self
 
diff --git a/clients/client-python/gravitino/client/generic_model.py 
b/clients/client-python/gravitino/client/generic_model.py
index 9ca708eb20..ee78f2f62a 100644
--- a/clients/client-python/gravitino/client/generic_model.py
+++ b/clients/client-python/gravitino/client/generic_model.py
@@ -23,9 +23,15 @@ 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
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.secret.supports_secrets import SupportsSecrets
 from gravitino.api.tag import Tag, TagList
 from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 from gravitino.client.metadata_object_secret_operations import (
     MetadataObjectSecretOperations,
 )
@@ -37,7 +43,7 @@ from gravitino.namespace import Namespace
 from gravitino.utils import HTTPClient
 
 
-class GenericModel(Model, SupportsTags, SupportsSecrets):
+class GenericModel(Model, SupportsTags, SupportsPolicies, SupportsSecrets):
     _model_dto: ModelDTO
     """The model DTO object."""
 
@@ -52,6 +58,9 @@ class GenericModel(Model, SupportsTags, SupportsSecrets):
         self._model_tag_operations = MetadataObjectTagOperations(
             model_ns.level(0), model_object, rest_client
         )
+        self._model_policy_operations = MetadataObjectPolicyOperations(
+            model_ns.level(0), model_object, rest_client
+        )
         self._object_secret_operations = MetadataObjectSecretOperations(
             model_ns.level(0), model_object, rest_client
         )
@@ -110,6 +119,21 @@ class GenericModel(Model, SupportsTags, SupportsSecrets):
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> list[str]:
+        return self._model_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return self._model_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> list[Policy]:
+        return self._model_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return 
self._model_policy_operations.list_policy_infos_with_visibility()
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def support_secrets(self) -> SupportsSecrets:
         return self
 
diff --git a/clients/client-python/gravitino/client/generic_policy.py 
b/clients/client-python/gravitino/client/generic_policy.py
new file mode 100644
index 0000000000..3e1af773b4
--- /dev/null
+++ b/clients/client-python/gravitino/client/generic_policy.py
@@ -0,0 +1,103 @@
+# 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.
+
+from typing import Optional
+
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_content import PolicyContent
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.policy_dto import PolicyDTO
+
+
+class GenericPolicy(Policy):
+    """Represents a generic policy, wrapping a PolicyDTO and exposing its
+    content as the domain policy content."""
+
+    def __init__(self, policy_dto: PolicyDTO) -> None:
+        self._policy_dto = policy_dto
+        content_dto = policy_dto.content()
+        self._content = (
+            content_dto.to_domain_content() if content_dto is not None else 
None
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GenericPolicy):
+            return False
+
+        return self._policy_dto == other._policy_dto
+
+    def __hash__(self) -> int:
+        return hash(self._policy_dto)
+
+    def name(self) -> str:
+        """Get the name of the policy.
+
+        Returns:
+            str: The name of the policy.
+        """
+        return self._policy_dto.name()
+
+    def policy_type(self) -> str:
+        """Get the type of the policy.
+
+        Returns:
+            str: The type of the policy.
+        """
+        return self._policy_dto.policy_type()
+
+    def comment(self) -> Optional[str]:
+        """Get the comment of the policy.
+
+        Returns:
+            Optional[str]: The comment of the policy.
+        """
+        return self._policy_dto.comment()
+
+    def enabled(self) -> bool:
+        """Check whether the policy is enabled.
+
+        Returns:
+            bool: True if the policy is enabled, False otherwise.
+        """
+        return self._policy_dto.enabled()
+
+    def content(self) -> PolicyContent:
+        """Get the content of the policy, converted to the domain policy
+        content type determined by the policy type.
+
+        Returns:
+            PolicyContent: The content of the policy.
+        """
+        return self._content
+
+    def inherited(self) -> Optional[bool]:
+        """Check if the policy is inherited from a parent object or not.
+
+        Returns:
+            Optional[bool]: True if the policy is inherited, False if it is
+                owned by the object itself, None if the policy is not
+                associated with any object.
+        """
+        return self._policy_dto.inherited()
+
+    def audit_info(self) -> AuditDTO:
+        """Retrieve the audit information of the entity.
+
+        Returns:
+            AuditDTO: The audit information of the entity.
+        """
+        return self._policy_dto.audit_info()
diff --git 
a/clients/client-python/gravitino/client/generic_policy_tag_association.py 
b/clients/client-python/gravitino/client/generic_policy_tag_association.py
new file mode 100644
index 0000000000..5f5b63d2d4
--- /dev/null
+++ b/clients/client-python/gravitino/client/generic_policy_tag_association.py
@@ -0,0 +1,72 @@
+# 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.
+
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_association_selector import 
PolicyAssociationSelector
+from gravitino.api.policy.policy_tag_association import PolicyTagAssociation
+from gravitino.api.tag.tag import Tag
+
+
+class GenericPolicyTagAssociation(PolicyTagAssociation):
+    """Represents a generic association between a policy and a tag."""
+
+    def __init__(
+        self,
+        policy: Policy,
+        tag: Tag,
+        selector: PolicyAssociationSelector,
+    ) -> None:
+        self._policy = policy
+        self._tag = tag
+        self._selector = selector
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GenericPolicyTagAssociation):
+            return False
+
+        return (
+            self._policy == other._policy
+            and self._tag == other._tag
+            and self._selector == other._selector
+        )
+
+    def __hash__(self) -> int:
+        return hash((self._policy, self._tag, self._selector))
+
+    def policy(self) -> Policy:
+        """Get the associated policy.
+
+        Returns:
+            Policy: The associated policy.
+        """
+        return self._policy
+
+    def tag(self) -> Tag:
+        """Get the associated tag.
+
+        Returns:
+            Tag: The associated tag.
+        """
+        return self._tag
+
+    def selector(self) -> PolicyAssociationSelector:
+        """Get the selector scoping the association.
+
+        Returns:
+            PolicyAssociationSelector: The selector of the association.
+        """
+        return self._selector
diff --git a/clients/client-python/gravitino/client/generic_schema.py 
b/clients/client-python/gravitino/client/generic_schema.py
index e3cd6b45bb..cd31d684d9 100644
--- a/clients/client-python/gravitino/client/generic_schema.py
+++ b/clients/client-python/gravitino/client/generic_schema.py
@@ -23,11 +23,17 @@ 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.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 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.api.tag.tag_list import TagList
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 from gravitino.client.metadata_object_role_operations import (
     MetadataObjectRoleOperations,
 )
@@ -43,6 +49,7 @@ class GenericSchema(
     Schema,
     SupportsRoles,
     SupportsTags,
+    SupportsPolicies,
     SupportsSecrets,
 ):
     def __init__(
@@ -65,6 +72,11 @@ class GenericSchema(
             metadata_object,
             rest_client,
         )
+        self._metadata_object_policy_operations = 
MetadataObjectPolicyOperations(
+            metalake,
+            metadata_object,
+            rest_client,
+        )
         self._metadata_object_role_operations = MetadataObjectRoleOperations(
             metalake,
             metadata_object,
@@ -131,6 +143,23 @@ class GenericSchema(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> list[str]:
+        return self._metadata_object_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return 
self._metadata_object_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> list[Policy]:
+        return self._metadata_object_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return (
+            
self._metadata_object_policy_operations.list_policy_infos_with_visibility()
+        )
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def supports_roles(self) -> SupportsRoles:
         return self
 
diff --git a/clients/client-python/gravitino/client/generic_semantic_model.py 
b/clients/client-python/gravitino/client/generic_semantic_model.py
index 08251dacea..1608021a03 100644
--- a/clients/client-python/gravitino/client/generic_semantic_model.py
+++ b/clients/client-python/gravitino/client/generic_semantic_model.py
@@ -20,18 +20,24 @@ 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.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 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.api.tag.tag_list import TagList
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 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, SupportsTags):
+class GenericSemanticModel(SemanticModel, SupportsTags, SupportsPolicies):
     """An immutable client-side Semantic Model returned by the Gravitino REST 
API."""
 
     def __init__(
@@ -59,6 +65,9 @@ class GenericSemanticModel(SemanticModel, SupportsTags):
         self._model_tag_operations = MetadataObjectTagOperations(
             semantic_model_ns.level(0), model_object, rest_client
         )
+        self._model_policy_operations = MetadataObjectPolicyOperations(
+            semantic_model_ns.level(0), model_object, rest_client
+        )
 
     def name(self) -> str:
         return self._name
@@ -99,6 +108,21 @@ class GenericSemanticModel(SemanticModel, SupportsTags):
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> list[str]:
+        return self._model_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return self._model_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> list[Policy]:
+        return self._model_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return 
self._model_policy_operations.list_policy_infos_with_visibility()
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def list_tags(self) -> list[str]:
         return self._model_tag_operations.list_tags()
 
diff --git a/clients/client-python/gravitino/client/gravitino_client.py 
b/clients/client-python/gravitino/client/gravitino_client.py
index 030c4ec15a..971c516894 100644
--- a/clients/client-python/gravitino/client/gravitino_client.py
+++ b/clients/client-python/gravitino/client/gravitino_client.py
@@ -33,6 +33,10 @@ 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.policy.policy import Policy
+from gravitino.api.policy.policy_association_selector import 
PolicyAssociationSelector
+from gravitino.api.policy.policy_operations import PolicyOperations
+from gravitino.api.policy.policy_tag_association import PolicyTagAssociation
 from gravitino.api.secret import SecretBinding, SecretReference
 from gravitino.api.tag.tag_operations import TagOperations
 from gravitino.auth.auth_data_provider import AuthDataProvider
@@ -45,7 +49,9 @@ _EMPTY_SECRET_BINDINGS: Mapping[str, SecretBinding] = 
MappingProxyType({})
 _EMPTY_SECRET_REFERENCES: Mapping[str, SecretReference] = MappingProxyType({})
 
 
-class GravitinoClient(GravitinoClientBase, SupportsJobs, TagOperations):
+class GravitinoClient(
+    GravitinoClientBase, SupportsJobs, TagOperations, PolicyOperations
+):
     """Gravitino Client for a user to interact with the Gravitino API, 
allowing the client to list,
     load, create, and alter Catalog.
 
@@ -361,6 +367,186 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs, 
TagOperations):
         """
         return self.get_metalake().delete_tag(tag_name)
 
+    def list_policy_associations_for_tag(self, tag_name) -> 
list[PolicyTagAssociation]:
+        """
+        List the policy associations of a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+
+        Returns:
+            list[PolicyTagAssociation]: The policy associations of the tag.
+        """
+        return self.get_metalake().list_policy_associations_for_tag(tag_name)
+
+    def add_policy_for_tag(
+        self, tag_name, policy_name, selector: PolicyAssociationSelector = None
+    ) -> PolicyTagAssociation:
+        """
+        Associate a policy with a tag under a metalake. When no selector is
+        given, the association applies to all values of the tag.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+            selector (PolicyAssociationSelector): The selector scoping the
+                association, or None for all tag values.
+
+        Returns:
+            PolicyTagAssociation: The created policy association.
+        """
+        return self.get_metalake().add_policy_for_tag(tag_name, policy_name, 
selector)
+
+    def remove_policy_from_tag(self, tag_name, policy_name) -> None:
+        """
+        Remove the association between a policy and a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+        """
+        return self.get_metalake().remove_policy_from_tag(tag_name, 
policy_name)
+
+    # Policy operations
+    def list_policies(self) -> list[str]:
+        """List all the policy names under a metalake.
+
+        Returns:
+            list[str]: The list of policy names.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().list_policies()
+
+    def list_policy_infos(self) -> list[Policy]:
+        """
+        List policies information under a metalake.
+
+        Returns:
+            list[Policy]: The list of policy information.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().list_policy_infos()
+
+    def get_policy(self, policy_name) -> Policy:
+        """
+        Get a policy by its name under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            Policy: The policy information.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        return self.get_metalake().get_policy(policy_name)
+
+    def create_policy(
+        self, policy_name, policy_type, comment, enabled, content
+    ) -> Policy:
+        """
+        Create a new policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+            policy_type (str): The type of the policy.
+            comment (str): The comment of the policy.
+            enabled (bool): Whether the policy is enabled.
+            content (PolicyContent): The content of the policy.
+
+        Returns:
+            Policy: The policy information.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+            PolicyAlreadyExistsException: If the policy already exists.
+        """
+        return self.get_metalake().create_policy(
+            policy_name, policy_type, comment, enabled, content
+        )
+
+    def enable_policy(self, policy_name) -> None:
+        """
+        Enable a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        return self.get_metalake().enable_policy(policy_name)
+
+    def disable_policy(self, policy_name) -> None:
+        """
+        Disable a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        return self.get_metalake().disable_policy(policy_name)
+
+    def alter_policy(self, policy_name, *changes) -> Policy:
+        """
+        Alter a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+            changes (PolicyChange): The changes to apply to the policy.
+
+        Returns:
+            Policy: The altered policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+            IllegalArgumentException: If the changes cannot be applied to
+                the policy.
+            PolicyAlreadyExistsException: If a policy with the new name
+                already exists.
+        """
+        return self.get_metalake().alter_policy(policy_name, *changes)
+
+    def delete_policy(self, policy_name) -> bool:
+        """
+        Delete a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            bool: True if the policy was deleted, False otherwise.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().delete_policy(policy_name)
+
+    def list_tag_associations_for_policy(
+        self, policy_name
+    ) -> list[PolicyTagAssociation]:
+        """
+        List the tag associations of a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            list[PolicyTagAssociation]: The tag associations of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        return 
self.get_metalake().list_tag_associations_for_policy(policy_name)
+
     # Owner operations
     def get_owner(self, metadata_object: MetadataObject) -> Optional[Owner]:
         """Get the owner of a metadata object.
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py 
b/clients/client-python/gravitino/client/gravitino_metalake.py
index 42eac86e61..8dbe7a8f5d 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -34,12 +34,25 @@ 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.policy.policy import Policy
+from gravitino.api.policy.policy_association_selector import (
+    AllValuesSelector,
+    PolicyAssociationSelector,
+)
+from gravitino.api.policy.policy_change import PolicyChange
+from gravitino.api.policy.policy_content import PolicyContent
+from gravitino.api.policy.policy_operations import PolicyOperations
+from gravitino.api.policy.policy_tag_association import PolicyTagAssociation
 from gravitino.api.secret import SecretBinding, SecretReference
 from gravitino.api.secret.supports_secrets import SupportsSecrets
 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_policy import GenericPolicy
+from gravitino.client.generic_policy_tag_association import (
+    GenericPolicyTagAssociation,
+)
 from gravitino.client.generic_tag import GenericTag
 from gravitino.client.metadata_object_role_operations import (
     MetadataObjectRoleOperations,
@@ -58,7 +71,12 @@ from gravitino.dto.requests.job_template_register_request 
import (
 from gravitino.dto.requests.job_template_updates_request import (
     JobTemplateUpdatesRequest,
 )
+from gravitino.dto.policy_dto import PolicyAssociationSelectorDTO
 from gravitino.dto.requests.owner_set_request import OwnerSetRequest
+from gravitino.dto.requests.policy_create_request import PolicyCreateRequest
+from gravitino.dto.requests.policy_set_request import PolicySetRequest
+from gravitino.dto.requests.policy_tag_add_request import PolicyTagAddRequest
+from gravitino.dto.requests.policy_updates_request import PolicyUpdatesRequest
 from gravitino.dto.requests.tag_create_request import TagCreateRequest
 from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
 from gravitino.dto.requests.user_add_request import UserAddRequest
@@ -79,6 +97,14 @@ from gravitino.dto.responses.job_response import JobResponse
 from gravitino.dto.responses.job_template_list_response import 
JobTemplateListResponse
 from gravitino.dto.responses.job_template_response import JobTemplateResponse
 from gravitino.dto.responses.owner_response import OwnerResponse
+from gravitino.dto.responses.policy_response import (
+    PolicyForTagAssociationListResponse,
+    PolicyListResponse,
+    PolicyNameListResponse,
+    PolicyResponse,
+    PolicyTagAssociationResponse,
+    TagForPolicyAssociationListResponse,
+)
 from gravitino.dto.responses.remove_response import RemoveResponse
 from gravitino.dto.responses.set_response import SetResponse
 from gravitino.dto.responses.tag_response import (
@@ -108,6 +134,7 @@ from gravitino.exceptions.handlers.permission_error_handler 
import (
 from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER
 from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
 from gravitino.exceptions.handlers.owner_error_handler import 
OWNER_ERROR_HANDLER
+from gravitino.exceptions.handlers.policy_error_handler import 
POLICY_ERROR_HANDLER
 from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
 from gravitino.exceptions.handlers.user_error_handler import USER_ERROR_HANDLER
 from gravitino.rest.rest_utils import encode_string
@@ -126,6 +153,7 @@ class GravitinoMetalake(
     SupportsJobs,
     SupportsRoles,
     TagOperations,
+    PolicyOperations,
     SupportsSecrets,
 ):  # pylint: disable=too-many-ancestors
     """
@@ -142,6 +170,11 @@ class GravitinoMetalake(
     API_METALAKES_OWNERS_PATH = "api/metalakes/{}/owners/{}"
     API_METALAKES_TAG_PATH = "api/metalakes/{}/tags/{}"
     API_METALAKES_TAGS_PATH = "api/metalakes/{}/tags"
+    API_METALAKES_POLICIES_PATH = "api/metalakes/{}/policies"
+    API_METALAKES_POLICY_PATH = "api/metalakes/{}/policies/{}"
+    API_METALAKES_POLICY_TAGS_PATH = "api/metalakes/{}/policies/{}/tags"
+    API_METALAKES_TAG_POLICIES_PATH = "api/metalakes/{}/tags/{}/policies"
+    API_METALAKES_TAG_POLICY_PATH = "api/metalakes/{}/tags/{}/policies/{}"
 
     # Authorization paths
     API_METALAKES_USERS_PATH = "api/metalakes/{}/users"
@@ -846,6 +879,377 @@ class GravitinoMetalake(
 
         return drop_response.dropped()
 
+    def list_policy_associations_for_tag(self, tag_name) -> 
list[PolicyTagAssociation]:
+        """
+        List the policy associations of a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+
+        Returns:
+            list[PolicyTagAssociation]: The policy associations of the tag.
+
+        Raises:
+            NoSuchTagException: If the tag does not exist.
+        """
+        url = self.API_METALAKES_TAG_POLICIES_PATH.format(
+            encode_string(self.name()), encode_string(tag_name)
+        )
+        response = self.rest_client.get(
+            url,
+            params={"details": "true"},
+            error_handler=TAG_ERROR_HANDLER,
+        )
+        resp = PolicyForTagAssociationListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        resp.validate()
+
+        tag = self.get_tag(tag_name)
+        return [
+            GenericPolicyTagAssociation(
+                GenericPolicy(association.policy()),
+                tag,
+                association.selector().to_selector(),
+            )
+            for association in resp.associations()
+        ]
+
+    def add_policy_for_tag(
+        self,
+        tag_name,
+        policy_name,
+        selector: PolicyAssociationSelector = None,
+    ) -> PolicyTagAssociation:
+        """
+        Associate a policy with a tag under a metalake. When no selector is
+        given, the association applies to all values of the tag.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+            selector (PolicyAssociationSelector): The selector scoping the
+                association, or None for all tag values.
+
+        Returns:
+            PolicyTagAssociation: The created policy association.
+
+        Raises:
+            NoSuchTagException: If the tag does not exist.
+            NoSuchPolicyException: If the policy does not exist.
+            PolicyAlreadyAssociatedException: If the policy is already
+                associated with the tag.
+        """
+        if selector is None:
+            selector = AllValuesSelector.get()
+        add_request = PolicyTagAddRequest(
+            PolicyAssociationSelectorDTO.from_selector(selector)
+        )
+        add_request.validate()
+
+        url = self.API_METALAKES_TAG_POLICY_PATH.format(
+            encode_string(self.name()),
+            encode_string(tag_name),
+            encode_string(policy_name),
+        )
+        response = self.rest_client.post(
+            url,
+            json=add_request,
+            error_handler=TAG_ERROR_HANDLER,
+        )
+        resp = PolicyTagAssociationResponse.from_json(response.body, 
infer_missing=True)
+        resp.validate()
+
+        return GenericPolicyTagAssociation(
+            self.get_policy(resp.policy()),
+            self.get_tag(resp.tag()),
+            resp.selector().to_selector(),
+        )
+
+    def remove_policy_from_tag(self, tag_name, policy_name) -> None:
+        """
+        Remove the association between a policy and a tag under a metalake.
+
+        Args:
+            tag_name (str): The name of the tag.
+            policy_name (str): The name of the policy.
+
+        Raises:
+            NoSuchTagException: If the tag does not exist.
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        url = self.API_METALAKES_TAG_POLICY_PATH.format(
+            encode_string(self.name()),
+            encode_string(tag_name),
+            encode_string(policy_name),
+        )
+        # The server returns HTTP 204 with no body on success, so the
+        # response body must not be parsed here.
+        self.rest_client.delete(
+            url,
+            error_handler=TAG_ERROR_HANDLER,
+        )
+
+    #########
+    # Policy operations
+    #########
+    def list_policies(self) -> list[str]:
+        """List all the policy names under a metalake.
+
+        Returns:
+            list[str]: The list of policy names.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        url = 
self.API_METALAKES_POLICIES_PATH.format(encode_string(self.name()))
+
+        response = self.rest_client.get(url, 
error_handler=POLICY_ERROR_HANDLER)
+        resp = PolicyNameListResponse.from_json(response.body, 
infer_missing=True)
+        resp.validate()
+
+        return resp.names
+
+    def list_policy_infos(self) -> List[Policy]:
+        """
+        List policies information under a metalake.
+
+        Returns:
+            list[Policy]: The list of policy information.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        url = 
self.API_METALAKES_POLICIES_PATH.format(encode_string(self.name()))
+
+        response = self.rest_client.get(
+            url,
+            params={"details": "true"},
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        list_info_resp = PolicyListResponse.from_json(response.body, 
infer_missing=True)
+        list_info_resp.validate()
+
+        return [GenericPolicy(policy_dto) for policy_dto in 
list_info_resp.policies()]
+
+    def get_policy(self, policy_name) -> Policy:
+        """
+        Get a policy by its name under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            Policy: The policy information.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        Precondition.check_string_not_empty(
+            policy_name, "policy name must not be null or empty"
+        )
+        url = self.API_METALAKES_POLICY_PATH.format(
+            encode_string(self.name()), encode_string(policy_name)
+        )
+        response = self.rest_client.get(url, 
error_handler=POLICY_ERROR_HANDLER)
+
+        policy_resp = PolicyResponse.from_json(response.body, 
infer_missing=True)
+        policy_resp.validate()
+
+        return GenericPolicy(policy_resp.policy())
+
+    def create_policy(
+        self,
+        policy_name,
+        policy_type,
+        comment,
+        enabled,
+        content: PolicyContent,
+    ) -> Policy:
+        """
+        Create a new policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+            policy_type (str): The type of the policy.
+            comment (str): The comment of the policy.
+            enabled (bool): Whether the policy is enabled.
+            content (PolicyContent): The content of the policy.
+
+        Returns:
+            Policy: The policy information.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+            PolicyAlreadyExistsException: If the policy already exists.
+        """
+        policy_create_request = PolicyCreateRequest(
+            _name=policy_name,
+            _policy_type=policy_type,
+            _comment=comment,
+            _enabled=enabled,
+            _content=DTOConverters.to_policy_content_dto(content),
+        )
+        policy_create_request.validate()
+
+        url = 
self.API_METALAKES_POLICIES_PATH.format(encode_string(self.name()))
+
+        response = self.rest_client.post(
+            url,
+            json=policy_create_request,
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        policy_resp = PolicyResponse.from_json(response.body, 
infer_missing=True)
+        policy_resp.validate()
+
+        return GenericPolicy(policy_resp.policy())
+
+    def enable_policy(self, policy_name) -> None:
+        """
+        Enable a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        self._set_policy_enabled(policy_name, True)
+
+    def disable_policy(self, policy_name) -> None:
+        """
+        Disable a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        self._set_policy_enabled(policy_name, False)
+
+    def _set_policy_enabled(self, policy_name, enabled: bool) -> None:
+        url = self.API_METALAKES_POLICY_PATH.format(
+            encode_string(self.name()), encode_string(policy_name)
+        )
+        self.rest_client.patch(
+            url,
+            json=PolicySetRequest(enabled),
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+
+    def alter_policy(self, policy_name, *changes: PolicyChange) -> Policy:
+        """
+        Alter a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+            changes (PolicyChange): The changes to apply to the policy.
+
+        Returns:
+            Policy: The altered policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+            IllegalArgumentException: If the changes cannot be applied to
+                the policy.
+            PolicyAlreadyExistsException: If a policy with the new name
+                already exists.
+        """
+        Precondition.check_argument(
+            StringUtils.is_not_blank(policy_name),
+            "policy name must not be null or empty",
+        )
+        Precondition.check_argument(
+            changes is not None and len(changes) > 0,
+            "at least one change is required",
+        )
+        updates = [DTOConverters.to_policy_update_request(change) for change 
in changes]
+        update_req = PolicyUpdatesRequest(updates)
+        update_req.validate()
+
+        url = self.API_METALAKES_POLICY_PATH.format(
+            encode_string(self.name()), encode_string(policy_name)
+        )
+        response = self.rest_client.put(
+            url,
+            json=update_req,
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        policy_resp = PolicyResponse.from_json(response.body, 
infer_missing=True)
+
+        policy_resp.validate()
+        return GenericPolicy(policy_resp.policy())
+
+    def delete_policy(self, policy_name) -> bool:
+        """
+        Delete a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            bool: True if the policy was deleted, False otherwise.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        Precondition.check_argument(
+            StringUtils.is_not_blank(policy_name),
+            "policy name must not be null or empty",
+        )
+
+        url = self.API_METALAKES_POLICY_PATH.format(
+            encode_string(self.name()), encode_string(policy_name)
+        )
+        response = self.rest_client.delete(
+            url,
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        drop_response = DropResponse.from_json(response.body, 
infer_missing=True)
+        drop_response.validate()
+
+        return drop_response.dropped()
+
+    def list_tag_associations_for_policy(
+        self, policy_name
+    ) -> list[PolicyTagAssociation]:
+        """
+        List the tag associations of a policy under a metalake.
+
+        Args:
+            policy_name (str): The name of the policy.
+
+        Returns:
+            list[PolicyTagAssociation]: The tag associations of the policy.
+
+        Raises:
+            NoSuchPolicyException: If the policy does not exist.
+        """
+        url = self.API_METALAKES_POLICY_TAGS_PATH.format(
+            encode_string(self.name()), encode_string(policy_name)
+        )
+        response = self.rest_client.get(
+            url,
+            params={"details": "true"},
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        resp = TagForPolicyAssociationListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        resp.validate()
+
+        policy = self.get_policy(policy_name)
+        return [
+            GenericPolicyTagAssociation(
+                policy,
+                GenericTag(self.name(), association.tag(), self.rest_client),
+                association.selector().to_selector(),
+            )
+            for association in resp.associations()
+        ]
+
     #########
     # Owner operations
     #########
diff --git 
a/clients/client-python/gravitino/client/metadata_object_policy_operations.py 
b/clients/client-python/gravitino/client/metadata_object_policy_operations.py
new file mode 100644
index 0000000000..377b69c106
--- /dev/null
+++ 
b/clients/client-python/gravitino/client/metadata_object_policy_operations.py
@@ -0,0 +1,97 @@
+# 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.
+
+from __future__ import annotations
+
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
+from gravitino.client.generic_policy import GenericPolicy
+from gravitino.dto.responses.policy_response import (
+    MetadataObjectPolicyListResponse,
+    PolicyNameListResponse,
+)
+from gravitino.exceptions.handlers.policy_error_handler import 
POLICY_ERROR_HANDLER
+from gravitino.rest.rest_utils import encode_string
+from gravitino.utils.http_client import HTTPClient
+
+
+class MetadataObjectPolicyOperations(SupportsPolicies):
+    """
+    The implementation of SupportsPolicies. This helper is composed into
+    metadata objects to provide policy operations for these objects.
+    """
+
+    POLICY_REQUEST_PATH = "api/metalakes/{}/objects/{}/{}/policies"
+
+    def __init__(
+        self,
+        metalake_name: str,
+        metadata_object: MetadataObject,
+        rest_client: HTTPClient,
+    ):
+        super().__init__()
+        self.metalake_name = metalake_name
+        self.metadata_object = metadata_object
+        self.rest_client = rest_client
+        self.policy_request_path = (
+            MetadataObjectPolicyOperations.POLICY_REQUEST_PATH.format(
+                encode_string(metalake_name),
+                metadata_object.type().name.lower(),
+                encode_string(metadata_object.full_name()),
+            )
+        )
+
+    def list_policies(self) -> list[str]:
+        return self.list_policies_with_visibility().policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        response = self.rest_client.get(
+            self.policy_request_path,
+            params={},
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+        list_policies_resp = PolicyNameListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        list_policies_resp.validate()
+
+        return PolicyList(
+            list_policies_resp.names,
+            list_policies_resp.has_inaccessible_policies(),
+        )
+
+    def list_policy_infos(self) -> list[Policy]:
+        return self.list_policy_infos_with_visibility().policies()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        response = self.rest_client.get(
+            self.policy_request_path,
+            params={"details": "true"},
+            error_handler=POLICY_ERROR_HANDLER,
+        )
+
+        list_info_resp = MetadataObjectPolicyListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        list_info_resp.validate()
+
+        return PolicyList(
+            [GenericPolicy(policy_dto) for policy_dto in 
list_info_resp.policies()],
+            list_info_resp.has_inaccessible_policies(),
+        )
diff --git a/clients/client-python/gravitino/client/relational_table.py 
b/clients/client-python/gravitino/client/relational_table.py
index 0ba96431ef..f753158c03 100644
--- a/clients/client-python/gravitino/client/relational_table.py
+++ b/clients/client-python/gravitino/client/relational_table.py
@@ -21,6 +21,9 @@ 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.policy.policy import Policy
+from gravitino.api.policy.policy_list import PolicyList
+from gravitino.api.policy.supports_policies import SupportsPolicies
 from gravitino.api.rel.column import Column
 from gravitino.api.rel.expressions.distributions.distribution import 
Distribution
 from gravitino.api.rel.expressions.sorts.sort_order import SortOrder
@@ -36,6 +39,9 @@ from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.api.tag.tag import Tag
 from gravitino.api.tag.tag_list import TagList
 from gravitino.client.generic_column import GenericColumn
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
 from gravitino.client.metadata_object_role_operations import (
     MetadataObjectRoleOperations,
 )
@@ -69,8 +75,9 @@ class RelationalTable(
     SupportsRoles,
     SupportsStatistics,
     SupportsTags,
+    SupportsPolicies,
     SupportsSecrets,
-):
+):  # pylint: disable=too-many-ancestors,too-many-instance-attributes
     """Represents a relational table."""
 
     def __init__(
@@ -86,6 +93,9 @@ class RelationalTable(
         self._object_tag_operations = MetadataObjectTagOperations(
             namespace.level(0), table_object, rest_client
         )
+        self._object_policy_operations = MetadataObjectPolicyOperations(
+            namespace.level(0), table_object, rest_client
+        )
         self._object_role_operations = MetadataObjectRoleOperations(
             namespace.level(0), table_object, rest_client
         )
@@ -290,6 +300,21 @@ class RelationalTable(
     def supports_tags(self) -> SupportsTags:
         return self
 
+    def list_policies(self) -> list[str]:
+        return self._object_policy_operations.list_policies()
+
+    def list_policies_with_visibility(self) -> PolicyList[str]:
+        return self._object_policy_operations.list_policies_with_visibility()
+
+    def list_policy_infos(self) -> list[Policy]:
+        return self._object_policy_operations.list_policy_infos()
+
+    def list_policy_infos_with_visibility(self) -> PolicyList[Policy]:
+        return 
self._object_policy_operations.list_policy_infos_with_visibility()
+
+    def supports_policies(self) -> SupportsPolicies:
+        return self
+
     def supports_roles(self) -> SupportsRoles:
         return self
 
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/policy_error_handler.py
similarity index 71%
copy from 
clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
copy to 
clients/client-python/gravitino/exceptions/handlers/policy_error_handler.py
index ae82e17c83..e799cedfe3 100644
--- a/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
+++ 
b/clients/client-python/gravitino/exceptions/handlers/policy_error_handler.py
@@ -6,7 +6,7 @@
 # "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
+#   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
@@ -20,29 +20,31 @@ from gravitino.exceptions.base import (
     IllegalArgumentException,
     MetalakeNotInUseException,
     NoSuchMetalakeException,
-    NoSuchTagException,
-    TagAlreadyAssociatedException,
-    TagAlreadyExistsException,
+    NoSuchPolicyException,
+    PolicyAlreadyAssociatedException,
+    PolicyAlreadyExistsException,
 )
 from gravitino.exceptions.handlers.rest_error_handler import 
CodeMappingErrorHandler
 
 
-class TagErrorHandler(CodeMappingErrorHandler):
-    """Error handler specific to Tag operations."""
+class PolicyErrorHandler(CodeMappingErrorHandler):
+    """Error handler specific to Policy operations."""
 
     _code_exception_map = {
         ErrorConstants.ILLEGAL_ARGUMENTS_CODE: IllegalArgumentException,
         ErrorConstants.NOT_FOUND_CODE: {
             NoSuchMetalakeException.__name__: NoSuchMetalakeException,
-            NoSuchTagException.__name__: NoSuchTagException,
+            NoSuchPolicyException.__name__: NoSuchPolicyException,
         },
         ErrorConstants.ALREADY_EXISTS_CODE: {
-            TagAlreadyExistsException.__name__: TagAlreadyExistsException,
-            TagAlreadyAssociatedException.__name__: 
TagAlreadyAssociatedException,
+            PolicyAlreadyExistsException.__name__: 
PolicyAlreadyExistsException,
+            PolicyAlreadyAssociatedException.__name__: (
+                PolicyAlreadyAssociatedException
+            ),
         },
         ErrorConstants.NOT_IN_USE_CODE: MetalakeNotInUseException,
         ErrorConstants.INTERNAL_ERROR_CODE: RuntimeError,
     }
 
 
-TAG_ERROR_HANDLER = TagErrorHandler()
+POLICY_ERROR_HANDLER = PolicyErrorHandler()
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
index ae82e17c83..c1b842d5dc 100644
--- a/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
+++ b/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
@@ -20,7 +20,9 @@ from gravitino.exceptions.base import (
     IllegalArgumentException,
     MetalakeNotInUseException,
     NoSuchMetalakeException,
+    NoSuchPolicyException,
     NoSuchTagException,
+    PolicyAlreadyAssociatedException,
     TagAlreadyAssociatedException,
     TagAlreadyExistsException,
 )
@@ -28,17 +30,26 @@ from gravitino.exceptions.handlers.rest_error_handler 
import CodeMappingErrorHan
 
 
 class TagErrorHandler(CodeMappingErrorHandler):
-    """Error handler specific to Tag operations."""
+    """Error handler specific to Tag operations.
+
+    The policy mappings cover the tag-side policy association calls in
+    ``GravitinoMetalake``, which share this handler; the tag endpoints
+    themselves never return the policy exception types.
+    """
 
     _code_exception_map = {
         ErrorConstants.ILLEGAL_ARGUMENTS_CODE: IllegalArgumentException,
         ErrorConstants.NOT_FOUND_CODE: {
             NoSuchMetalakeException.__name__: NoSuchMetalakeException,
             NoSuchTagException.__name__: NoSuchTagException,
+            NoSuchPolicyException.__name__: NoSuchPolicyException,
         },
         ErrorConstants.ALREADY_EXISTS_CODE: {
             TagAlreadyExistsException.__name__: TagAlreadyExistsException,
             TagAlreadyAssociatedException.__name__: 
TagAlreadyAssociatedException,
+            PolicyAlreadyAssociatedException.__name__: (
+                PolicyAlreadyAssociatedException
+            ),
         },
         ErrorConstants.NOT_IN_USE_CODE: MetalakeNotInUseException,
         ErrorConstants.INTERNAL_ERROR_CODE: RuntimeError,
diff --git 
a/clients/client-python/tests/unittests/client/test_metadata_object_policy_operations.py
 
b/clients/client-python/tests/unittests/client/test_metadata_object_policy_operations.py
new file mode 100644
index 0000000000..2e103c7faf
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/client/test_metadata_object_policy_operations.py
@@ -0,0 +1,268 @@
+# 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 patch
+
+from gravitino.api.metadata_objects import MetadataObject, MetadataObjects
+from gravitino.api.policy.policy import Policy
+from gravitino.api.policy.supports_policies import SupportsPolicies
+from gravitino.client.generic_policy import GenericPolicy
+from gravitino.client.generic_schema import GenericSchema
+from gravitino.client.metadata_object_policy_operations import (
+    MetadataObjectPolicyOperations,
+)
+from gravitino.client.relational_catalog import RelationalCatalog
+from gravitino.client.relational_table import RelationalTable
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.rel.table_dto import TableDTO
+from gravitino.dto.responses.policy_response import (
+    MetadataObjectPolicyListResponse,
+    PolicyNameListResponse,
+)
+from gravitino.dto.responses.schema_response import SchemaResponse
+from gravitino.dto.responses.table_response import TableResponse
+from gravitino.exceptions.handlers.policy_error_handler import 
POLICY_ERROR_HANDLER
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.rest.rest_utils import encode_string
+from gravitino.utils import HTTPClient
+from tests.unittests import mock_base
+from tests.unittests.fixtures.table_fixtures import (
+    TABLE_DTO_JSON_STRING_WITH_STARTING_DATE_SORT,
+)
+
+
+class TestMetadataObjectPolicyOperations(unittest.TestCase):
+    REST_CLIENT = HTTPClient("http://localhost:8090";)
+    METALAKE_NAME = "demo_metalake"
+
+    def test_list_policy_request_path(self) -> None:
+        metadata_object = MetadataObjects.of(
+            ["catalog", "schema", "table"], MetadataObject.Type.TABLE
+        )
+        table_policy_request_path = (
+            MetadataObjectPolicyOperations.POLICY_REQUEST_PATH.format(
+                
encode_string(TestMetadataObjectPolicyOperations.METALAKE_NAME),
+                metadata_object.type().name.lower(),
+                encode_string(metadata_object.full_name()),
+            )
+        )
+        self.assertEqual(
+            
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/policies",
+            table_policy_request_path,
+        )
+
+    def test_list_policies(self) -> None:
+        policy_operations = MetadataObjectPolicyOperations(
+            TestMetadataObjectPolicyOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema", "table"],
+                MetadataObject.Type.TABLE,
+            ),
+            TestMetadataObjectPolicyOperations.REST_CLIENT,
+        )
+        json_str = PolicyNameListResponse(0, ["policyA", "policyB"], 
True).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            policies = policy_operations.list_policies()
+
+            self.assertEqual(["policyA", "policyB"], policies)
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/policies",
+                params={},
+                error_handler=POLICY_ERROR_HANDLER,
+            )
+
+    def test_list_policies_with_visibility(self) -> None:
+        policy_operations = MetadataObjectPolicyOperations(
+            TestMetadataObjectPolicyOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema"],
+                MetadataObject.Type.SCHEMA,
+            ),
+            TestMetadataObjectPolicyOperations.REST_CLIENT,
+        )
+        json_str = PolicyNameListResponse(0, ["policyA"], True).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ):
+            policy_list = policy_operations.list_policies_with_visibility()
+
+            self.assertEqual(["policyA"], policy_list.policies())
+            self.assertTrue(policy_list.has_inaccessible_policies())
+
+    def test_list_policy_infos(self) -> None:
+        policy_operations = MetadataObjectPolicyOperations(
+            TestMetadataObjectPolicyOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema", "table"],
+                MetadataObject.Type.TABLE,
+            ),
+            TestMetadataObjectPolicyOperations.REST_CLIENT,
+        )
+        policy = mock_base.build_policy_dto()
+        json_str = MetadataObjectPolicyListResponse(0, [policy], 
False).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            policies = policy_operations.list_policy_infos()
+
+            self.assertEqual(1, len(policies))
+            self.assertIsInstance(policies[0], GenericPolicy)
+            self.assertIsInstance(policies[0], Policy)
+            self.assertEqual(policy.name(), policies[0].name())
+            self.assertEqual(policy.comment(), policies[0].comment())
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/policies",
+                params={"details": "true"},
+                error_handler=POLICY_ERROR_HANDLER,
+            )
+
+    def test_list_policy_infos_with_visibility(self) -> None:
+        policy_operations = MetadataObjectPolicyOperations(
+            TestMetadataObjectPolicyOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog"],
+                MetadataObject.Type.CATALOG,
+            ),
+            TestMetadataObjectPolicyOperations.REST_CLIENT,
+        )
+        policy = mock_base.build_policy_dto()
+        json_str = MetadataObjectPolicyListResponse(0, [policy], 
True).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ):
+            policy_list = policy_operations.list_policy_infos_with_visibility()
+
+            self.assertEqual(1, len(policy_list.policies()))
+            self.assertTrue(policy_list.has_inaccessible_policies())
+
+
+class TestPoliciesThroughLoadedObjects(unittest.TestCase):
+    """Policy listing through loaded metadata objects.
+
+    The objects returned by the catalog load methods must expose the policy
+    operations themselves; these tests never instantiate
+    MetadataObjectPolicyOperations directly.
+    """
+
+    def test_list_policies_through_loaded_schema(self) -> None:
+        catalog = mock_base.mock_load_catalog("fileset_catalog")
+        schema_response = SchemaResponse(0, mock_base.build_schema_dto())
+        policies_response = PolicyNameListResponse(0, ["policyA", "policyB"], 
True)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(schema_response.to_json()),
+                mock_base.mock_http_response(policies_response.to_json()),
+                mock_base.mock_http_response(policies_response.to_json()),
+            ],
+        ) as mock_get:
+            schema = catalog.as_schemas().load_schema("demo_schema")
+
+            self.assertIsInstance(schema, GenericSchema)
+            self.assertIsInstance(schema, SupportsPolicies)
+            self.assertIs(schema, schema.supports_policies())
+            self.assertEqual(
+                ["policyA", "policyB"], 
schema.supports_policies().list_policies()
+            )
+            policy_list = schema.list_policies_with_visibility()
+            self.assertEqual(["policyA", "policyB"], policy_list.policies())
+            self.assertTrue(policy_list.has_inaccessible_policies())
+            self.assertEqual(
+                "api/metalakes/metalake_demo/objects/schema"
+                "/fileset_catalog.demo_schema/policies",
+                mock_get.call_args_list[1].args[0],
+            )
+
+    def test_list_policies_through_loaded_table(self) -> None:
+        rest_client = HTTPClient("http://localhost:8090";)
+        catalog = RelationalCatalog(
+            catalog_namespace=Namespace.of("test_metalake"),
+            name="test_catalog",
+            catalog_type=RelationalCatalog.Type.RELATIONAL,
+            provider="test_provider",
+            audit=AuditDTO("anonymous"),
+            rest_client=rest_client,
+        )
+        table_dto = 
TableDTO.from_json(TABLE_DTO_JSON_STRING_WITH_STARTING_DATE_SORT)
+        table_response = TableResponse(0, table_dto)
+        policies_response = PolicyNameListResponse(0, ["policyA"], False)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(table_response.to_json()),
+                mock_base.mock_http_response(policies_response.to_json()),
+            ],
+        ) as mock_get:
+            table = catalog.load_table(
+                NameIdentifier.of("test_schema", table_dto.name())
+            )
+
+            self.assertIsInstance(table, RelationalTable)
+            self.assertIsInstance(table, SupportsPolicies)
+            self.assertIs(table, table.supports_policies())
+            self.assertEqual(["policyA"], 
table.supports_policies().list_policies())
+            self.assertEqual(
+                "api/metalakes/test_metalake/objects/table"
+                f"/test_catalog.test_schema.{table_dto.name()}/policies",
+                mock_get.call_args_list[1].args[0],
+            )
+
+    def test_list_policies_through_loaded_catalog(self) -> None:
+        rest_client = HTTPClient("http://localhost:8090";)
+        catalog = RelationalCatalog(
+            catalog_namespace=Namespace.of("test_metalake"),
+            name="test_catalog",
+            catalog_type=RelationalCatalog.Type.RELATIONAL,
+            provider="test_provider",
+            audit=AuditDTO("anonymous"),
+            rest_client=rest_client,
+        )
+        policies_response = PolicyNameListResponse(0, ["policyA"], False)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            
return_value=mock_base.mock_http_response(policies_response.to_json()),
+        ) as mock_get:
+            self.assertIsInstance(catalog, SupportsPolicies)
+            self.assertIs(catalog, catalog.supports_policies())
+            self.assertEqual(["policyA"], 
catalog.supports_policies().list_policies())
+            self.assertEqual(
+                
"api/metalakes/test_metalake/objects/catalog/test_catalog/policies",
+                mock_get.call_args_list[0].args[0],
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git 
a/clients/client-python/tests/unittests/client/test_policy_dto_converters.py 
b/clients/client-python/tests/unittests/client/test_policy_dto_converters.py
new file mode 100644
index 0000000000..82e1384053
--- /dev/null
+++ b/clients/client-python/tests/unittests/client/test_policy_dto_converters.py
@@ -0,0 +1,125 @@
+# 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 gravitino.api.authorization.privileges import Privilege
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.policy.policy_change import PolicyChange
+from gravitino.api.policy.policy_content import PolicyContent
+from gravitino.api.policy.policy_contents import PolicyContents
+from gravitino.client.dto_converters import DTOConverters
+from gravitino.dto.policy_dto import (
+    AccessControlContentDTO,
+    IcebergCompactionContentDTO,
+    RowFilterContentDTO,
+)
+from gravitino.dto.requests.policy_update_request import PolicyUpdateRequest
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+class TestPolicyDTOConverters(unittest.TestCase):
+    def test_to_policy_update_request(self) -> None:
+        rename = 
DTOConverters.to_policy_update_request(PolicyChange.rename("new_name"))
+        self.assertIsInstance(rename, PolicyUpdateRequest.RenamePolicyRequest)
+        self.assertEqual("new_name", rename.new_name)
+
+        comment = DTOConverters.to_policy_update_request(
+            PolicyChange.update_comment("new comment")
+        )
+        self.assertIsInstance(comment, 
PolicyUpdateRequest.UpdatePolicyCommentRequest)
+        self.assertEqual("new comment", comment.new_comment)
+
+        content = DTOConverters.to_policy_update_request(
+            PolicyChange.update_content(
+                "system_row_filter", PolicyContents.row_filter("x > 1")
+            )
+        )
+        self.assertIsInstance(content, 
PolicyUpdateRequest.UpdatePolicyContentRequest)
+        self.assertEqual("system_row_filter", content.policy_type)
+        self.assertIsInstance(content.new_content, RowFilterContentDTO)
+        self.assertEqual("x > 1", content.new_content.expression())
+
+    def test_to_policy_update_request_unknown_change(self) -> None:
+        class UnknownChange(PolicyChange):
+            pass
+
+        with self.assertRaises(IllegalArgumentException):
+            DTOConverters.to_policy_update_request(UnknownChange())
+
+    def test_content_round_trips(self) -> None:
+        contents = [
+            PolicyContents.custom({"rule": 1}, {MetadataObject.Type.TABLE}, 
{"k": "v"}),
+            PolicyContents.iceberg_data_compaction(
+                min_data_file_mse=10,
+                rewrite_strategy="sort",
+                sort_order="id",
+                rewrite_options={"a": "b"},
+                time_trigger="0 0 * * *",
+                min_interval_ms=5,
+            ),
+            PolicyContents.iceberg_orphan_file_removal(
+                older_than_days=7, location="s3://b/t", dry_run=True
+            ),
+            PolicyContents.iceberg_rewrite_manifests(
+                manifest_count_critical=900, spec_id=1, use_caching=False
+            ),
+            PolicyContents.iceberg_snapshot_expiration(
+                older_than_days=30, retain_last=3, stream_results=True
+            ),
+            PolicyContents.row_filter("age > 18"),
+            PolicyContents.column_mask("mask(ssn)"),
+            PolicyContents.access_control(
+                [Privilege.Name.SELECT_TABLE, Privilege.Name.USE_MODEL],
+                ["analyst", "admin"],
+            ),
+        ]
+        for content in contents:
+            dto = DTOConverters.to_policy_content_dto(content)
+            self.assertEqual(
+                content,
+                DTOConverters.from_policy_content_dto(dto),
+                f"round trip failed for {type(content).__name__}",
+            )
+
+    def test_to_policy_content_dto_types(self) -> None:
+        dto = DTOConverters.to_policy_content_dto(
+            PolicyContents.iceberg_data_compaction()
+        )
+        self.assertIsInstance(dto, IcebergCompactionContentDTO)
+        self.assertEqual(405323966463344, dto.min_data_file_mse())
+
+        dto = DTOConverters.to_policy_content_dto(
+            PolicyContents.access_control([Privilege.Name.SELECT_TABLE], 
["analyst"])
+        )
+        self.assertIsInstance(dto, AccessControlContentDTO)
+        self.assertEqual([Privilege.Name.SELECT_TABLE], dto.privileges())
+
+    def test_to_policy_content_dto_unknown(self) -> None:
+        class UnknownContent(PolicyContent):
+            def supported_object_types(self):
+                return {MetadataObject.Type.TABLE}
+
+            def properties(self):
+                return {}
+
+        with self.assertRaises(IllegalArgumentException):
+            DTOConverters.to_policy_content_dto(UnknownContent())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clients/client-python/tests/unittests/mock_base.py 
b/clients/client-python/tests/unittests/mock_base.py
index 28648f04d6..832720060c 100644
--- a/clients/client-python/tests/unittests/mock_base.py
+++ b/clients/client-python/tests/unittests/mock_base.py
@@ -22,7 +22,9 @@ from http.client import HTTPResponse
 from unittest.mock import MagicMock, Mock, patch
 
 from gravitino import Catalog, Fileset, GravitinoMetalake
+from gravitino.api.policy.policy_change import PolicyChange
 from gravitino.api.tag.tag_change import TagChange
+from gravitino.client.dto_converters import DTOConverters
 from gravitino.client.fileset_catalog import FilesetCatalog
 from gravitino.client.generic_fileset import GenericFileset
 from gravitino.client.generic_model_catalog import GenericModelCatalog
@@ -30,6 +32,7 @@ from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.fileset_dto import FilesetDTO
 from gravitino.dto.metalake_dto import MetalakeDTO
 from gravitino.dto.model_dto import ModelDTO
+from gravitino.dto.policy_dto import AccessControlContentDTO, PolicyDTO
 from gravitino.dto.schema_dto import SchemaDTO
 from gravitino.dto.tag_dto import TagDTO
 from gravitino.namespace import Namespace
@@ -380,6 +383,148 @@ def mock_tag_methods():
         yield mocks, repo
 
 
+def build_policy_dto(
+    name: str = "policyA",
+    comment: str = "commentA",
+    policy_type: str = "system_access_control",
+    content=None,
+    enabled: bool = True,
+) -> PolicyDTO:
+    if content is None:
+        content = AccessControlContentDTO.from_dict(
+            {"privileges": ["select_table"], "applicableRoles": ["analyst"]},
+            infer_missing=True,
+        )
+
+    return (
+        PolicyDTO.builder()
+        .name(name)
+        .comment(comment)
+        .policy_type(policy_type)
+        .enabled(enabled)
+        .content(content)
+        .audit_info(build_audit_info())
+        .inherited(False)
+        .build()
+    )
+
+
+def _rebuild_policy_dto(policy_dto: PolicyDTO, **overrides) -> PolicyDTO:
+    values = {
+        "_name": policy_dto.name(),
+        "_policy_type": policy_dto.policy_type(),
+        "_content": policy_dto.content(),
+        "_comment": policy_dto.comment(),
+        "_enabled": policy_dto.enabled(),
+        "_inherited": policy_dto.inherited(),
+        "_audit": policy_dto.audit_info(),
+    }
+    values.update(overrides)
+    return PolicyDTO(**values)
+
+
+class MockPolicyRepo:
+    def __init__(self) -> None:
+        self.policy_store = {
+            "policyA": build_policy_dto("policyA", "mock policy A"),
+            "policyB": build_policy_dto("policyB", "mock policy B"),
+        }
+
+    def mock_list_policies(self) -> list[str]:
+        return list(self.policy_store.keys())
+
+    def mock_list_policy_infos(self) -> list[PolicyDTO]:
+        return list(self.policy_store.values())
+
+    def mock_get_policy(self, policy_name: str) -> PolicyDTO:
+        if policy_name not in self.policy_store:
+            raise ValueError(f"Policy {policy_name} does not exist")
+        return self.policy_store[policy_name]
+
+    def mock_create_policy(
+        self,
+        policy_name: str,
+        policy_type: str = "system_access_control",
+        comment: str = "",
+        enabled: bool = True,
+        content=None,
+    ) -> PolicyDTO:
+        if policy_name in self.policy_store:
+            raise ValueError(f"Policy {policy_name} already exists")
+        content_dto = None
+        if content is not None:
+            content_dto = DTOConverters.to_policy_content_dto(content)
+        self.policy_store[policy_name] = build_policy_dto(
+            policy_name, comment, policy_type, content_dto, enabled
+        )
+        return self.policy_store[policy_name]
+
+    def mock_alter_policy(self, policy_name: str, *changes) -> PolicyDTO:
+        if policy_name not in self.policy_store:
+            raise ValueError(f"Policy {policy_name} does not exist")
+
+        for change in changes:
+            current_policy_obj = self.policy_store[policy_name]
+
+            if isinstance(change, PolicyChange.RenamePolicy):
+                self.policy_store[change.new_name] = _rebuild_policy_dto(
+                    current_policy_obj, _name=change.new_name
+                )
+                del self.policy_store[policy_name]
+                policy_name = change.new_name
+
+            elif isinstance(change, PolicyChange.UpdatePolicyComment):
+                self.policy_store[policy_name] = _rebuild_policy_dto(
+                    current_policy_obj, _comment=change.new_comment
+                )
+
+            elif isinstance(change, PolicyChange.UpdateContent):
+                self.policy_store[policy_name] = _rebuild_policy_dto(
+                    current_policy_obj,
+                    _policy_type=change.policy_type,
+                    
_content=DTOConverters.to_policy_content_dto(change.content),
+                )
+
+            else:
+                raise ValueError(f"Unknown policy change type: {change}")
+
+        return self.policy_store[policy_name]
+
+    def mock_delete_policy(self, policy_name: str) -> bool:
+        if policy_name not in self.policy_store:
+            return False
+        del self.policy_store[policy_name]
+        return True
+
+    def mock_enable_policy(self, policy_name: str) -> None:
+        self.policy_store[policy_name] = _rebuild_policy_dto(
+            self.mock_get_policy(policy_name), _enabled=True
+        )
+
+    def mock_disable_policy(self, policy_name: str) -> None:
+        self.policy_store[policy_name] = _rebuild_policy_dto(
+            self.mock_get_policy(policy_name), _enabled=False
+        )
+
+
+@contextmanager
+def mock_policy_methods():
+    repo = MockPolicyRepo()
+
+    with patch.multiple(
+        GravitinoMetalake,
+        list_policies=MagicMock(side_effect=repo.mock_list_policies),
+        list_policy_infos=MagicMock(side_effect=repo.mock_list_policy_infos),
+        get_policy=MagicMock(side_effect=repo.mock_get_policy),
+        create_policy=MagicMock(side_effect=repo.mock_create_policy),
+        alter_policy=MagicMock(side_effect=repo.mock_alter_policy),
+        delete_policy=MagicMock(side_effect=repo.mock_delete_policy),
+        enable_policy=MagicMock(side_effect=repo.mock_enable_policy),
+        disable_policy=MagicMock(side_effect=repo.mock_disable_policy),
+    ) as mocks:
+        yield mocks, repo
+
+
 def mock_http_response(json_str: str) -> Response:
     mock_http_resp = Mock(HTTPResponse)
     mock_http_resp.getcode.return_value = 200
diff --git 
a/clients/client-python/tests/unittests/test_code_mapping_error_handler.py 
b/clients/client-python/tests/unittests/test_code_mapping_error_handler.py
index 80909aa3bb..fdd3fabbe6 100644
--- a/clients/client-python/tests/unittests/test_code_mapping_error_handler.py
+++ b/clients/client-python/tests/unittests/test_code_mapping_error_handler.py
@@ -39,9 +39,11 @@ from gravitino.exceptions.base import (
     NoSuchModelException,
     NoSuchModelVersionException,
     NoSuchModelVersionURINameException,
+    NoSuchPolicyException,
     NoSuchSchemaException,
     NoSuchTagException,
     NotFoundException,
+    PolicyAlreadyAssociatedException,
     RESTException,
     TagAlreadyAssociatedException,
     TagAlreadyExistsException,
@@ -215,6 +217,13 @@ class TestCodeMappingErrorHandler(unittest.TestCase):
                     "NoSuchTagException",
                     NoSuchTagException,
                 ),
+                # The tag-side policy association calls share this handler,
+                # so the policy types returned by those endpoints map here.
+                (
+                    ErrorConstants.NOT_FOUND_CODE,
+                    "NoSuchPolicyException",
+                    NoSuchPolicyException,
+                ),
                 (
                     ErrorConstants.ALREADY_EXISTS_CODE,
                     "TagAlreadyExistsException",
@@ -225,6 +234,11 @@ class TestCodeMappingErrorHandler(unittest.TestCase):
                     "TagAlreadyAssociatedException",
                     TagAlreadyAssociatedException,
                 ),
+                (
+                    ErrorConstants.ALREADY_EXISTS_CODE,
+                    "PolicyAlreadyAssociatedException",
+                    PolicyAlreadyAssociatedException,
+                ),
                 (
                     ErrorConstants.NOT_IN_USE_CODE,
                     "AnyType",
diff --git a/clients/client-python/tests/unittests/test_generic_policy.py 
b/clients/client-python/tests/unittests/test_generic_policy.py
new file mode 100644
index 0000000000..7f7c9b6f86
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_generic_policy.py
@@ -0,0 +1,87 @@
+# 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 gravitino.api.authorization.privileges import Privilege
+from gravitino.api.policy.policy_contents import PolicyContents
+from gravitino.client.generic_policy import GenericPolicy
+from gravitino.client.generic_policy_tag_association import (
+    GenericPolicyTagAssociation,
+)
+from gravitino.api.policy.policy_association_selector import (
+    AllValuesSelector,
+    TagValueSelector,
+)
+from gravitino.dto.tag_dto import TagDTO
+from tests.unittests import mock_base
+
+
+class TestGenericPolicy(unittest.TestCase):
+    def test_delegation(self) -> None:
+        policy_dto = mock_base.build_policy_dto()
+        policy = GenericPolicy(policy_dto)
+
+        self.assertEqual("policyA", policy.name())
+        self.assertEqual("system_access_control", policy.policy_type())
+        self.assertEqual("commentA", policy.comment())
+        self.assertTrue(policy.enabled())
+        self.assertEqual(False, policy.inherited())
+        self.assertEqual(policy_dto.audit_info(), policy.audit_info())
+
+    def test_content_converted_to_domain(self) -> None:
+        policy = GenericPolicy(mock_base.build_policy_dto())
+        content = policy.content()
+        self.assertEqual(
+            PolicyContents.access_control([Privilege.Name.SELECT_TABLE], 
["analyst"]),
+            content,
+        )
+
+    def test_equality_and_hash(self) -> None:
+        left = GenericPolicy(mock_base.build_policy_dto())
+        right = GenericPolicy(mock_base.build_policy_dto())
+        self.assertEqual(left, right)
+        self.assertEqual(hash(left), hash(right))
+        self.assertNotEqual(left, 
GenericPolicy(mock_base.build_policy_dto("policyB")))
+        self.assertNotEqual(left, mock_base.build_policy_dto())
+
+
+class TestGenericPolicyTagAssociation(unittest.TestCase):
+    def test_accessors_and_equality(self) -> None:
+        policy = GenericPolicy(mock_base.build_policy_dto())
+        tag = TagDTO.builder().name("tagA").build()
+        selector = TagValueSelector.of("gold")
+
+        association = GenericPolicyTagAssociation(policy, tag, selector)
+        self.assertEqual(policy, association.policy())
+        self.assertEqual(tag, association.tag())
+        self.assertEqual(selector, association.selector())
+
+        same = GenericPolicyTagAssociation(
+            GenericPolicy(mock_base.build_policy_dto()),
+            TagDTO.builder().name("tagA").build(),
+            TagValueSelector.of("gold"),
+        )
+        self.assertEqual(association, same)
+        self.assertEqual(hash(association), hash(same))
+
+        different = GenericPolicyTagAssociation(policy, tag, 
AllValuesSelector.get())
+        self.assertNotEqual(association, different)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/clients/client-python/tests/unittests/test_policy_client_api.py 
b/clients/client-python/tests/unittests/test_policy_client_api.py
new file mode 100644
index 0000000000..46ab1f30cd
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_policy_client_api.py
@@ -0,0 +1,533 @@
+# 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.
+
+from __future__ import annotations
+
+import unittest
+from http.client import HTTPResponse
+from io import BytesIO
+from unittest.mock import Mock, patch
+from urllib.error import HTTPError
+
+from gravitino import GravitinoClient
+from gravitino.api.authorization.privileges import Privilege
+from gravitino.api.policy.policy_association_selector import TagValueSelector
+from gravitino.api.policy.policy_change import PolicyChange
+from gravitino.api.policy.policy_content import AccessControlContent
+from gravitino.api.policy.policy_contents import PolicyContents
+from gravitino.api.tag.tag_operations import TagOperations
+from gravitino.client.generic_policy import GenericPolicy
+from gravitino.dto.policy_dto import (
+    PolicyForTagAssociationDTO,
+    PolicyAssociationSelectorDTO,
+    TagForPolicyAssociationDTO,
+)
+from gravitino.dto.requests.policy_set_request import PolicySetRequest
+from gravitino.dto.responses.drop_response import DropResponse
+from gravitino.dto.responses.policy_response import (
+    PolicyForTagAssociationListResponse,
+    PolicyListResponse,
+    PolicyNameListResponse,
+    PolicyResponse,
+    PolicyTagAssociationResponse,
+    TagForPolicyAssociationListResponse,
+)
+from gravitino.dto.responses.tag_response import TagResponse
+from gravitino.exceptions.base import (
+    IllegalArgumentException,
+    NoSuchPolicyException,
+    PolicyAlreadyAssociatedException,
+    UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.policy_error_handler import 
POLICY_ERROR_HANDLER
+from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
+from tests.unittests import mock_base
+
+
+@mock_base.mock_data
+class TestPolicyClientAPI(unittest.TestCase):
+    _metalake_name: str = "metalake_demo"
+
+    def _client(self) -> GravitinoClient:
+        return GravitinoClient(
+            uri="http://localhost:8090";,
+            metalake_name=self._metalake_name,
+            check_version=False,
+        )
+
+    def check_policy_equal(self, expected, actual) -> None:
+        self.assertEqual(expected.name(), actual.name())
+        self.assertEqual(expected.policy_type(), actual.policy_type())
+        self.assertEqual(expected.comment(), actual.comment())
+        self.assertEqual(expected.enabled(), actual.enabled())
+
+    @staticmethod
+    def _opener_returning(status_code: int, body: bytes) -> Mock:
+        """An opener whose response carries the given status and raw body,
+        so requests run through the real HTTPClient."""
+        mock_http_resp = Mock(HTTPResponse)
+        mock_http_resp.getcode.return_value = status_code
+        mock_http_resp.read.return_value = body
+        mock_http_resp.info.return_value = None
+        mock_http_resp.url = None
+        opener = Mock()
+        opener.open.return_value = mock_http_resp
+        return opener
+
+    @staticmethod
+    def _opener_raising(status_code: int, body: bytes) -> Mock:
+        """An opener that fails the request with an HTTPError carrying the
+        given error response body, like a real server error."""
+        opener = Mock()
+        opener.open.side_effect = HTTPError(
+            "http://localhost:8090";, status_code, "error", None, BytesIO(body)
+        )
+        return opener
+
+    # Client delegation tests, backed by the mock policy repo.
+
+    def test_client_list_policies(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            self.assertEqual(["policyA", "policyB"], client.list_policies())
+            self.assertEqual(2, len(client.list_policy_infos()))
+
+    def test_client_get_policy(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            policy = client.get_policy("policyA")
+            self.assertEqual("policyA", policy.name())
+            self.assertEqual("mock policy A", policy.comment())
+
+    def test_client_create_policy(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            created = client.create_policy(
+                "policyC",
+                "system_access_control",
+                "mock policy C",
+                True,
+                PolicyContents.access_control(
+                    [Privilege.Name.SELECT_TABLE], ["analyst"]
+                ),
+            )
+            self.check_policy_equal(
+                mock_base.build_policy_dto("policyC", "mock policy C"), created
+            )
+            self.assertEqual(["policyA", "policyB", "policyC"], 
client.list_policies())
+
+    def test_client_alter_policy(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            client.alter_policy(
+                "policyB",
+                PolicyChange.update_comment("mock policy B updated"),
+                PolicyChange.rename("new_policy_B"),
+            )
+            retrieved = client.get_policy("new_policy_B")
+            self.assertEqual("new_policy_B", retrieved.name())
+            self.assertEqual("mock policy B updated", retrieved.comment())
+
+    def test_client_enable_disable_policy(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            client.disable_policy("policyA")
+            self.assertFalse(client.get_policy("policyA").enabled())
+            client.enable_policy("policyA")
+            self.assertTrue(client.get_policy("policyA").enabled())
+
+    def test_client_delete_policy(self, *mock_method) -> None:
+        with mock_base.mock_policy_methods():
+            client = self._client()
+            self.assertTrue(client.delete_policy("policyA"))
+            self.assertFalse(client.delete_policy("policyA"))
+            self.assertEqual(["policyB"], client.list_policies())
+
+    def test_tag_operations_policy_defaults_unsupported(self, *mock_method) -> 
None:
+        class BareTagOperations(TagOperations):
+            def list_tags(self):
+                return []
+
+            def list_tags_info(self):
+                return []
+
+            def get_tag(self, tag_name):
+                return None
+
+            def create_tag(self, tag_name, comment, properties, 
allowed_values=None):
+                return None
+
+            def alter_tag(self, tag_name, *changes):
+                return None
+
+            def delete_tag(self, tag_name):
+                return False
+
+        operations = BareTagOperations()
+        with self.assertRaises(UnsupportedOperationException):
+            operations.list_policy_associations_for_tag("tagA")
+        with self.assertRaises(UnsupportedOperationException):
+            operations.add_policy_for_tag("tagA", "policyA")
+        with self.assertRaises(UnsupportedOperationException):
+            operations.remove_policy_from_tag("tagA", "policyA")
+
+    # Metalake HTTP-level tests, with the HTTP client verbs mocked.
+
+    def test_gravitino_list_policies_api(self, *mock_method) -> None:
+        resp = PolicyNameListResponse(0, ["policyA", "policyB"])
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            policies = client.list_policies()
+            self.assertEqual(["policyA", "policyB"], policies)
+            mock_get.assert_called_once_with(
+                "api/metalakes/metalake_demo/policies",
+                error_handler=POLICY_ERROR_HANDLER,
+            )
+
+    def test_gravitino_list_policy_infos_api(self, *mock_method) -> None:
+        policy = mock_base.build_policy_dto()
+        resp = PolicyListResponse(0, [policy])
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            policies = client.list_policy_infos()
+            self.assertEqual(1, len(policies))
+            self.assertIsInstance(policies[0], GenericPolicy)
+            self.check_policy_equal(policy, policies[0])
+            self.assertIsInstance(policies[0].content(), AccessControlContent)
+            mock_get.assert_called_once_with(
+                "api/metalakes/metalake_demo/policies",
+                params={"details": "true"},
+                error_handler=POLICY_ERROR_HANDLER,
+            )
+
+    def test_gravitino_get_policy_api(self, *mock_method) -> None:
+        policy = mock_base.build_policy_dto()
+        resp = PolicyResponse(0, policy)
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ):
+            retrieved = client.get_policy(policy.name())
+            self.check_policy_equal(policy, retrieved)
+
+    def test_gravitino_get_policy_api_with_empty_name(self, *mock_method) -> 
None:
+        client = self._client()
+        with self.assertRaises(IllegalArgumentException):
+            client.get_policy(" ")
+
+    def test_gravitino_create_policy_api(self, *mock_method) -> None:
+        policy = mock_base.build_policy_dto()
+        resp = PolicyResponse(0, policy)
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=mock_resp,
+        ) as mock_post:
+            created = client.create_policy(
+                policy.name(),
+                policy.policy_type(),
+                policy.comment(),
+                policy.enabled(),
+                PolicyContents.access_control(
+                    [Privilege.Name.SELECT_TABLE], ["analyst"]
+                ),
+            )
+            self.check_policy_equal(policy, created)
+            args, kwargs = mock_post.call_args
+            self.assertEqual("api/metalakes/metalake_demo/policies", args[0])
+            self.assertEqual(POLICY_ERROR_HANDLER, kwargs["error_handler"])
+            request = kwargs["json"]
+            self.assertEqual("policyA", request.name)
+            self.assertEqual("system_access_control", request.policy_type)
+
+    def test_gravitino_enable_disable_policy_api(self, *mock_method) -> None:
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.patch",
+        ) as mock_patch:
+            client.enable_policy("policyA")
+            args, kwargs = mock_patch.call_args
+            self.assertEqual("api/metalakes/metalake_demo/policies/policyA", 
args[0])
+            self.assertIsInstance(kwargs["json"], PolicySetRequest)
+            self.assertTrue(kwargs["json"].enable)
+            self.assertEqual(POLICY_ERROR_HANDLER, kwargs["error_handler"])
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.patch",
+        ) as mock_patch:
+            client.disable_policy("policyA")
+            _, kwargs = mock_patch.call_args
+            self.assertFalse(kwargs["json"].enable)
+
+    def test_gravitino_alter_policy_api(self, *mock_method) -> None:
+        policy = mock_base.build_policy_dto("new_policy", "new comment")
+        resp = PolicyResponse(0, policy)
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.put",
+            return_value=mock_resp,
+        ) as mock_put:
+            altered = client.alter_policy(
+                "policyA",
+                PolicyChange.rename("new_policy"),
+                PolicyChange.update_comment("new comment"),
+            )
+            self.check_policy_equal(policy, altered)
+            args, kwargs = mock_put.call_args
+            self.assertEqual("api/metalakes/metalake_demo/policies/policyA", 
args[0])
+            updates = kwargs["json"].to_dict()["updates"]
+            self.assertEqual("rename", updates[0]["@type"])
+            self.assertEqual("updateComment", updates[1]["@type"])
+
+    def test_gravitino_alter_policy_api_without_changes(self, *mock_method) -> 
None:
+        client = self._client()
+        with self.assertRaises(IllegalArgumentException):
+            client.alter_policy("policyA")
+
+    def test_gravitino_delete_policy_api(self, *mock_method) -> None:
+        resp = DropResponse(0, True)
+        mock_resp = mock_base.mock_http_response(resp.to_json())
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete",
+            return_value=mock_resp,
+        ):
+            self.assertTrue(client.delete_policy("policyA"))
+
+    def test_gravitino_list_tag_associations_for_policy(self, *mock_method) -> 
None:
+        policy = mock_base.build_policy_dto()
+        tag = mock_base.build_tag_dto()
+        association = TagForPolicyAssociationDTO(
+            _tag=tag,
+            _selector=PolicyAssociationSelectorDTO("ALL_VALUES", None),
+        )
+        resp = TagForPolicyAssociationListResponse(0, [association])
+        policy_resp = PolicyResponse(0, policy)
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(resp.to_json()),
+                mock_base.mock_http_response(policy_resp.to_json()),
+            ],
+        ) as mock_get:
+            associations = client.list_tag_associations_for_policy("policyA")
+            self.assertEqual(1, len(associations))
+            self.assertEqual("policyA", associations[0].policy().name())
+            self.assertEqual("tagA", associations[0].tag().name())
+            self.assertEqual("ALL_VALUES", associations[0].selector().type())
+            first_call = mock_get.call_args_list[0]
+            self.assertEqual(
+                "api/metalakes/metalake_demo/policies/policyA/tags",
+                first_call.args[0],
+            )
+            self.assertEqual({"details": "true"}, first_call.kwargs["params"])
+
+    def test_gravitino_list_policy_associations_for_tag(self, *mock_method) -> 
None:
+        policy = mock_base.build_policy_dto()
+        tag = mock_base.build_tag_dto()
+        association = PolicyForTagAssociationDTO(
+            _policy=policy,
+            _selector=PolicyAssociationSelectorDTO("TAG_VALUE", "gold"),
+        )
+        resp = PolicyForTagAssociationListResponse(0, [association])
+        tag_resp = TagResponse(0, tag)
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(resp.to_json()),
+                mock_base.mock_http_response(tag_resp.to_json()),
+            ],
+        ) as mock_get:
+            associations = client.list_policy_associations_for_tag("tagA")
+            self.assertEqual(1, len(associations))
+            self.assertEqual("policyA", associations[0].policy().name())
+            self.assertEqual("tagA", associations[0].tag().name())
+            self.assertEqual("gold", associations[0].selector().value())
+            first_call = mock_get.call_args_list[0]
+            self.assertEqual(
+                "api/metalakes/metalake_demo/tags/tagA/policies",
+                first_call.args[0],
+            )
+            self.assertEqual(TAG_ERROR_HANDLER, 
first_call.kwargs["error_handler"])
+
+    def test_gravitino_add_policy_for_tag(self, *mock_method) -> None:
+        policy = mock_base.build_policy_dto()
+        tag = mock_base.build_tag_dto()
+        resp = PolicyTagAssociationResponse(
+            0,
+            "policyA",
+            "tagA",
+            PolicyAssociationSelectorDTO("TAG_VALUE", "gold"),
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=mock_base.mock_http_response(resp.to_json()),
+        ) as mock_post, patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(PolicyResponse(0, 
policy).to_json()),
+                mock_base.mock_http_response(TagResponse(0, tag).to_json()),
+            ],
+        ):
+            association = client.add_policy_for_tag(
+                "tagA", "policyA", TagValueSelector.of("gold")
+            )
+            self.assertEqual("policyA", association.policy().name())
+            self.assertEqual("tagA", association.tag().name())
+            self.assertEqual("gold", association.selector().value())
+            args, kwargs = mock_post.call_args
+            self.assertEqual(
+                "api/metalakes/metalake_demo/tags/tagA/policies/policyA", 
args[0]
+            )
+            self.assertEqual(TAG_ERROR_HANDLER, kwargs["error_handler"])
+            self.assertEqual("TAG_VALUE", kwargs["json"].selector_dto.type())
+            self.assertEqual("gold", kwargs["json"].selector_dto.value())
+
+    def test_gravitino_add_policy_for_tag_default_selector(self, *mock_method) 
-> None:
+        policy = mock_base.build_policy_dto()
+        tag = mock_base.build_tag_dto()
+        resp = PolicyTagAssociationResponse(
+            0,
+            "policyA",
+            "tagA",
+            PolicyAssociationSelectorDTO("ALL_VALUES", None),
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=mock_base.mock_http_response(resp.to_json()),
+        ) as mock_post, patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=[
+                mock_base.mock_http_response(PolicyResponse(0, 
policy).to_json()),
+                mock_base.mock_http_response(TagResponse(0, tag).to_json()),
+            ],
+        ):
+            association = client.add_policy_for_tag("tagA", "policyA")
+            self.assertEqual("ALL_VALUES", association.selector().type())
+            _, kwargs = mock_post.call_args
+            self.assertEqual("ALL_VALUES", kwargs["json"].selector_dto.type())
+
+    def test_gravitino_remove_policy_from_tag(self, *mock_method) -> None:
+        client = self._client()
+
+        # The server answers a successful removal with HTTP 204 and no body;
+        # run through the real HTTPClient so the empty body is exercised.
+        with patch(
+            "gravitino.utils.http_client.build_opener",
+            return_value=self._opener_returning(204, b""),
+        ) as mock_build_opener:
+            self.assertIsNone(client.remove_policy_from_tag("tagA", "policyA"))
+            request = mock_build_opener.return_value.open.call_args.args[0]
+            self.assertEqual("DELETE", request.get_method())
+            self.assertEqual(
+                "http://localhost:9090/api/metalakes/metalake_demo/tags/tagA";
+                "/policies/policyA",
+                request.full_url,
+            )
+
+    def test_gravitino_add_policy_for_tag_no_such_policy(self, *mock_method) 
-> None:
+        error_body = (
+            b'{"code":1003,"type":"NoSuchPolicyException",'
+            b'"message":"Policy does not exist","stack":null}'
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.build_opener",
+            return_value=self._opener_raising(404, error_body),
+        ):
+            with self.assertRaises(NoSuchPolicyException):
+                client.add_policy_for_tag("tagA", "policyA")
+
+    def test_gravitino_add_policy_for_tag_already_associated(
+        self, *mock_method
+    ) -> None:
+        error_body = (
+            b'{"code":1004,"type":"PolicyAlreadyAssociatedException",'
+            b'"message":"Policy is already associated with the 
tag","stack":null}'
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.build_opener",
+            return_value=self._opener_raising(409, error_body),
+        ):
+            with self.assertRaises(PolicyAlreadyAssociatedException):
+                client.add_policy_for_tag("tagA", "policyA")
+
+    def test_gravitino_remove_policy_from_tag_no_such_policy(
+        self, *mock_method
+    ) -> None:
+        error_body = (
+            b'{"code":1003,"type":"NoSuchPolicyException",'
+            b'"message":"Policy does not exist","stack":null}'
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.build_opener",
+            return_value=self._opener_raising(404, error_body),
+        ):
+            with self.assertRaises(NoSuchPolicyException):
+                client.remove_policy_from_tag("tagA", "policyA")
+
+    def test_gravitino_remove_policy_from_tag_already_associated(
+        self, *mock_method
+    ) -> None:
+        error_body = (
+            b'{"code":1004,"type":"PolicyAlreadyAssociatedException",'
+            b'"message":"Policy is already associated with the 
tag","stack":null}'
+        )
+        client = self._client()
+
+        with patch(
+            "gravitino.utils.http_client.build_opener",
+            return_value=self._opener_raising(409, error_body),
+        ):
+            with self.assertRaises(PolicyAlreadyAssociatedException):
+                client.remove_policy_from_tag("tagA", "policyA")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to