This is an automated email from the ASF dual-hosted git repository.
honahx pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 69c546a2c Client: add support for policy management (#2701)
69c546a2c is described below
commit 69c546a2cbfb37f927e09d3679fc79ecc9e0ca02
Author: Yong Zheng <[email protected]>
AuthorDate: Tue Oct 7 23:28:59 2025 -0500
Client: add support for policy management (#2701)
Implementation for policy management via Polaris CLI (#1867).
Here are the subcommands to API mapping:
attach
- PUT
/polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}/mappings
create
- POST
/polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}/mappings
delete
- DELETE /polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}
detach
- POST
/polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}/mappings
get
- GET /polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}
list
- GET /polaris/v1/{prefix}/namespaces/{namespace}/policies
- This is default for `list` operation
- GET /polaris/v1/{prefix}/applicable-policies
- This is when we have `--applicable` option provided
update
- PUT /polaris/v1/{prefix}/namespaces/{namespace}/policies/{policy-name}
---
client/python/cli/command/__init__.py | 20 ++
client/python/cli/command/namespaces.py | 22 +--
client/python/cli/command/policies.py | 202 +++++++++++++++++++
client/python/cli/command/utils.py | 44 +++++
client/python/cli/constants.py | 25 +++
client/python/cli/options/option_tree.py | 47 ++++-
client/python/cli/polaris_cli.py | 42 ++++
client/python/integration_tests/conftest.py | 63 +++++-
.../python/integration_tests/test_catalog_apis.py | 218 +++++++++++++++++++++
.../in-dev/unreleased/command-line-interface.md | 191 +++++++++++++++++-
10 files changed, 853 insertions(+), 21 deletions(-)
diff --git a/client/python/cli/command/__init__.py
b/client/python/cli/command/__init__.py
index d9c29f215..76cc0bca3 100644
--- a/client/python/cli/command/__init__.py
+++ b/client/python/cli/command/__init__.py
@@ -40,6 +40,7 @@ class Command(ABC):
set_properties =
Parser.parse_properties(options_get(Arguments.SET_PROPERTY))
remove_properties = options_get(Arguments.REMOVE_PROPERTY)
catalog_client_scopes = options_get(Arguments.CATALOG_CLIENT_SCOPE)
+ parameters =
Parser.parse_properties(options_get(Arguments.PARAMETERS)),
command = None
if options.command == Commands.CATALOGS:
@@ -169,6 +170,25 @@ class Command(ABC):
command = ProfilesCommand(
subcommand, profile_name=options_get(Arguments.PROFILE)
)
+ elif options.command == Commands.POLICIES:
+ from cli.command.policies import PoliciesCommand
+
+ subcommand = options_get(f"{Commands.POLICIES}_subcommand")
+ command = PoliciesCommand(
+ subcommand,
+ catalog_name=options_get(Arguments.CATALOG),
+ namespace=options_get(Arguments.NAMESPACE),
+ policy_name=options_get(Arguments.POLICY),
+ policy_file=options_get(Arguments.POLICY_FILE),
+ policy_type=options_get(Arguments.POLICY_TYPE),
+ policy_description=options_get(Arguments.POLICY_DESCRIPTION),
+ target_name=options_get(Arguments.TARGET_NAME),
+ parameters={} if parameters is None else parameters,
+ detach_all=options_get(Arguments.DETACH_ALL),
+ applicable=options_get(Arguments.APPLICABLE),
+ attachment_type=options_get(Arguments.ATTACHMENT_TYPE),
+ attachment_path=options_get(Arguments.ATTACHMENT_PATH),
+ )
if command is not None:
command.validate()
diff --git a/client/python/cli/command/namespaces.py
b/client/python/cli/command/namespaces.py
index 3528a6ba1..e3bf193f5 100644
--- a/client/python/cli/command/namespaces.py
+++ b/client/python/cli/command/namespaces.py
@@ -17,16 +17,16 @@
# under the License.
#
import json
-import re
from dataclasses import dataclass
from typing import Dict, Optional, List
from pydantic import StrictStr
from cli.command import Command
+from cli.command.utils import get_catalog_api_client
from cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
from cli.options.option_tree import Argument
-from polaris.catalog import IcebergCatalogAPI, CreateNamespaceRequest,
ApiClient, Configuration
+from polaris.catalog import IcebergCatalogAPI, CreateNamespaceRequest
from polaris.management import PolarisDefaultApi
@@ -55,23 +55,9 @@ class NamespacesCommand(Command):
f"Missing required argument:
{Argument.to_flag_name(Arguments.CATALOG)}"
)
- def _get_catalog_api(self, api: PolarisDefaultApi):
- """
- Convert a management API to a catalog API
- """
- catalog_host = re.match(
- r"(https?://.+)/api/management", api.api_client.configuration.host
- ).group(1)
- configuration = Configuration(
- host=f"{catalog_host}/api/catalog",
- username=api.api_client.configuration.username,
- password=api.api_client.configuration.password,
- access_token=api.api_client.configuration.access_token,
- )
- return IcebergCatalogAPI(ApiClient(configuration))
-
def execute(self, api: PolarisDefaultApi) -> None:
- catalog_api = self._get_catalog_api(api)
+ catalog_api_client = get_catalog_api_client(api)
+ catalog_api = IcebergCatalogAPI(catalog_api_client)
if self.namespaces_subcommand == Subcommands.CREATE:
req_properties = self.properties or {}
if self.location:
diff --git a/client/python/cli/command/policies.py
b/client/python/cli/command/policies.py
new file mode 100644
index 000000000..78ada1768
--- /dev/null
+++ b/client/python/cli/command/policies.py
@@ -0,0 +1,202 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+import os
+import json
+from dataclasses import dataclass
+from typing import Optional, Dict
+from cli.command import Command
+from cli.command.utils import get_catalog_api_client
+from cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
+from cli.options.option_tree import Argument
+from polaris.management import PolarisDefaultApi
+from polaris.catalog.api.policy_api import PolicyAPI
+from polaris.catalog.models.create_policy_request import CreatePolicyRequest
+from polaris.catalog.models.update_policy_request import UpdatePolicyRequest
+from polaris.catalog.models.policy_attachment_target import
PolicyAttachmentTarget
+from polaris.catalog.models.attach_policy_request import AttachPolicyRequest
+from polaris.catalog.models.detach_policy_request import DetachPolicyRequest
+
+
+
+@dataclass
+class PoliciesCommand(Command):
+ """
+ A Command implementation to represent `polaris policies`.
+ """
+
+ policies_subcommand: str
+ catalog_name: str
+ namespace: str
+ policy_name: str
+ policy_file: str
+ policy_type: Optional[str]
+ policy_description: Optional[str]
+ target_name: Optional[str]
+ parameters: Optional[Dict[str, str]]
+ detach_all: Optional[bool]
+ applicable: Optional[bool]
+ attachment_type: Optional[str]
+ attachment_path: Optional[str]
+
+ def validate(self):
+ if not self.catalog_name:
+ raise Exception(f"Missing required argument:
{Argument.to_flag_name(Arguments.CATALOG)}")
+ if self.policies_subcommand in [Subcommands.CREATE,
Subcommands.UPDATE]:
+ if not self.policy_file:
+ raise Exception(f"Missing required argument:
{Argument.to_flag_name(Arguments.POLICY_FILE)}")
+ if self.policies_subcommand in [Subcommands.ATTACH,
Subcommands.DETACH]:
+ if not self.attachment_type:
+ raise Exception(f"Missing required argument:
{Argument.to_flag_name(Arguments.ATTACHMENT_TYPE)}")
+ if self.attachment_type != 'catalog' and not self.attachment_path:
+ raise
Exception(f"'{Argument.to_flag_name(Arguments.ATTACHMENT_PATH)}' is required
when attachment type is not 'catalog'")
+ if self.policies_subcommand == Subcommands.LIST and self.applicable
and self.target_name:
+ if not self.namespace:
+ raise Exception(
+ f"Missing required argument:
{Argument.to_flag_name(Arguments.NAMESPACE)}"
+ f" when {Argument.to_flag_name(Arguments.TARGET_NAME)} is
set."
+ )
+ if self.policies_subcommand == Subcommands.LIST and not
self.applicable:
+ if not self.namespace:
+ raise Exception(
+ f"Missing required argument:
{Argument.to_flag_name(Arguments.NAMESPACE)}"
+ f" when listing policies without
{Argument.to_flag_name(Arguments.APPLICABLE)} flag."
+ )
+
+ def execute(self, api: PolarisDefaultApi) -> None:
+ catalog_api_client = get_catalog_api_client(api)
+ policy_api = PolicyAPI(catalog_api_client)
+
+ namespace_str = self.namespace.replace('.', UNIT_SEPARATOR) if
self.namespace else ""
+ if self.policies_subcommand == Subcommands.CREATE:
+ with open(self.policy_file, "r") as f:
+ policy = json.load(f)
+ policy_api.create_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ create_policy_request=CreatePolicyRequest(
+ name=self.policy_name,
+ type=self.policy_type,
+ description=self.policy_description,
+ content=json.dumps(policy)
+ )
+ )
+ elif self.policies_subcommand == Subcommands.DELETE:
+ policy_api.drop_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name,
+ detach_all=self.detach_all
+ )
+ elif self.policies_subcommand == Subcommands.GET:
+ print(policy_api.load_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name
+ ).to_json())
+ elif self.policies_subcommand == Subcommands.LIST:
+ if self.applicable:
+ applicable_policies_list = []
+
+ if self.target_name:
+ # Table-like level policies
+ applicable_policies_list =
policy_api.get_applicable_policies(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ target_name=self.target_name,
+ policy_type=self.policy_type
+ ).applicable_policies
+ elif self.namespace:
+ # Namespace level policies
+ applicable_policies_list =
policy_api.get_applicable_policies(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_type=self.policy_type
+ ).applicable_policies
+ else:
+ # Catalog level policies
+ applicable_policies_list =
policy_api.get_applicable_policies(
+ prefix=self.catalog_name,
+ policy_type=self.policy_type
+ ).applicable_policies
+ for policy in applicable_policies_list:
+ print(policy.to_json())
+ else:
+ # List all policy identifiers in the namespace
+ policies_response = policy_api.list_policies(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_type=self.policy_type
+ ).to_json()
+ print(policies_response)
+ elif self.policies_subcommand == Subcommands.UPDATE:
+ with open(self.policy_file, "r") as f:
+ policy_document = json.load(f)
+ # Fetch the current policy to get its version
+ loaded_policy_response = policy_api.load_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name
+ )
+ if loaded_policy_response and loaded_policy_response.policy:
+ current_policy_version = loaded_policy_response.policy.version
+ else:
+ raise Exception(f"Could not retrieve current policy version
for {self.policy_name}")
+
+ policy_api.update_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name,
+ update_policy_request=UpdatePolicyRequest(
+ description=self.policy_description,
+ content=json.dumps(policy_document),
+ current_policy_version=current_policy_version
+ )
+ )
+ elif self.policies_subcommand == Subcommands.ATTACH:
+ attachment_path_list = [] if self.attachment_type == "catalog"
else self.attachment_path.split('.')
+
+ policy_api.attach_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name,
+ attach_policy_request=AttachPolicyRequest(
+ target=PolicyAttachmentTarget(
+ type=self.attachment_type,
+ path=attachment_path_list
+ ),
+ parameters=self.parameters if isinstance(self.parameters,
dict) else None
+ )
+ )
+ elif self.policies_subcommand == Subcommands.DETACH:
+ attachment_path_list = [] if self.attachment_type == "catalog"
else self.attachment_path.split('.')
+
+ policy_api.detach_policy(
+ prefix=self.catalog_name,
+ namespace=namespace_str,
+ policy_name=self.policy_name,
+ detach_policy_request=DetachPolicyRequest(
+ target=PolicyAttachmentTarget(
+ type=self.attachment_type,
+ path=attachment_path_list
+ ),
+ parameters=self.parameters if isinstance(self.parameters,
dict) else None
+ )
+ )
+ else:
+ raise Exception(f"{self.policies_subcommand} is not supported in
the CLI")
diff --git a/client/python/cli/command/utils.py
b/client/python/cli/command/utils.py
new file mode 100644
index 000000000..b19b56282
--- /dev/null
+++ b/client/python/cli/command/utils.py
@@ -0,0 +1,44 @@
+#
+# 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 re
+
+from polaris.catalog.api_client import ApiClient
+from polaris.catalog.configuration import Configuration
+from polaris.management import PolarisDefaultApi
+
+
+def get_catalog_api_client(api: PolarisDefaultApi) -> ApiClient:
+ """
+ Convert a management API to a catalog API client
+ """
+ mgmt_config = api.api_client.configuration
+ catalog_host = re.sub(r"/api/management(?:/v1)?", "/api/catalog",
mgmt_config.host)
+ configuration = Configuration(
+ host=catalog_host,
+ username=mgmt_config.username,
+ password=mgmt_config.password,
+ access_token=mgmt_config.access_token,
+ )
+
+ if hasattr(mgmt_config, "proxy"):
+ configuration.proxy = mgmt_config.proxy
+ if hasattr(mgmt_config, "proxy_headers"):
+ configuration.proxy_headers = mgmt_config.proxy_headers
+
+ return ApiClient(configuration)
diff --git a/client/python/cli/constants.py b/client/python/cli/constants.py
index 335085d66..756a47d15 100644
--- a/client/python/cli/constants.py
+++ b/client/python/cli/constants.py
@@ -88,6 +88,7 @@ class Commands:
PRIVILEGES = "privileges"
NAMESPACES = "namespaces"
PROFILES = "profiles"
+ POLICIES = "policies"
class Subcommands:
@@ -110,6 +111,8 @@ class Subcommands:
REVOKE = "revoke"
ACCESS = "access"
RESET = "reset"
+ ATTACH = "attach"
+ DETACH = "detach"
class Actions:
@@ -189,6 +192,16 @@ class Arguments:
CATALOG_EXTERNAL_ID = "catalog_external_id"
CATALOG_SIGNING_REGION = "catalog_signing_region"
CATALOG_SIGNING_NAME = "catalog_signing_name"
+ POLICY = "policy"
+ POLICY_FILE = "policy_file"
+ POLICY_TYPE = "policy_type"
+ POLICY_DESCRIPTION = "policy_description"
+ TARGET_NAME = "target_name"
+ PARAMETERS = "parameters"
+ DETACH_ALL = "detach_all"
+ APPLICABLE = "applicable"
+ ATTACHMENT_TYPE = "attachment_type"
+ ATTACHMENT_PATH = "attachment_path"
REALM = "realm"
HEADER = "header"
@@ -373,6 +386,18 @@ class Hints:
LOCATION = "If specified, the location at which to store the namespace
and entities inside it"
PARENT = "If specified, list namespaces inside this parent namespace"
+ class Policies:
+ POLICY = "The name of a policy"
+ POLICY_FILE = "The path to a JSON file containing the policy
definition"
+ POLICY_TYPE = "The type of the policy, e.g., 'system.data-compaction'"
+ POLICY_DESCRIPTION = "An optional description for the policy."
+ TARGET_NAME = "The name of the target entity (e.g., table name,
namespace name)."
+ PARAMETERS = "Optional key-value pairs for the attachment/detachment,
e.g., key=value. Can be specified multiple times."
+ DETACH_ALL = "When set to true, the policy will be deleted along with
all its attached mappings."
+ APPLICABLE = "When set, lists policies applicable to the target entity
(considering inheritance) instead of policies defined directly in the target."
+ ATTACHMENT_TYPE = "The type of entity to attach the policy to, e.g.,
'catalog', 'namespace', or table-like."
+ ATTACHMENT_PATH = "The path of the entity to attach the policy to,
e.g., 'ns1.tb1'. Not required for catalog-level attachment."
+
UNIT_SEPARATOR = chr(0x1F)
CLIENT_ID_ENV = "CLIENT_ID"
diff --git a/client/python/cli/options/option_tree.py
b/client/python/cli/options/option_tree.py
index f71589d90..78f15b96e 100644
--- a/client/python/cli/options/option_tree.py
+++ b/client/python/cli/options/option_tree.py
@@ -277,5 +277,50 @@ class OptionTree:
Option(Subcommands.UPDATE, input_name=Arguments.PROFILE),
Option(Subcommands.GET, input_name=Arguments.PROFILE),
Option(Subcommands.LIST),
- ])
+ ]),
+ Option(Commands.POLICIES, 'manage policies', children=[
+ Option(Subcommands.CREATE, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.POLICY_FILE, str,
Hints.Policies.POLICY_FILE),
+ Argument(Arguments.POLICY_TYPE, str,
Hints.Policies.POLICY_TYPE),
+ Argument(Arguments.POLICY_DESCRIPTION, str,
Hints.Policies.POLICY_DESCRIPTION),
+ ], input_name=Arguments.POLICY),
+ Option(Subcommands.DELETE, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.DETACH_ALL, bool,
Hints.Policies.DETACH_ALL),
+ ], input_name=Arguments.POLICY),
+ Option(Subcommands.GET, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ ], input_name=Arguments.POLICY),
+ Option(Subcommands.LIST, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.TARGET_NAME, str,
Hints.Policies.TARGET_NAME),
+ Argument(Arguments.APPLICABLE, bool,
Hints.Policies.APPLICABLE),
+ Argument(Arguments.POLICY_TYPE, str,
Hints.Policies.POLICY_TYPE),
+ ]),
+ Option(Subcommands.UPDATE, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.POLICY_FILE, str,
Hints.Policies.POLICY_FILE),
+ Argument(Arguments.POLICY_DESCRIPTION, str,
Hints.Policies.POLICY_DESCRIPTION),
+ ], input_name=Arguments.POLICY),
+ Option(Subcommands.ATTACH, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.ATTACHMENT_TYPE, str,
Hints.Policies.ATTACHMENT_TYPE),
+ Argument(Arguments.ATTACHMENT_PATH, str,
Hints.Policies.ATTACHMENT_PATH),
+ Argument(Arguments.PARAMETERS, str,
Hints.Policies.PARAMETERS, allow_repeats=True),
+ ], input_name=Arguments.POLICY),
+ Option(Subcommands.DETACH, args=[
+ Argument(Arguments.CATALOG, str,
Hints.CatalogRoles.CATALOG_NAME),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ Argument(Arguments.ATTACHMENT_TYPE, str,
Hints.Policies.ATTACHMENT_TYPE),
+ Argument(Arguments.ATTACHMENT_PATH, str,
Hints.Policies.ATTACHMENT_PATH),
+ Argument(Arguments.PARAMETERS, str,
Hints.Policies.PARAMETERS, allow_repeats=True),
+ ], input_name=Arguments.POLICY),
+ ]),
]
diff --git a/client/python/cli/polaris_cli.py b/client/python/cli/polaris_cli.py
index c038bedbf..78d8728e3 100644
--- a/client/python/cli/polaris_cli.py
+++ b/client/python/cli/polaris_cli.py
@@ -46,8 +46,50 @@ class PolarisCli:
# Can be enabled if the client is able to authenticate directly without
first fetching a token
DIRECT_AUTHENTICATION_ENABLED = False
+ @staticmethod
+ def _patch_generated_models() -> None:
+ """
+ The OpenAPI generator creates an `api_client` that dynamically looks up
+ model classes from the `polaris.catalog.models` module using
`getattr()`.
+ For example, when a response for a `create_policy` call is received,
the
+ deserializer tries to find the `LoadPolicyResponse` class by looking
for
+ `polaris.catalog.models.LoadPolicyResponse`.
+
+ However, the generator fails to add the necessary `import` statements
+ to the `polaris/catalog/models/__init__.py` file. This means that even
+ though the model files exist (e.g., `load_policy_response.py`), the
classes
+ are not part of the `polaris.catalog.models` namespace.
+
+ This method works around the bug in the generated code without
modifying
+ the source files. It runs once per CLI execution, before any commands,
and
+ manually injects the missing response-side model classes into the
+ `polaris.catalog.models` namespace, allowing the deserializer to find
them.
+ """
+ import polaris.catalog.models
+ from polaris.catalog.models.applicable_policy import ApplicablePolicy
+ from polaris.catalog.models.get_applicable_policies_response import
GetApplicablePoliciesResponse
+ from polaris.catalog.models.list_policies_response import
ListPoliciesResponse
+ from polaris.catalog.models.load_policy_response import
LoadPolicyResponse
+ from polaris.catalog.models.policy import Policy
+ from polaris.catalog.models.policy_attachment_target import
PolicyAttachmentTarget
+ from polaris.catalog.models.policy_identifier import PolicyIdentifier
+
+ models_to_patch = {
+ "ApplicablePolicy": ApplicablePolicy,
+ "GetApplicablePoliciesResponse": GetApplicablePoliciesResponse,
+ "ListPoliciesResponse": ListPoliciesResponse,
+ "LoadPolicyResponse": LoadPolicyResponse,
+ "Policy": Policy,
+ "PolicyAttachmentTarget": PolicyAttachmentTarget,
+ "PolicyIdentifier": PolicyIdentifier,
+ }
+
+ for name, model_class in models_to_patch.items():
+ setattr(polaris.catalog.models, name, model_class)
+
@staticmethod
def execute(args=None):
+ PolarisCli._patch_generated_models()
options = Parser.parse(args)
if options.command == Commands.PROFILES:
from cli.command import Command
diff --git a/client/python/integration_tests/conftest.py
b/client/python/integration_tests/conftest.py
index eaa98e1e4..adfb9d6cd 100644
--- a/client/python/integration_tests/conftest.py
+++ b/client/python/integration_tests/conftest.py
@@ -30,8 +30,10 @@ from pyiceberg.types import NestedField, StringType,
IntegerType, BooleanType
from polaris.catalog import OAuthTokenResponse
from polaris.catalog.api.iceberg_catalog_api import IcebergCatalogAPI
from polaris.catalog.api.iceberg_o_auth2_api import IcebergOAuth2API
+from polaris.catalog.api.policy_api import PolicyAPI
from polaris.catalog.api_client import ApiClient as CatalogApiClient
from polaris.catalog.api_client import Configuration as
CatalogApiClientConfiguration
+
from polaris.management import (
Catalog,
ApiClient,
@@ -197,13 +199,28 @@ def test_catalog_client(
return IcebergCatalogAPI(
CatalogApiClient(
- Configuration(
+ CatalogApiClientConfiguration(
access_token=test_principal_token.access_token,
host=polaris_catalog_url
)
)
)
[email protected]
+def test_policy_api(
+ polaris_catalog_url: str,
+ test_principal_token: OAuthTokenResponse,
+) -> PolicyAPI:
+ return PolicyAPI(
+ CatalogApiClient(
+ CatalogApiClientConfiguration(
+ access_token=test_principal_token.access_token,
+ host=polaris_catalog_url,
+ )
+ )
+ )
+
+
@pytest.fixture
def test_catalog(
management_client: PolarisDefaultApi,
@@ -356,3 +373,47 @@ def clear_namespace(
def format_namespace(namespace: List[str]) -> str:
return codecs.decode("1F", "hex").decode("UTF-8").join(namespace)
+
+
[email protected](scope="session", autouse=True)
+def _patch_generated_models() -> None:
+ """
+ The OpenAPI generator creates an `api_client` that dynamically looks up
+ model classes from the `polaris.catalog.models` module using `getattr()`.
+ For example, when a response for a `create_policy` call is received, the
+ deserializer tries to find the `LoadPolicyResponse` class by looking for
+ `polaris.catalog.models.LoadPolicyResponse`.
+
+ However, the generator fails to add the necessary `import` statements
+ to the `polaris/catalog/models/__init__.py` file. This means that even
+ though the model files exist (e.g., `load_policy_response.py`), the classes
+ are not part of the `polaris.catalog.models` namespace.
+
+ This fixture works around the bug in the generated code without modifying
+ the source files. It runs once per test session, before any tests, and
+ manually injects the missing response-side model classes into the
+ `polaris.catalog.models` namespace, allowing the deserializer to find them.
+ """
+ import polaris.catalog.models
+ from polaris.catalog.models.applicable_policy import ApplicablePolicy
+ from polaris.catalog.models.get_applicable_policies_response import (
+ GetApplicablePoliciesResponse,
+ )
+ from polaris.catalog.models.list_policies_response import
ListPoliciesResponse
+ from polaris.catalog.models.load_policy_response import LoadPolicyResponse
+ from polaris.catalog.models.policy import Policy
+ from polaris.catalog.models.policy_attachment_target import
PolicyAttachmentTarget
+ from polaris.catalog.models.policy_identifier import PolicyIdentifier
+
+ models_to_patch = {
+ "ApplicablePolicy": ApplicablePolicy,
+ "GetApplicablePoliciesResponse": GetApplicablePoliciesResponse,
+ "ListPoliciesResponse": ListPoliciesResponse,
+ "LoadPolicyResponse": LoadPolicyResponse,
+ "Policy": Policy,
+ "PolicyAttachmentTarget": PolicyAttachmentTarget,
+ "PolicyIdentifier": PolicyIdentifier,
+ }
+
+ for name, model_class in models_to_patch.items():
+ setattr(polaris.catalog.models, name, model_class)
diff --git a/client/python/integration_tests/test_catalog_apis.py
b/client/python/integration_tests/test_catalog_apis.py
index 60109713e..214329a06 100644
--- a/client/python/integration_tests/test_catalog_apis.py
+++ b/client/python/integration_tests/test_catalog_apis.py
@@ -17,6 +17,7 @@
# under the License.
#
+import json
import os.path
import time
@@ -30,6 +31,13 @@ from polaris.catalog import (
TableIdentifier,
IcebergCatalogAPI,
)
+from polaris.catalog.models.attach_policy_request import AttachPolicyRequest
+from polaris.catalog.models.create_policy_request import CreatePolicyRequest
+from polaris.catalog.models.detach_policy_request import DetachPolicyRequest
+from polaris.catalog.models.policy_attachment_target import
PolicyAttachmentTarget
+from polaris.catalog.models.update_policy_request import UpdatePolicyRequest
+from polaris.catalog.exceptions import NotFoundException
+from polaris.catalog.api.policy_api import PolicyAPI
from pyiceberg.schema import Schema
from polaris.management import Catalog
from pyiceberg.catalog import Catalog as PyIcebergCatalog
@@ -168,3 +176,213 @@ def test_drop_table_purge(
if os.path.exists(metadata_location):
pytest.fail(f"Metadata file {metadata_location} still exists after 60
seconds.")
+
+
+def test_policies(
+ test_catalog: Catalog,
+ test_catalog_client: IcebergCatalogAPI,
+ test_policy_api: PolicyAPI,
+ test_table_schema: Schema,
+) -> None:
+ # Resource identifiers
+ namespace_name = "POLICY_NS1"
+ sub_namespace = "POLICY_NS2"
+ sub_namespace_path = [namespace_name, sub_namespace]
+ policy_name = "test_policy"
+ table_name = "test_table_for_policy"
+ table_path = sub_namespace_path + [table_name]
+
+ policy_content = {
+ "version": "2025-02-03",
+ "enable": True,
+ "config": {"target_file_size_bytes": 134217728},
+ }
+ policy_type = "system.data-compaction"
+ policy_description = "A test policy"
+
+ # Create resources
+ test_catalog_client.create_namespace(
+ prefix=test_catalog.name,
+
create_namespace_request=CreateNamespaceRequest(namespace=[namespace_name]),
+ )
+ test_catalog_client.create_namespace(
+ prefix=test_catalog.name,
+
create_namespace_request=CreateNamespaceRequest(namespace=sub_namespace_path),
+ )
+ test_catalog_client.create_table(
+ prefix=test_catalog.name,
+ namespace=format_namespace(sub_namespace_path),
+ create_table_request=CreateTableRequest(
+ name=table_name,
+ var_schema=ModelSchema.from_dict(test_table_schema.model_dump()),
+ ),
+ )
+ test_policy_api.create_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ create_policy_request=CreatePolicyRequest(
+ name=policy_name,
+ type=policy_type,
+ description=policy_description,
+ content=json.dumps(policy_content),
+ ),
+ )
+
+ try:
+ # GET
+ loaded_policy = test_policy_api.load_policy(
+ prefix=test_catalog.name, namespace=namespace_name,
policy_name=policy_name
+ )
+ assert loaded_policy.policy.name == policy_name
+ assert loaded_policy.policy.policy_type == policy_type
+ assert loaded_policy.policy.description == policy_description
+ assert json.loads(loaded_policy.policy.content) == policy_content
+
+ # LIST
+ policies = test_policy_api.list_policies(
+ prefix=test_catalog.name, namespace=namespace_name
+ )
+ assert len(policies.identifiers) == 1
+ assert policies.identifiers[0].name == policy_name
+ assert policies.identifiers[0].namespace == [namespace_name]
+
+ # UPDATE
+ updated_policy_content = {
+ "version": "2025-02-03",
+ "enable": False,
+ "config": {"target_file_size_bytes": 134217728},
+ }
+ updated_policy_description = "An updated test policy"
+ test_policy_api.update_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ update_policy_request=UpdatePolicyRequest(
+ description=updated_policy_description,
+ content=json.dumps(updated_policy_content),
+ current_policy_version=loaded_policy.policy.version,
+ ),
+ )
+
+ # GET after UPDATE
+ updated_policy = test_policy_api.load_policy(
+ prefix=test_catalog.name, namespace=namespace_name,
policy_name=policy_name
+ )
+ assert updated_policy.policy.description == updated_policy_description
+ assert json.loads(updated_policy.policy.content) ==
updated_policy_content
+
+ # ATTACH to namespace
+ test_policy_api.attach_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ attach_policy_request=AttachPolicyRequest(
+ target=PolicyAttachmentTarget(type="namespace",
path=sub_namespace_path)
+ ),
+ )
+
+ # GET APPLICABLE on namespace
+ applicable_policies = test_policy_api.get_applicable_policies(
+ prefix=test_catalog.name,
namespace=format_namespace(sub_namespace_path)
+ )
+ assert len(applicable_policies.applicable_policies) == 1
+ assert applicable_policies.applicable_policies[0].name == policy_name
+
+ # DETACH from namespace
+ test_policy_api.detach_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ detach_policy_request=DetachPolicyRequest(
+ target=PolicyAttachmentTarget(type="namespace",
path=sub_namespace_path)
+ ),
+ )
+
+ # GET APPLICABLE on namespace after DETACH
+ applicable_policies_after_detach =
test_policy_api.get_applicable_policies(
+ prefix=test_catalog.name,
namespace=format_namespace(sub_namespace_path)
+ )
+ assert len(applicable_policies_after_detach.applicable_policies) == 0
+
+ # ATTACH to table
+ test_policy_api.attach_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ attach_policy_request=AttachPolicyRequest(
+ target=PolicyAttachmentTarget(type="table-like",
path=table_path)
+ ),
+ )
+
+ # GET APPLICABLE on table
+ applicable_for_table = test_policy_api.get_applicable_policies(
+ prefix=test_catalog.name,
+ namespace=format_namespace(sub_namespace_path),
+ target_name=table_name,
+ )
+ assert len(applicable_for_table.applicable_policies) == 1
+ assert applicable_for_table.applicable_policies[0].name == policy_name
+
+ # DETACH from table
+ test_policy_api.detach_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ detach_policy_request=DetachPolicyRequest(
+ target=PolicyAttachmentTarget(type="table-like",
path=table_path)
+ ),
+ )
+
+ # GET APPLICABLE on table after DETACH
+ applicable_for_table_after_detach =
test_policy_api.get_applicable_policies(
+ prefix=test_catalog.name,
+ namespace=format_namespace(sub_namespace_path),
+ target_name=table_name,
+ )
+ assert len(applicable_for_table_after_detach.applicable_policies) == 0
+
+ # DELETE
+ test_policy_api.drop_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ detach_all=True,
+ )
+
+ # LIST after DELETE
+ policies_after_delete = test_policy_api.list_policies(
+ prefix=test_catalog.name, namespace=namespace_name
+ )
+ assert len(policies_after_delete.identifiers) == 0
+
+ finally:
+ # Cleanup
+ try:
+ test_policy_api.drop_policy(
+ prefix=test_catalog.name,
+ namespace=namespace_name,
+ policy_name=policy_name,
+ detach_all=True,
+ )
+ except NotFoundException:
+ pass
+ try:
+ test_catalog_client.drop_table(
+ prefix=test_catalog.name,
+ namespace=format_namespace(sub_namespace_path),
+ table=table_name,
+ )
+ except NotFoundException:
+ pass
+ try:
+ test_catalog_client.drop_namespace(
+ prefix=test_catalog.name,
namespace=format_namespace(sub_namespace_path)
+ )
+ except NotFoundException:
+ pass
+ try:
+ test_catalog_client.drop_namespace(
+ prefix=test_catalog.name, namespace=namespace_name
+ )
+ except NotFoundException:
+ pass
diff --git a/site/content/in-dev/unreleased/command-line-interface.md
b/site/content/in-dev/unreleased/command-line-interface.md
index cfe606ba1..2c7c26563 100644
--- a/site/content/in-dev/unreleased/command-line-interface.md
+++ b/site/content/in-dev/unreleased/command-line-interface.md
@@ -39,6 +39,7 @@ options:
--realm
--header
--profile
+--proxy
```
`COMMAND` must be one of the following:
@@ -49,7 +50,8 @@ options:
5. namespaces
6. privileges
7. profiles
-8. repair
+8. policies
+9. repair
Each _command_ supports several _subcommands_, and some _subcommands_ have
_actions_ that come after the subcommand in turn. Finally, _arguments_ follow
to form a full invocation. Within a set of named arguments at the end of an
invocation ordering is generally not important. Many invocations also have a
required positional argument of the type that the _command_ refers to. Again,
the ordering of this positional argument relative to named arguments is not
important.
@@ -62,6 +64,7 @@ polaris catalogs update --property foo=bar some_other_catalog
polaris catalogs update another_catalog --property k=v
polaris privileges namespace grant --namespace some.schema --catalog
fourth_catalog --catalog-role some_catalog_role TABLE_READ_DATA
polaris profiles list
+polaris policies list --catalog some_catalog --namespace some.schema
polaris repair
```
@@ -1225,6 +1228,192 @@ options:
polaris profiles update dev
```
+### Policies
+
+The `policies` command is used to manage policies within Polaris.
+
+`policies` supports the following subcommands:
+
+1. attach
+2. create
+3. delete
+4. detach
+5. get
+6. list
+7. update
+
+#### attach
+
+The `attach` subcommand is used to create a mapping between a policy and a
resource entity.
+
+```
+input: polaris policies attach --help
+options:
+ attach
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --attachment-type The type of entity to attach the policy to, e.g.,
'catalog', 'namespace', or table-like.
+ --attachment-path The path of the entity to attach the policy to, e.g.,
'ns1.tb1'. Not required for catalog-level attachment.
+ --parameters Optional key-value pairs for the attachment/detachment,
e.g., key=value. Can be specified multiple times.
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies attach --catalog some_catalog --namespace some.schema
--attachment-type namespace --attachment-path some.schema some_policy
+
+polaris policies attach --catalog some_catalog --namespace some.schema
--attachment-type table-like --attachment-path some.schema.t some_table_policy
+```
+
+#### create
+
+The `create` subcommand is used to create a policy.
+
+```
+input: polaris policies create --help
+options:
+ create
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --policy-file The path to a JSON file containing the policy definition
+ --policy-type The type of the policy, e.g., 'system.data-compaction'
+ --policy-description An optional description for the policy.
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies create --catalog some_catalog --namespace some.schema
--policy-file some_policy.json --policy-type system.data-compaction some_policy
+
+polaris policies create --catalog some_catalog --namespace some.schema
--policy-file some_snapshot_expiry_policy.json --policy-type
system.snapshot-expiry some_snapshot_expiry_policy
+```
+
+#### delete
+
+The `delete` subcommand is used to delete a policy.
+
+```
+input: polaris policies delete --help
+options:
+ delete
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --detach-all When set to true, the policy will be deleted along with
all its attached mappings.
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies delete --catalog some_catalog --namespace some.schema
some_policy
+
+polaris policies delete --catalog some_catalog --namespace some.schema
--detach-all some_policy
+```
+
+#### detach
+
+The `detach` subcommand is used to remove a mapping between a policy and a
target entity
+
+```
+input: polaris policies detach --help
+options:
+ detach
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --attachment-type The type of entity to attach the policy to, e.g.,
'catalog', 'namespace', or table-like.
+ --attachment-path The path of the entity to attach the policy to, e.g.,
'ns1.tb1'. Not required for catalog-level attachment.
+ --parameters Optional key-value pairs for the attachment/detachment,
e.g., key=value. Can be specified multiple times.
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies detach --catalog some_catalog --namespace some.schema
--attachment-type namespace --attachment-path some.schema some_policy
+
+polaris policies detach --catalog some_catalog --namespace some.schema
--attachment-type catalog --attachment-path some_catalog some_policy
+```
+
+#### get
+
+The `get` subcommand is used to load a policy from the catalog.
+
+```
+input: polaris policies get --help
+options:
+ get
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies get --catalog some_catalog --namespace some.schema some_policy
+```
+
+#### list
+
+The `list` subcommand is used to get all policy identifiers under this
namespace and all applicable policies for a specified entity.
+
+```
+input: polaris policies list --help
+options:
+ list
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --target-name The name of the target entity (e.g., table name,
namespace name).
+ --applicable When set, lists policies applicable to the target entity
(considering inheritance) instead of policies defined directly in the target.
+ --policy-type The type of the policy, e.g., 'system.data-compaction'
+```
+
+##### Examples
+
+```
+polaris policies list --catalog some_catalog
+
+polaris policies list --catalog some_catalog --applicable
+```
+
+#### update
+
+The `update` subcommand is used to update a policy.
+
+```
+input: polaris policies update --help
+options:
+ update
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ --policy-file The path to a JSON file containing the policy definition
+ --policy-description An optional description for the policy.
+ Positional arguments:
+ policy
+```
+
+##### Examples
+
+```
+polaris policies update --catalog some_catalog --namespace some.schema
--policy-file my_updated_policy.json my_policy
+
+polaris policies update --catalog some_catalog --namespace some.schema
--policy-file my_updated_policy.json --policy-description "Updated policy
description" my_policy
+```
+
### repair
The `repair` command is a bash script wrapper used to regenerate Python client
code and update necessary dependencies, ensuring the Polaris client remains
up-to-date and functional. **Please note that this command does not support any
options and its usage information is not available via a `--help` flag.**