diff --git a/clients/client-python/gravitino/api/authorization/privileges.py b/clients/client-python/gravitino/api/authorization/privileges.py index cfd1b5ab295..fde10d00e9d 100644 --- a/clients/client-python/gravitino/api/authorization/privileges.py +++ b/clients/client-python/gravitino/api/authorization/privileges.py @@ -265,9 +265,9 @@ def __hash__(self) -> int: return hash((self._condition, self._name)) def __eq__(self, value: object) -> bool: - if not isinstance(value, GenericPrivilege): + if not isinstance(value, Privilege): return False - return self._condition == value._condition and self._name == value._name + return self._condition == value.condition() and self._name == value.name() class CreateCatalog(GenericPrivilege): diff --git a/clients/client-python/gravitino/client/dto_converters.py b/clients/client-python/gravitino/client/dto_converters.py index 925063a41b5..186c1017850 100644 --- a/clients/client-python/gravitino/client/dto_converters.py +++ b/clients/client-python/gravitino/client/dto_converters.py @@ -17,6 +17,11 @@ from __future__ import annotations +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.authorization.securable_objects import ( + SecurableObject, + SecurableObjects, +) from gravitino.api.catalog import Catalog from gravitino.api.catalog_change import CatalogChange from gravitino.api.job.job_template import JobTemplate, JobType @@ -36,6 +41,8 @@ from gravitino.client.fileset_catalog import FilesetCatalog from gravitino.client.generic_model_catalog import GenericModelCatalog from gravitino.client.relational_catalog import RelationalCatalog +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO from gravitino.dto.catalog_dto import CatalogDTO from gravitino.dto.job.job_template_dto import JobTemplateDTO from gravitino.dto.job.shell_job_template_dto import ShellJobTemplateDTO @@ -325,3 +332,20 @@ def to_tag_update_request( return TagUpdateRequest.RemoveTagPropertyRequest(change.removed_property) raise IllegalArgumentException(f"Unknown change type: {type(change)}") + + @staticmethod + def to_privilege_dto(privilege: Privilege) -> PrivilegeDTO: + return PrivilegeDTO(privilege.name(), privilege.condition()) + + @staticmethod + def from_privilege_dto(dto: PrivilegeDTO) -> Privilege: + return dto + + @staticmethod + def to_securable_object_dto(obj: SecurableObject) -> SecurableObjectDTO: + privilege_dtos = [DTOConverters.to_privilege_dto(p) for p in obj.privileges()] + return SecurableObjectDTO(obj.full_name(), obj.type(), privilege_dtos) + + @staticmethod + def from_securable_object_dto(dto: SecurableObjectDTO) -> SecurableObject: + return SecurableObjects.parse(dto.full_name(), dto.type(), dto.privileges()) diff --git a/clients/client-python/gravitino/client/gravitino_client.py b/clients/client-python/gravitino/client/gravitino_client.py index f61185d36c2..b7f31377bcb 100644 --- a/clients/client-python/gravitino/client/gravitino_client.py +++ b/clients/client-python/gravitino/client/gravitino_client.py @@ -21,6 +21,9 @@ from gravitino.api.authorization.group import Group from gravitino.api.authorization.owner import Owner +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.authorization.role import Role +from gravitino.api.authorization.securable_objects import SecurableObject from gravitino.api.authorization.user import User from gravitino.api.catalog import Catalog from gravitino.api.catalog_change import CatalogChange @@ -507,3 +510,177 @@ def list_group_names(self) -> list[str]: NoSuchMetalakeException: If the metalake does not exist. """ return self.get_metalake().list_group_names() + + # Role operations + + def create_role( + self, + role_name: str, + properties: Optional[Dict[str, str]] = None, + securable_objects: Optional[List[SecurableObject]] = None, + ) -> Role: + """Create a new role under the metalake. + + Args: + role_name: The name of the role. + properties: The properties of the role. + securable_objects: The securable objects of the role. + + Returns: + The created Role object. + + Raises: + RoleAlreadyExistsException: If a role with the same name already exists. + NoSuchMetalakeException: If the metalake does not exist. + """ + return self.get_metalake().create_role(role_name, properties, securable_objects) + + def get_role(self, role_name: str) -> Role: + """Get a role by name from the metalake. + + Args: + role_name: The name of the role. + + Returns: + The Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + return self.get_metalake().get_role(role_name) + + def delete_role(self, role_name: str) -> bool: + """Delete a role from the metalake. + + Args: + role_name: The name of the role. + + Returns: + True if the role was deleted, False otherwise. + + Raises: + NoSuchMetalakeException: If the metalake does not exist. + """ + return self.get_metalake().delete_role(role_name) + + def list_role_names(self) -> list[str]: + """List all role names under the metalake. + + Returns: + A list of role name strings. + + Raises: + NoSuchMetalakeException: If the metalake does not exist. + """ + return self.get_metalake().list_role_names() + + def grant_roles_to_user(self, role_names: List[str], user_name: str) -> User: + """Grant roles to a user. + + Args: + role_names: The names of the roles to grant. + user_name: The name of the user. + + Returns: + The updated User object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchUserException: If the user does not exist. + """ + return self.get_metalake().grant_roles_to_user(role_names, user_name) + + def revoke_roles_from_user(self, role_names: List[str], user_name: str) -> User: + """Revoke roles from a user. + + Args: + role_names: The names of the roles to revoke. + user_name: The name of the user. + + Returns: + The updated User object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchUserException: If the user does not exist. + """ + return self.get_metalake().revoke_roles_from_user(role_names, user_name) + + def grant_roles_to_group(self, role_names: List[str], group_name: str) -> Group: + """Grant roles to a group. + + Args: + role_names: The names of the roles to grant. + group_name: The name of the group. + + Returns: + The updated Group object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchGroupException: If the group does not exist. + """ + return self.get_metalake().grant_roles_to_group(role_names, group_name) + + def revoke_roles_from_group(self, role_names: List[str], group_name: str) -> Group: + """Revoke roles from a group. + + Args: + role_names: The names of the roles to revoke. + group_name: The name of the group. + + Returns: + The updated Group object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchGroupException: If the group does not exist. + """ + return self.get_metalake().revoke_roles_from_group(role_names, group_name) + + def grant_privileges_to_role( + self, + role_name: str, + securable_object: SecurableObject, + privileges: List[Privilege], + ) -> Role: + """Grant privileges to a role on a securable object. + + Args: + role_name: The name of the role. + securable_object: The securable object. + privileges: The privileges to grant. + + Returns: + The updated Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + """ + return self.get_metalake().grant_privileges_to_role( + role_name, securable_object, privileges + ) + + def revoke_privileges_from_role( + self, + role_name: str, + securable_object: SecurableObject, + privileges: List[Privilege], + ) -> Role: + """Revoke privileges from a role on a securable object. + + Args: + role_name: The name of the role. + securable_object: The securable object. + privileges: The privileges to revoke. + + Returns: + The updated Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + """ + return self.get_metalake().revoke_privileges_from_role( + role_name, securable_object, privileges + ) diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py b/clients/client-python/gravitino/client/gravitino_metalake.py index 65c1d4354cb..74ed932a9a9 100644 --- a/clients/client-python/gravitino/client/gravitino_metalake.py +++ b/clients/client-python/gravitino/client/gravitino_metalake.py @@ -15,11 +15,15 @@ # specific language governing permissions and limitations # under the License. +# pylint: disable=too-many-lines import logging from typing import Dict, List, Optional from gravitino.api.authorization.group import Group from gravitino.api.authorization.owner import Owner +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.authorization.role import Role +from gravitino.api.authorization.securable_objects import SecurableObject from gravitino.api.authorization.user import User from gravitino.api.catalog import Catalog from gravitino.api.catalog_change import CatalogChange @@ -49,6 +53,11 @@ from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest from gravitino.dto.requests.user_add_request import UserAddRequest from gravitino.dto.requests.group_add_request import GroupAddRequest +from gravitino.dto.requests.privilege_grant_request import PrivilegeGrantRequest +from gravitino.dto.requests.privilege_revoke_request import PrivilegeRevokeRequest +from gravitino.dto.requests.role_create_request import RoleCreateRequest +from gravitino.dto.requests.role_grant_request import RoleGrantRequest +from gravitino.dto.requests.role_revoke_request import RoleRevokeRequest from gravitino.dto.responses.catalog_list_response import CatalogListResponse from gravitino.dto.responses.catalog_response import CatalogResponse from gravitino.dto.responses.drop_response import DropResponse @@ -75,8 +84,16 @@ GroupNamesListResponse, GroupResponse, ) +from gravitino.dto.responses.role_response import ( + RoleNamesListResponse, + RoleResponse, +) from gravitino.exceptions.handlers.catalog_error_handler import CATALOG_ERROR_HANDLER from gravitino.exceptions.handlers.group_error_handler import GROUP_ERROR_HANDLER +from gravitino.exceptions.handlers.permission_error_handler import ( + PERMISSION_ERROR_HANDLER, +) +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.tag_error_handler import TAG_ERROR_HANDLER @@ -114,6 +131,18 @@ class GravitinoMetalake( API_METALAKES_USER_PATH = "api/metalakes/{}/users/{}" API_METALAKES_GROUPS_PATH = "api/metalakes/{}/groups" API_METALAKES_GROUP_PATH = "api/metalakes/{}/groups/{}" + API_METALAKES_ROLES_PATH = "api/metalakes/{}/roles" + API_METALAKES_ROLE_PATH = "api/metalakes/{}/roles/{}" + API_PERMISSIONS_USER_GRANT_PATH = "api/metalakes/{}/permissions/users/{}/grant" + API_PERMISSIONS_USER_REVOKE_PATH = "api/metalakes/{}/permissions/users/{}/revoke" + API_PERMISSIONS_GROUP_GRANT_PATH = "api/metalakes/{}/permissions/groups/{}/grant" + API_PERMISSIONS_GROUP_REVOKE_PATH = "api/metalakes/{}/permissions/groups/{}/revoke" + API_PERMISSIONS_ROLE_GRANT_PATH = ( + "api/metalakes/{}/permissions/roles/{}/{}/{}/grant" + ) + API_PERMISSIONS_ROLE_REVOKE_PATH = ( + "api/metalakes/{}/permissions/roles/{}/{}/{}/revoke" + ) def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = None): super().__init__( @@ -1002,3 +1031,286 @@ def list_group_names(self) -> list[str]: resp = GroupNamesListResponse.from_json(response.body, infer_missing=True) resp.validate() return resp.names() + + ##################### + # Role operations + ##################### + + def create_role( + self, + role_name: str, + properties: Optional[Dict[str, str]] = None, + securable_objects: Optional[List[SecurableObject]] = None, + ) -> Role: + """Create a new role under this metalake. + + Args: + role_name: The name of the role. + properties: The properties of the role. + securable_objects: The securable objects of the role. + + Returns: + The created Role object. + + Raises: + RoleAlreadyExistsException: If a role with the same name already exists. + NoSuchMetalakeException: If the metalake does not exist. + """ + Precondition.check_string_not_empty( + role_name, "role name must not be null or empty" + ) + securable_object_dtos = [ + DTOConverters.to_securable_object_dto(obj) + for obj in (securable_objects or []) + ] + req = RoleCreateRequest(role_name, properties, securable_object_dtos) + req.validate() + url = self.API_METALAKES_ROLES_PATH.format(encode_string(self.name())) + response = self.rest_client.post( + url, json=req, error_handler=ROLE_ERROR_HANDLER + ) + resp = RoleResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.role() + + def get_role(self, role_name: str) -> Role: + """Get a role by name from this metalake. + + Args: + role_name: The name of the role. + + Returns: + The Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + Precondition.check_string_not_empty( + role_name, "role name must not be null or empty" + ) + url = self.API_METALAKES_ROLE_PATH.format( + encode_string(self.name()), encode_string(role_name) + ) + response = self.rest_client.get(url, error_handler=ROLE_ERROR_HANDLER) + resp = RoleResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.role() + + def delete_role(self, role_name: str) -> bool: + """Delete a role from this metalake. + + Args: + role_name: The name of the role. + + Returns: + True if the role was deleted, False otherwise. + + Raises: + NoSuchMetalakeException: If the metalake does not exist. + """ + Precondition.check_string_not_empty( + role_name, "role name must not be null or empty" + ) + url = self.API_METALAKES_ROLE_PATH.format( + encode_string(self.name()), encode_string(role_name) + ) + response = self.rest_client.delete(url, error_handler=ROLE_ERROR_HANDLER) + drop_response = DropResponse.from_json(response.body, infer_missing=True) + drop_response.validate() + return drop_response.dropped() + + def list_role_names(self) -> list[str]: + """List all role names under this metalake. + + Returns: + A list of role name strings. + + Raises: + NoSuchMetalakeException: If the metalake does not exist. + """ + url = self.API_METALAKES_ROLES_PATH.format(encode_string(self.name())) + response = self.rest_client.get(url, error_handler=ROLE_ERROR_HANDLER) + resp = RoleNamesListResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.names() + + def grant_roles_to_user(self, role_names: List[str], user_name: str) -> User: + """Grant roles to a user. + + Args: + role_names: The names of the roles to grant. + user_name: The name of the user. + + Returns: + The updated User object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchUserException: If the user does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + req = RoleGrantRequest(role_names) + req.validate() + url = self.API_PERMISSIONS_USER_GRANT_PATH.format( + encode_string(self.name()), encode_string(user_name) + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = UserResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.user() + + def revoke_roles_from_user(self, role_names: List[str], user_name: str) -> User: + """Revoke roles from a user. + + Args: + role_names: The names of the roles to revoke. + user_name: The name of the user. + + Returns: + The updated User object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchUserException: If the user does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + req = RoleRevokeRequest(role_names) + req.validate() + url = self.API_PERMISSIONS_USER_REVOKE_PATH.format( + encode_string(self.name()), encode_string(user_name) + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = UserResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.user() + + def grant_roles_to_group(self, role_names: List[str], group_name: str) -> Group: + """Grant roles to a group. + + Args: + role_names: The names of the roles to grant. + group_name: The name of the group. + + Returns: + The updated Group object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchGroupException: If the group does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + req = RoleGrantRequest(role_names) + req.validate() + url = self.API_PERMISSIONS_GROUP_GRANT_PATH.format( + encode_string(self.name()), encode_string(group_name) + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = GroupResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.group() + + def revoke_roles_from_group(self, role_names: List[str], group_name: str) -> Group: + """Revoke roles from a group. + + Args: + role_names: The names of the roles to revoke. + group_name: The name of the group. + + Returns: + The updated Group object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchGroupException: If the group does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + req = RoleRevokeRequest(role_names) + req.validate() + url = self.API_PERMISSIONS_GROUP_REVOKE_PATH.format( + encode_string(self.name()), encode_string(group_name) + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = GroupResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.group() + + def grant_privileges_to_role( + self, + role_name: str, + securable_object: SecurableObject, + privileges: List[Privilege], + ) -> Role: + """Grant privileges to a role on a securable object. + + Args: + role_name: The name of the role. + securable_object: The securable object. + privileges: The privileges to grant. + + Returns: + The updated Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + privilege_dtos = [DTOConverters.to_privilege_dto(p) for p in privileges] + req = PrivilegeGrantRequest(privilege_dtos) + req.validate() + url = self.API_PERMISSIONS_ROLE_GRANT_PATH.format( + encode_string(self.name()), + encode_string(role_name), + encode_string(securable_object.type().name.lower()), + encode_string(securable_object.full_name()), + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = RoleResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.role() + + def revoke_privileges_from_role( + self, + role_name: str, + securable_object: SecurableObject, + privileges: List[Privilege], + ) -> Role: + """Revoke privileges from a role on a securable object. + + Args: + role_name: The name of the role. + securable_object: The securable object. + privileges: The privileges to revoke. + + Returns: + The updated Role object. + + Raises: + NoSuchRoleException: If the role does not exist. + NoSuchMetalakeException: If the metalake does not exist. + """ + privilege_dtos = [DTOConverters.to_privilege_dto(p) for p in privileges] + req = PrivilegeRevokeRequest(privilege_dtos) + req.validate() + url = self.API_PERMISSIONS_ROLE_REVOKE_PATH.format( + encode_string(self.name()), + encode_string(role_name), + encode_string(securable_object.type().name.lower()), + encode_string(securable_object.full_name()), + ) + response = self.rest_client.put( + url, json=req, error_handler=PERMISSION_ERROR_HANDLER + ) + resp = RoleResponse.from_json(response.body, infer_missing=True) + resp.validate() + return resp.role() diff --git a/clients/client-python/gravitino/dto/authorization/__init__.py b/clients/client-python/gravitino/dto/authorization/__init__.py index 26a4e378cf4..1701d24c3c7 100644 --- a/clients/client-python/gravitino/dto/authorization/__init__.py +++ b/clients/client-python/gravitino/dto/authorization/__init__.py @@ -16,3 +16,6 @@ # under the License. from gravitino.dto.authorization.owner_dto import OwnerDTO +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.dto.authorization.role_dto import RoleDTO +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO diff --git a/clients/client-python/gravitino/dto/authorization/privilege_dto.py b/clients/client-python/gravitino/dto/authorization/privilege_dto.py new file mode 100644 index 00000000000..df8cd3d9086 --- /dev/null +++ b/clients/client-python/gravitino/dto/authorization/privilege_dto.py @@ -0,0 +1,110 @@ +# 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 dataclasses import dataclass, field + +from dataclasses_json import config, dataclass_json + +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.metadata_object import MetadataObject + + +def _encode_privilege_name(name: Privilege.Name) -> str: + return name.name.lower() + + +def _decode_privilege_name(val: str) -> Privilege.Name: + return Privilege.Name[val.upper()] + + +def _encode_condition(condition: Privilege.Condition) -> str: + return condition.value.lower() + + +def _decode_condition(val: str) -> Privilege.Condition: + return Privilege.Condition(val.upper()) + + +@dataclass_json +@dataclass +class PrivilegeDTO(Privilege): + """Data transfer object representing a privilege.""" + + _name: Privilege.Name = field( + metadata=config( + field_name="name", + encoder=_encode_privilege_name, + decoder=_decode_privilege_name, + ) + ) + _condition: Privilege.Condition = field( + metadata=config( + field_name="condition", + encoder=_encode_condition, + decoder=_decode_condition, + ) + ) + + def name(self) -> Privilege.Name: + return self._name + + def simple_string(self) -> str: + return f"{self._condition.value} {self._name.name.lower().replace('_', ' ')}" + + def condition(self) -> Privilege.Condition: + return self._condition + + def can_bind_to(self, obj_type: MetadataObject.Type) -> bool: + return True + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Privilege): + return False + return self._name == other.name() and self._condition == other.condition() + + def __hash__(self) -> int: + return hash((self._name, self._condition)) + + @staticmethod + def builder() -> PrivilegeDTO.Builder: + return PrivilegeDTO.Builder() + + class Builder: + """Helper class to build a PrivilegeDTO object.""" + + def __init__(self) -> None: + self._name: Privilege.Name = None + self._condition: Privilege.Condition = None + + def with_name(self, name: Privilege.Name) -> PrivilegeDTO.Builder: + self._name = name + return self + + def with_condition( + self, condition: Privilege.Condition + ) -> PrivilegeDTO.Builder: + self._condition = condition + return self + + def build(self) -> PrivilegeDTO: + if self._name is None: + raise ValueError("name cannot be None") + if self._condition is None: + raise ValueError("condition cannot be None") + return PrivilegeDTO(self._name, self._condition) diff --git a/clients/client-python/gravitino/dto/authorization/role_dto.py b/clients/client-python/gravitino/dto/authorization/role_dto.py new file mode 100644 index 00000000000..877c4563b76 --- /dev/null +++ b/clients/client-python/gravitino/dto/authorization/role_dto.py @@ -0,0 +1,119 @@ +# 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 dataclasses import dataclass, field +from typing import Optional + +from dataclasses_json import config, dataclass_json + +from gravitino.api.authorization.role import Role +from gravitino.api.authorization.securable_objects import SecurableObject +from gravitino.dto.audit_dto import AuditDTO +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO + + +@dataclass_json +@dataclass +class RoleDTO(Role): + """Represents a Role Data Transfer Object (DTO).""" + + _name: str = field(metadata=config(field_name="name")) + _properties: Optional[dict[str, str]] = field( + default=None, metadata=config(field_name="properties") + ) + _securable_objects: list[SecurableObjectDTO] = field( + default_factory=list, metadata=config(field_name="securableObjects") + ) + _audit: Optional[AuditDTO] = field( + default=None, metadata=config(field_name="audit") + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RoleDTO): + return False + return ( + self._name == other._name + and self._properties == other._properties + and self._audit == other._audit + ) + + def __hash__(self) -> int: + return hash( + ( + self._name, + frozenset(self._properties.items()) if self._properties else None, + self._audit, + ) + ) + + @staticmethod + def builder() -> RoleDTO.Builder: + return RoleDTO.Builder() + + def name(self) -> str: + return self._name + + def properties(self) -> Optional[dict[str, str]]: + return self._properties + + def securable_objects(self) -> list[SecurableObject]: + return list(self._securable_objects) if self._securable_objects else [] + + def audit_info(self) -> Optional[AuditDTO]: + return self._audit + + class Builder: + """Helper class to build a RoleDTO object.""" + + def __init__(self) -> None: + self._name: str = "" + self._properties: Optional[dict[str, str]] = None + self._securable_objects: list[SecurableObjectDTO] = [] + self._audit: Optional[AuditDTO] = None + + def with_name(self, name: str) -> RoleDTO.Builder: + self._name = name + return self + + def with_properties(self, properties: dict[str, str]) -> RoleDTO.Builder: + if properties is not None: + self._properties = properties + return self + + def with_securable_objects( + self, securable_objects: list[SecurableObjectDTO] + ) -> RoleDTO.Builder: + self._securable_objects = securable_objects + return self + + def with_audit(self, audit: AuditDTO) -> RoleDTO.Builder: + self._audit = audit + return self + + def build(self) -> RoleDTO: + if not self._name: + raise ValueError("name cannot be null or empty") + if self._audit is None: + raise ValueError("audit cannot be null") + return RoleDTO( + self._name, + self._properties, + self._securable_objects, + self._audit, + ) diff --git a/clients/client-python/gravitino/dto/authorization/securable_object_dto.py b/clients/client-python/gravitino/dto/authorization/securable_object_dto.py new file mode 100644 index 00000000000..87c05ec9f5e --- /dev/null +++ b/clients/client-python/gravitino/dto/authorization/securable_object_dto.py @@ -0,0 +1,119 @@ +# 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 dataclasses import dataclass, field +from typing import List, Optional + +from dataclasses_json import config, dataclass_json + +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.authorization.securable_objects import SecurableObject +from gravitino.api.metadata_object import MetadataObject +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO + + +def _encode_metadata_type(type_: MetadataObject.Type) -> str: + return type_.value + + +def _decode_metadata_type(val: str) -> MetadataObject.Type: + return MetadataObject.Type(val) + + +@dataclass_json +@dataclass +class SecurableObjectDTO(SecurableObject): + """Data transfer object representing a securable object.""" + + _full_name: str = field(metadata=config(field_name="fullName")) + _type: MetadataObject.Type = field( + metadata=config( + field_name="type", + encoder=_encode_metadata_type, + decoder=_decode_metadata_type, + ) + ) + _privileges: List[PrivilegeDTO] = field( + default_factory=list, metadata=config(field_name="privileges") + ) + + def __post_init__(self): + if self._full_name and "." in self._full_name: + index = self._full_name.rfind(".") + self._parent = self._full_name[:index] + self._name = self._full_name[index + 1 :] + else: + self._parent = None + self._name = self._full_name if self._full_name else "" + + def parent(self) -> Optional[str]: + return self._parent + + def name(self) -> str: + return self._name + + def full_name(self) -> str: + return self._full_name + + def type(self) -> MetadataObject.Type: + return self._type + + def privileges(self) -> List[Privilege]: + return list(self._privileges) if self._privileges else [] + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SecurableObject): + return False + return self._full_name == other.full_name() and self._type == other.type() + + def __hash__(self) -> int: + return hash((self._full_name, self._type)) + + @staticmethod + def builder() -> SecurableObjectDTO.Builder: + return SecurableObjectDTO.Builder() + + class Builder: + """Helper class to build a SecurableObjectDTO object.""" + + def __init__(self) -> None: + self._full_name: str = None + self._type: MetadataObject.Type = None + self._privileges: List[PrivilegeDTO] = [] + + def with_full_name(self, full_name: str) -> SecurableObjectDTO.Builder: + self._full_name = full_name + return self + + def with_type(self, type_: MetadataObject.Type) -> SecurableObjectDTO.Builder: + self._type = type_ + return self + + def with_privileges( + self, privileges: List[PrivilegeDTO] + ) -> SecurableObjectDTO.Builder: + self._privileges = privileges + return self + + def build(self) -> SecurableObjectDTO: + if not self._full_name: + raise ValueError("fullName cannot be null or empty") + if self._type is None: + raise ValueError("type cannot be None") + return SecurableObjectDTO(self._full_name, self._type, self._privileges) diff --git a/clients/client-python/gravitino/dto/requests/privilege_grant_request.py b/clients/client-python/gravitino/dto/requests/privilege_grant_request.py new file mode 100644 index 00000000000..d7dea8c932e --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/privilege_grant_request.py @@ -0,0 +1,46 @@ +# 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 dataclasses import dataclass, field +from typing import List + +from dataclasses_json import config, dataclass_json + +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class PrivilegeGrantRequest(RESTRequest): + """Represents a request to grant privileges.""" + + _privileges: List[PrivilegeDTO] = field( + default_factory=list, metadata=config(field_name="privileges") + ) + + def __init__(self, privileges: List[PrivilegeDTO]): + self._privileges = privileges + + def validate(self) -> None: + Precondition.check_argument( + self._privileges is not None and len(self._privileges) > 0, + "privileges cannot be null or empty", + ) diff --git a/clients/client-python/gravitino/dto/requests/privilege_revoke_request.py b/clients/client-python/gravitino/dto/requests/privilege_revoke_request.py new file mode 100644 index 00000000000..a835972b188 --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/privilege_revoke_request.py @@ -0,0 +1,46 @@ +# 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 dataclasses import dataclass, field +from typing import List + +from dataclasses_json import config, dataclass_json + +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class PrivilegeRevokeRequest(RESTRequest): + """Represents a request to revoke privileges.""" + + _privileges: List[PrivilegeDTO] = field( + default_factory=list, metadata=config(field_name="privileges") + ) + + def __init__(self, privileges: List[PrivilegeDTO]): + self._privileges = privileges + + def validate(self) -> None: + Precondition.check_argument( + self._privileges is not None and len(self._privileges) > 0, + "privileges cannot be null or empty", + ) diff --git a/clients/client-python/gravitino/dto/requests/role_create_request.py b/clients/client-python/gravitino/dto/requests/role_create_request.py new file mode 100644 index 00000000000..6ca55cd7897 --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/role_create_request.py @@ -0,0 +1,60 @@ +# 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 dataclasses import dataclass, field +from typing import Dict, List, Optional + +from dataclasses_json import config, dataclass_json + +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class RoleCreateRequest(RESTRequest): + """Represents a request to create a role.""" + + _name: str = field(metadata=config(field_name="name")) + _properties: Optional[Dict[str, str]] = field( + default=None, metadata=config(field_name="properties") + ) + _securable_objects: List[SecurableObjectDTO] = field( + default_factory=list, metadata=config(field_name="securableObjects") + ) + + def __init__( + self, + name: str, + properties: Optional[Dict[str, str]] = None, + securable_objects: Optional[List[SecurableObjectDTO]] = None, + ): + self._name = name + self._properties = properties + self._securable_objects = securable_objects or [] + + def validate(self) -> None: + Precondition.check_string_not_empty( + self._name, "name is required and cannot be empty" + ) + Precondition.check_argument( + self._securable_objects is not None, + "securableObjects cannot be null", + ) diff --git a/clients/client-python/gravitino/dto/requests/role_grant_request.py b/clients/client-python/gravitino/dto/requests/role_grant_request.py new file mode 100644 index 00000000000..38f3fe58c43 --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/role_grant_request.py @@ -0,0 +1,45 @@ +# 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 dataclasses import dataclass, field +from typing import List + +from dataclasses_json import config, dataclass_json + +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class RoleGrantRequest(RESTRequest): + """Represents a request to grant roles.""" + + _role_names: List[str] = field( + default_factory=list, metadata=config(field_name="roleNames") + ) + + def __init__(self, role_names: List[str]): + self._role_names = role_names + + def validate(self) -> None: + Precondition.check_argument( + self._role_names is not None and len(self._role_names) > 0, + "roleNames cannot be null or empty", + ) diff --git a/clients/client-python/gravitino/dto/requests/role_revoke_request.py b/clients/client-python/gravitino/dto/requests/role_revoke_request.py new file mode 100644 index 00000000000..56cb2562bfc --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/role_revoke_request.py @@ -0,0 +1,45 @@ +# 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 dataclasses import dataclass, field +from typing import List + +from dataclasses_json import config, dataclass_json + +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class RoleRevokeRequest(RESTRequest): + """Represents a request to revoke roles.""" + + _role_names: List[str] = field( + default_factory=list, metadata=config(field_name="roleNames") + ) + + def __init__(self, role_names: List[str]): + self._role_names = role_names + + def validate(self) -> None: + Precondition.check_argument( + self._role_names is not None and len(self._role_names) > 0, + "roleNames cannot be null or empty", + ) diff --git a/clients/client-python/gravitino/dto/responses/role_response.py b/clients/client-python/gravitino/dto/responses/role_response.py new file mode 100644 index 00000000000..945732e05f9 --- /dev/null +++ b/clients/client-python/gravitino/dto/responses/role_response.py @@ -0,0 +1,59 @@ +# 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 dataclasses import dataclass, field +from typing import Optional + +from dataclasses_json import config, dataclass_json + +from gravitino.dto.authorization.role_dto import RoleDTO +from gravitino.dto.responses.base_response import BaseResponse +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class RoleResponse(BaseResponse): + """Represents a response for a role.""" + + _role: Optional[RoleDTO] = field(default=None, metadata=config(field_name="role")) + + def role(self) -> Optional[RoleDTO]: + return self._role + + def validate(self) -> None: + Precondition.check_argument( + self._role is not None, "Role response should have a role" + ) + + +@dataclass_json +@dataclass +class RoleNamesListResponse(BaseResponse): + """Represents a response for a list of role names.""" + + _names: list[str] = field(default_factory=list, metadata=config(field_name="names")) + + def names(self) -> list[str]: + return self._names + + def validate(self) -> None: + Precondition.check_argument( + self._names is not None, "Role names list response should have names" + ) diff --git a/clients/client-python/gravitino/exceptions/handlers/permission_error_handler.py b/clients/client-python/gravitino/exceptions/handlers/permission_error_handler.py new file mode 100644 index 00000000000..b71617444a4 --- /dev/null +++ b/clients/client-python/gravitino/exceptions/handlers/permission_error_handler.py @@ -0,0 +1,77 @@ +# 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.constants.error import ErrorConstants +from gravitino.dto.responses.error_response import ErrorResponse +from gravitino.exceptions.base import ( + IllegalArgumentException, + IllegalMetadataObjectException, + IllegalPrivilegeException, + MetalakeNotInUseException, + NoSuchGroupException, + NoSuchMetadataObjectException, + NoSuchMetalakeException, + NoSuchRoleException, + NoSuchUserException, + NotFoundException, + UnsupportedOperationException, +) +from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler + + +class PermissionErrorHandler(RestErrorHandler): + """Error handler for permission operations (grant/revoke).""" + + def handle(self, error_response: ErrorResponse): + error_message = error_response.format_error_message() + code = error_response.code() + exception_type = error_response.type() + + if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE: + if exception_type == IllegalPrivilegeException.__name__: + raise IllegalPrivilegeException(error_message) + if exception_type == IllegalMetadataObjectException.__name__: + raise IllegalMetadataObjectException(error_message) + raise IllegalArgumentException(error_message) + + if code == ErrorConstants.NOT_FOUND_CODE: + if exception_type == NoSuchMetalakeException.__name__: + raise NoSuchMetalakeException(error_message) + if exception_type == NoSuchUserException.__name__: + raise NoSuchUserException(error_message) + if exception_type == NoSuchGroupException.__name__: + raise NoSuchGroupException(error_message) + if exception_type == NoSuchRoleException.__name__: + raise NoSuchRoleException(error_message) + if exception_type == NoSuchMetadataObjectException.__name__: + raise NoSuchMetadataObjectException(error_message) + raise NotFoundException(error_message) + + if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE: + raise UnsupportedOperationException(error_message) + + if code == ErrorConstants.NOT_IN_USE_CODE: + raise MetalakeNotInUseException(error_message) + + if code == ErrorConstants.INTERNAL_ERROR_CODE: + raise RuntimeError(error_message) + + super().handle(error_response) + + +PERMISSION_ERROR_HANDLER = PermissionErrorHandler() diff --git a/clients/client-python/tests/integration/test_role_management.py b/clients/client-python/tests/integration/test_role_management.py new file mode 100644 index 00000000000..9fe215e55f0 --- /dev/null +++ b/clients/client-python/tests/integration/test_role_management.py @@ -0,0 +1,170 @@ +# 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 logging +import os +import uuid + +from gravitino import GravitinoAdminClient, GravitinoClient +from gravitino.api.authorization.privileges import Privileges +from gravitino.api.authorization.securable_objects import SecurableObjects +from gravitino.exceptions.base import ( + NoSuchRoleException, + RoleAlreadyExistsException, +) +from tests.integration.integration_test_env import IntegrationTestEnv + +logger = logging.getLogger(__name__) + + +class TestRoleManagement(IntegrationTestEnv): + _metalake_name: str = f"test_role_metalake_{uuid.uuid4().hex[:8]}" + _gravitino_admin_client: GravitinoAdminClient = None + _gravitino_client: GravitinoClient = None + + @classmethod + def setUpClass(cls): + cls._get_gravitino_home() + conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf") + auth_confs = { + "gravitino.authorization.enable": "true", + "gravitino.authorization.serviceAdmins": "anonymous", + } + cls._reset_conf(auth_confs, conf_path) + cls._append_conf(auth_confs, conf_path) + if ( + os.environ.get("START_EXTERNAL_GRAVITINO") is not None + and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true" + ): + cls.restart_server() + else: + super().setUpClass() + cls._gravitino_admin_client = GravitinoAdminClient(uri="http://localhost:8090") + + @classmethod + def tearDownClass(cls): + conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf") + reset_confs = { + "gravitino.authorization.enable": "false", + "gravitino.authorization.serviceAdmins": "anonymous", + } + cls._reset_conf(reset_confs, conf_path) + cls._append_conf(reset_confs, conf_path) + if ( + os.environ.get("START_EXTERNAL_GRAVITINO") is not None + and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true" + ): + cls.restart_server() + else: + super().tearDownClass() + + def setUp(self): + self._gravitino_admin_client.create_metalake( + self._metalake_name, comment="test role management", properties={} + ) + self._gravitino_client = GravitinoClient( + uri="http://localhost:8090", metalake_name=self._metalake_name + ) + + def tearDown(self): + try: + self._gravitino_admin_client.drop_metalake( + self._metalake_name, force=True + ) + except Exception: # pylint: disable=broad-except + logger.warning("Failed to drop metalake %s", self._metalake_name) + + def test_create_and_get_role(self): + privileges = [Privileges.allow("USE_CATALOG")] + securable_objects = [ + SecurableObjects.of_metalake(self._metalake_name, privileges) + ] + created = self._gravitino_client.create_role( + "test_role", + properties={"k": "v"}, + securable_objects=securable_objects, + ) + self.assertEqual("test_role", created.name()) + + retrieved = self._gravitino_client.get_role("test_role") + self.assertEqual("test_role", retrieved.name()) + + def test_create_duplicate_role(self): + self._gravitino_client.create_role("dup_role") + with self.assertRaises(RoleAlreadyExistsException): + self._gravitino_client.create_role("dup_role") + + def test_delete_role(self): + self._gravitino_client.create_role("del_role") + self.assertTrue(self._gravitino_client.delete_role("del_role")) + + with self.assertRaises(NoSuchRoleException): + self._gravitino_client.get_role("del_role") + + def test_list_role_names(self): + self._gravitino_client.create_role("role_a") + self._gravitino_client.create_role("role_b") + + names = self._gravitino_client.list_role_names() + self.assertIn("role_a", names) + self.assertIn("role_b", names) + + def test_grant_revoke_roles_to_user(self): + self._gravitino_client.create_role("user_role") + self._gravitino_client.add_user("alice") + + granted = self._gravitino_client.grant_roles_to_user( + ["user_role"], "alice" + ) + self.assertIn("user_role", granted.roles()) + + revoked = self._gravitino_client.revoke_roles_from_user( + ["user_role"], "alice" + ) + self.assertNotIn("user_role", revoked.roles()) + + def test_grant_revoke_roles_to_group(self): + self._gravitino_client.create_role("group_role") + self._gravitino_client.add_group("engineers") + + granted = self._gravitino_client.grant_roles_to_group( + ["group_role"], "engineers" + ) + self.assertIn("group_role", granted.roles()) + + revoked = self._gravitino_client.revoke_roles_from_group( + ["group_role"], "engineers" + ) + self.assertNotIn("group_role", revoked.roles()) + + def test_grant_revoke_privileges_to_role(self): + self._gravitino_client.create_role("priv_role") + + privileges = [Privileges.allow("USE_CATALOG")] + securable_obj = SecurableObjects.of_metalake( + self._metalake_name, privileges + ) + + granted = self._gravitino_client.grant_privileges_to_role( + "priv_role", securable_obj, privileges + ) + self.assertEqual("priv_role", granted.name()) + + revoked = self._gravitino_client.revoke_privileges_from_role( + "priv_role", securable_obj, privileges + ) + self.assertEqual("priv_role", revoked.name()) diff --git a/clients/client-python/tests/unittests/client/test_metalake_role_operations.py b/clients/client-python/tests/unittests/client/test_metalake_role_operations.py new file mode 100644 index 00000000000..50a2b342702 --- /dev/null +++ b/clients/client-python/tests/unittests/client/test_metalake_role_operations.py @@ -0,0 +1,596 @@ +# 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.authorization.privileges import Privilege +from gravitino.api.authorization.securable_objects import SecurableObjects +from gravitino.api.metadata_object import MetadataObject +from gravitino.client.gravitino_client import GravitinoClient +from gravitino.dto.audit_dto import AuditDTO +from gravitino.dto.authorization.group_dto import GroupDTO +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.dto.authorization.role_dto import RoleDTO +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO +from gravitino.dto.authorization.user_dto import UserDTO +from gravitino.dto.requests.role_create_request import RoleCreateRequest +from gravitino.dto.requests.role_grant_request import RoleGrantRequest +from gravitino.dto.requests.role_revoke_request import RoleRevokeRequest +from gravitino.dto.requests.privilege_grant_request import PrivilegeGrantRequest +from gravitino.dto.responses.drop_response import DropResponse +from gravitino.dto.responses.group_response import GroupResponse +from gravitino.dto.responses.role_response import ( + RoleNamesListResponse, + RoleResponse, +) +from gravitino.dto.responses.user_response import UserResponse +from gravitino.exceptions.base import ( + IllegalArgumentException, + NoSuchRoleException, + RoleAlreadyExistsException, +) +from gravitino.exceptions.handlers.permission_error_handler import ( + PERMISSION_ERROR_HANDLER, +) +from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER +from tests.unittests import mock_base + + +def _audit() -> AuditDTO: + return AuditDTO(_creator="admin", _create_time="2024-01-01T00:00:00Z") + + +def _build_role_dto( + name: str = "admin_role", + props: dict | None = None, + sec_objs: list | None = None, +) -> RoleDTO: + return ( + RoleDTO.builder() + .with_name(name) + .with_properties(props) + .with_securable_objects(sec_objs or []) + .with_audit(_audit()) + .build() + ) + + +def _build_user_dto(name: str = "alice", roles: list | None = None) -> UserDTO: + return ( + UserDTO.builder() + .with_name(name) + .with_roles(roles or []) + .with_audit(_audit()) + .build() + ) + + +def _build_group_dto(name: str = "engineers", roles: list | None = None) -> GroupDTO: + return ( + GroupDTO.builder() + .with_name(name) + .with_roles(roles if roles is not None else []) + .with_audit(_audit()) + .build() + ) + + +class TestMetalakeRoleOperations(unittest.TestCase): + METALAKE_ROLES_PATH = "api/metalakes/metalake_demo/roles" + METALAKE_ROLE_PATH = "api/metalakes/metalake_demo/roles/admin_role" + PERMISSIONS_USER_GRANT_PATH = ( + "api/metalakes/metalake_demo/permissions/users/alice/grant" + ) + PERMISSIONS_USER_REVOKE_PATH = ( + "api/metalakes/metalake_demo/permissions/users/alice/revoke" + ) + PERMISSIONS_GROUP_GRANT_PATH = ( + "api/metalakes/metalake_demo/permissions/groups/engineers/grant" + ) + PERMISSIONS_GROUP_REVOKE_PATH = ( + "api/metalakes/metalake_demo/permissions/groups/engineers/revoke" + ) + PERMISSIONS_ROLE_GRANT_PATH = "api/metalakes/metalake_demo/permissions/roles/admin_role/catalog/my_catalog/grant" + PERMISSIONS_ROLE_REVOKE_PATH = "api/metalakes/metalake_demo/permissions/roles/admin_role/catalog/my_catalog/revoke" + + def test_create_role(self): + metalake = mock_base.mock_load_metalake() + sec_objs = [ + SecurableObjectDTO( + "my_catalog", + MetadataObject.Type.CATALOG, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + ] + role = _build_role_dto(sec_objs=sec_objs) + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.post", + return_value=mock_resp, + ) as mock_post: + result = metalake.create_role( + "admin_role", + properties=None, + securable_objects=[ + SecurableObjectDTO( + "my_catalog", + MetadataObject.Type.CATALOG, + [ + PrivilegeDTO( + Privilege.Name.USE_CATALOG, + Privilege.Condition.ALLOW, + ) + ], + ) + ], + ) + + self.assertEqual("admin_role", result.name()) + mock_post.assert_called_once() + call_args = mock_post.call_args + self.assertEqual(self.METALAKE_ROLES_PATH, call_args.args[0]) + self.assertIsInstance(call_args.kwargs["json"], RoleCreateRequest) + self.assertIs(ROLE_ERROR_HANDLER, call_args.kwargs["error_handler"]) + + def test_create_role_empty_name_rejected(self): + metalake = mock_base.mock_load_metalake() + with self.assertRaises(IllegalArgumentException): + metalake.create_role("") + + def test_create_role_already_exists(self): + metalake = mock_base.mock_load_metalake() + with patch( + "gravitino.utils.http_client.HTTPClient.post", + side_effect=RoleAlreadyExistsException("role already exists"), + ): + with self.assertRaises(RoleAlreadyExistsException): + metalake.create_role("admin_role") + + def test_get_role(self): + metalake = mock_base.mock_load_metalake() + role = _build_role_dto(props={"k": "v"}) + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.get", + return_value=mock_resp, + ) as mock_get: + result = metalake.get_role("admin_role") + + self.assertEqual("admin_role", result.name()) + self.assertEqual({"k": "v"}, result.properties()) + mock_get.assert_called_once() + self.assertEqual(self.METALAKE_ROLE_PATH, mock_get.call_args.args[0]) + self.assertIs( + ROLE_ERROR_HANDLER, mock_get.call_args.kwargs["error_handler"] + ) + + def test_get_role_not_found(self): + metalake = mock_base.mock_load_metalake() + with patch( + "gravitino.utils.http_client.HTTPClient.get", + side_effect=NoSuchRoleException("no such role"), + ): + with self.assertRaises(NoSuchRoleException): + metalake.get_role("nonexistent") + + def test_delete_role(self): + metalake = mock_base.mock_load_metalake() + mock_resp = mock_base.mock_http_response(DropResponse(0, True).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.delete", + return_value=mock_resp, + ) as mock_delete: + self.assertTrue(metalake.delete_role("admin_role")) + + mock_delete.assert_called_once() + self.assertEqual(self.METALAKE_ROLE_PATH, mock_delete.call_args.args[0]) + self.assertIs( + ROLE_ERROR_HANDLER, mock_delete.call_args.kwargs["error_handler"] + ) + + def test_delete_role_returns_false(self): + metalake = mock_base.mock_load_metalake() + mock_resp = mock_base.mock_http_response(DropResponse(0, False).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.delete", + return_value=mock_resp, + ): + self.assertFalse(metalake.delete_role("admin_role")) + + def test_list_role_names(self): + metalake = mock_base.mock_load_metalake() + mock_resp = mock_base.mock_http_response( + RoleNamesListResponse(0, ["role1", "role2"]).to_json() + ) + + with patch( + "gravitino.utils.http_client.HTTPClient.get", + return_value=mock_resp, + ) as mock_get: + names = metalake.list_role_names() + + self.assertEqual(["role1", "role2"], names) + mock_get.assert_called_once() + self.assertEqual(self.METALAKE_ROLES_PATH, mock_get.call_args.args[0]) + self.assertIs( + ROLE_ERROR_HANDLER, mock_get.call_args.kwargs["error_handler"] + ) + + def test_grant_roles_to_user(self): + metalake = mock_base.mock_load_metalake() + user = _build_user_dto(roles=["admin_role"]) + mock_resp = mock_base.mock_http_response(UserResponse(0, user).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + result = metalake.grant_roles_to_user(["admin_role"], "alice") + + self.assertEqual("alice", result.name()) + self.assertEqual(["admin_role"], result.roles()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_USER_GRANT_PATH, mock_put.call_args.args[0] + ) + self.assertIsInstance(mock_put.call_args.kwargs["json"], RoleGrantRequest) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + def test_revoke_roles_from_user(self): + metalake = mock_base.mock_load_metalake() + user = _build_user_dto(roles=[]) + mock_resp = mock_base.mock_http_response(UserResponse(0, user).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + result = metalake.revoke_roles_from_user(["admin_role"], "alice") + + self.assertEqual([], result.roles()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_USER_REVOKE_PATH, mock_put.call_args.args[0] + ) + self.assertIsInstance(mock_put.call_args.kwargs["json"], RoleRevokeRequest) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + def test_grant_roles_to_group(self): + metalake = mock_base.mock_load_metalake() + group = _build_group_dto(roles=["admin_role"]) + mock_resp = mock_base.mock_http_response(GroupResponse(0, group).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + result = metalake.grant_roles_to_group(["admin_role"], "engineers") + + self.assertEqual("engineers", result.name()) + self.assertEqual(["admin_role"], result.roles()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_GROUP_GRANT_PATH, mock_put.call_args.args[0] + ) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + def test_revoke_roles_from_group(self): + metalake = mock_base.mock_load_metalake() + group = _build_group_dto(roles=[]) + mock_resp = mock_base.mock_http_response(GroupResponse(0, group).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + result = metalake.revoke_roles_from_group(["admin_role"], "engineers") + + self.assertEqual([], result.roles()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_GROUP_REVOKE_PATH, mock_put.call_args.args[0] + ) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + def test_grant_privileges_to_role(self): + metalake = mock_base.mock_load_metalake() + sec_obj = SecurableObjectDTO( + "my_catalog", + MetadataObject.Type.CATALOG, + [ + PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW), + PrivilegeDTO(Privilege.Name.CREATE_SCHEMA, Privilege.Condition.ALLOW), + ], + ) + role = _build_role_dto(sec_objs=[sec_obj]) + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + securable_obj = SecurableObjects.of_catalog( + "my_catalog", + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + result = metalake.grant_privileges_to_role( + "admin_role", + securable_obj, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + + self.assertEqual("admin_role", result.name()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_ROLE_GRANT_PATH, mock_put.call_args.args[0] + ) + self.assertIsInstance( + mock_put.call_args.kwargs["json"], PrivilegeGrantRequest + ) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + def test_revoke_privileges_from_role(self): + metalake = mock_base.mock_load_metalake() + role = _build_role_dto(sec_objs=[]) + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ) as mock_put: + securable_obj = SecurableObjects.of_catalog( + "my_catalog", + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + result = metalake.revoke_privileges_from_role( + "admin_role", + securable_obj, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + + self.assertEqual("admin_role", result.name()) + mock_put.assert_called_once() + self.assertEqual( + self.PERMISSIONS_ROLE_REVOKE_PATH, mock_put.call_args.args[0] + ) + self.assertIs( + PERMISSION_ERROR_HANDLER, + mock_put.call_args.kwargs["error_handler"], + ) + + +class TestGravitinoClientRoleDelegates(unittest.TestCase): + """Verify that GravitinoClient correctly delegates Role operations.""" + + def _make_client(self): + client = GravitinoClient.__new__(GravitinoClient) + return client + + def test_client_create_role(self): + client = self._make_client() + role = _build_role_dto() + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.post", + return_value=mock_resp, + ), + ): + result = client.create_role("admin_role") + self.assertEqual("admin_role", result.name()) + + def test_client_get_role(self): + client = self._make_client() + role = _build_role_dto(props={"k": "v"}) + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.get", + return_value=mock_resp, + ), + ): + result = client.get_role("admin_role") + self.assertEqual({"k": "v"}, result.properties()) + + def test_client_delete_role(self): + client = self._make_client() + mock_resp = mock_base.mock_http_response(DropResponse(0, True).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.delete", + return_value=mock_resp, + ), + ): + self.assertTrue(client.delete_role("admin_role")) + + def test_client_list_role_names(self): + client = self._make_client() + mock_resp = mock_base.mock_http_response( + RoleNamesListResponse(0, ["role1", "role2"]).to_json() + ) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.get", + return_value=mock_resp, + ), + ): + result = client.list_role_names() + self.assertEqual(["role1", "role2"], result) + + def test_client_grant_roles_to_user(self): + client = self._make_client() + user = _build_user_dto(roles=["admin_role"]) + mock_resp = mock_base.mock_http_response(UserResponse(0, user).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + result = client.grant_roles_to_user(["admin_role"], "alice") + self.assertEqual(["admin_role"], result.roles()) + + def test_client_revoke_roles_from_user(self): + client = self._make_client() + user = _build_user_dto(roles=[]) + mock_resp = mock_base.mock_http_response(UserResponse(0, user).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + result = client.revoke_roles_from_user(["admin_role"], "alice") + self.assertEqual([], result.roles()) + + def test_client_grant_roles_to_group(self): + client = self._make_client() + group = _build_group_dto(roles=["admin_role"]) + mock_resp = mock_base.mock_http_response(GroupResponse(0, group).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + result = client.grant_roles_to_group(["admin_role"], "engineers") + self.assertEqual(["admin_role"], result.roles()) + + def test_client_revoke_roles_from_group(self): + client = self._make_client() + group = _build_group_dto(roles=[]) + mock_resp = mock_base.mock_http_response(GroupResponse(0, group).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + result = client.revoke_roles_from_group(["admin_role"], "engineers") + self.assertEqual([], result.roles()) + + def test_client_grant_privileges_to_role(self): + client = self._make_client() + role = _build_role_dto() + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + securable_obj = SecurableObjects.of_catalog( + "my_catalog", + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + result = client.grant_privileges_to_role( + "admin_role", + securable_obj, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + self.assertEqual("admin_role", result.name()) + + def test_client_revoke_privileges_from_role(self): + client = self._make_client() + role = _build_role_dto() + mock_resp = mock_base.mock_http_response(RoleResponse(0, role).to_json()) + with ( + patch.object( + GravitinoClient, + "get_metalake", + return_value=mock_base.mock_load_metalake(), + ), + patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ), + ): + securable_obj = SecurableObjects.of_catalog( + "my_catalog", + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + result = client.revoke_privileges_from_role( + "admin_role", + securable_obj, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + self.assertEqual("admin_role", result.name()) diff --git a/clients/client-python/tests/unittests/dto/responses/test_role_response.py b/clients/client-python/tests/unittests/dto/responses/test_role_response.py new file mode 100644 index 00000000000..4dfd38b325f --- /dev/null +++ b/clients/client-python/tests/unittests/dto/responses/test_role_response.py @@ -0,0 +1,66 @@ +# 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.dto.audit_dto import AuditDTO +from gravitino.dto.authorization.role_dto import RoleDTO +from gravitino.dto.responses.role_response import ( + RoleNamesListResponse, + RoleResponse, +) + + +class TestRoleResponse(unittest.TestCase): + def test_role_response(self): + role = ( + RoleDTO.builder() + .with_name("test_role") + .with_audit(AuditDTO(_creator="admin", _create_time="2024-01-01T00:00:00Z")) + .build() + ) + resp = RoleResponse(0, role) + resp.validate() + self.assertEqual(role, resp.role()) + + def test_role_names_list_response(self): + resp = RoleNamesListResponse(0, ["role1", "role2"]) + resp.validate() + self.assertEqual(["role1", "role2"], resp.names()) + + def test_validate_no_role(self): + resp = RoleResponse(0, None) + with self.assertRaises(ValueError): + resp.validate() + + def test_validate_no_names(self): + resp = RoleNamesListResponse(0, None) + with self.assertRaises(ValueError): + resp.validate() + + def test_json_roundtrip(self): + role = ( + RoleDTO.builder() + .with_name("json_role") + .with_audit(AuditDTO(_creator="admin", _create_time="2024-01-01T00:00:00Z")) + .build() + ) + resp = RoleResponse(0, role) + json_str = resp.to_json() + restored = RoleResponse.from_json(json_str, infer_missing=True) + restored.validate() + self.assertEqual("json_role", restored.role().name()) diff --git a/clients/client-python/tests/unittests/dto/test_privilege_dto.py b/clients/client-python/tests/unittests/dto/test_privilege_dto.py new file mode 100644 index 00000000000..45a18494eef --- /dev/null +++ b/clients/client-python/tests/unittests/dto/test_privilege_dto.py @@ -0,0 +1,79 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +import unittest + +from gravitino.api.authorization.privileges import Privilege +from gravitino.api.authorization.privileges import Privileges +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO + + +class TestPrivilegeDTO(unittest.TestCase): + def test_create_privilege_dto(self): + dto = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + self.assertEqual(Privilege.Name.CREATE_FILESET, dto.name()) + self.assertEqual(Privilege.Condition.ALLOW, dto.condition()) + + def test_simple_string(self): + dto = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + self.assertEqual("ALLOW create fileset", dto.simple_string()) + + def test_can_bind_to(self): + dto = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + self.assertTrue(dto.can_bind_to(None)) + + def test_equality_and_hash(self): + dto1 = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + dto2 = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + dto3 = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.DENY) + self.assertEqual(dto1, dto2) + self.assertEqual(hash(dto1), hash(dto2)) + self.assertNotEqual(dto1, dto3) + + def test_equality_with_generic_privilege(self): + dto = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + generic = Privileges.allow("CREATE_FILESET") + self.assertEqual(dto, generic) + self.assertEqual(generic, dto) + + def test_builder_validations(self): + with self.assertRaises(ValueError): + PrivilegeDTO.builder().with_condition(Privilege.Condition.ALLOW).build() + + with self.assertRaises(ValueError): + PrivilegeDTO.builder().with_name(Privilege.Name.CREATE_FILESET).build() + + def test_json_roundtrip(self): + dto = PrivilegeDTO(Privilege.Name.CREATE_FILESET, Privilege.Condition.ALLOW) + encoded = dto.to_json() + decoded = json.loads(encoded) + self.assertEqual("create_fileset", decoded["name"]) + self.assertEqual("allow", decoded["condition"]) + + restored = PrivilegeDTO.from_json(encoded) + self.assertEqual(dto, restored) + + def test_builder(self): + dto = ( + PrivilegeDTO.builder() + .with_name(Privilege.Name.USE_CATALOG) + .with_condition(Privilege.Condition.DENY) + .build() + ) + self.assertEqual(Privilege.Name.USE_CATALOG, dto.name()) + self.assertEqual(Privilege.Condition.DENY, dto.condition()) diff --git a/clients/client-python/tests/unittests/dto/test_role_dto.py b/clients/client-python/tests/unittests/dto/test_role_dto.py new file mode 100644 index 00000000000..53b6cc5fad6 --- /dev/null +++ b/clients/client-python/tests/unittests/dto/test_role_dto.py @@ -0,0 +1,128 @@ +# 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 json as _json +import unittest + +from gravitino.api.metadata_object import MetadataObject +from gravitino.dto.audit_dto import AuditDTO +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.api.authorization.privileges import Privilege +from gravitino.dto.authorization.role_dto import RoleDTO +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO + + +class TestRoleDTO(unittest.TestCase): + def _audit(self) -> AuditDTO: + return AuditDTO(_creator="admin", _create_time="2024-01-01T00:00:00Z") + + def test_create_role_dto(self): + sec_objs = [ + SecurableObjectDTO( + "my_catalog", + MetadataObject.Type.CATALOG, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + ] + role = ( + RoleDTO.builder() + .with_name("admin_role") + .with_properties({"k": "v"}) + .with_securable_objects(sec_objs) + .with_audit(self._audit()) + .build() + ) + self.assertEqual("admin_role", role.name()) + self.assertEqual({"k": "v"}, role.properties()) + self.assertEqual(1, len(role.securable_objects())) + + def test_role_dto_without_properties_and_objects(self): + role = ( + RoleDTO.builder().with_name("empty_role").with_audit(self._audit()).build() + ) + self.assertIsNone(role.properties()) + self.assertEqual([], role.securable_objects()) + + def test_role_dto_methods(self): + role = ( + RoleDTO.builder() + .with_name("test_role") + .with_properties({"a": "b"}) + .with_audit(self._audit()) + .build() + ) + self.assertEqual("test_role", role.name()) + self.assertEqual({"a": "b"}, role.properties()) + self.assertEqual([], role.securable_objects()) + self.assertIsNotNone(role.audit_info()) + + def test_builder_empty_name_raises(self): + with self.assertRaises(ValueError): + RoleDTO.builder().with_audit(self._audit()).build() + + def test_builder_no_audit_raises(self): + with self.assertRaises(ValueError): + RoleDTO.builder().with_name("test").build() + + def test_equality_and_hash(self): + role1 = ( + RoleDTO.builder() + .with_name("role1") + .with_properties({"k": "v"}) + .with_audit(self._audit()) + .build() + ) + role2 = ( + RoleDTO.builder() + .with_name("role1") + .with_properties({"k": "v"}) + .with_audit(self._audit()) + .build() + ) + role3 = ( + RoleDTO.builder() + .with_name("role2") + .with_properties({"k": "v"}) + .with_audit(self._audit()) + .build() + ) + self.assertEqual(role1, role2) + self.assertEqual(hash(role1), hash(role2)) + self.assertNotEqual(role1, role3) + + def test_json_roundtrip(self): + sec_objs = [ + SecurableObjectDTO( + "catalog", + MetadataObject.Type.CATALOG, + [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)], + ) + ] + role = ( + RoleDTO.builder() + .with_name("json_role") + .with_properties({"k": "v"}) + .with_securable_objects(sec_objs) + .with_audit(self._audit()) + .build() + ) + encoded = _json.dumps(role.to_dict()).encode("utf-8") + decoded = _json.loads(encoded) + self.assertEqual("json_role", decoded["name"]) + self.assertEqual({"k": "v"}, decoded["properties"]) + self.assertEqual(1, len(decoded["securableObjects"])) diff --git a/clients/client-python/tests/unittests/dto/test_securable_object_dto.py b/clients/client-python/tests/unittests/dto/test_securable_object_dto.py new file mode 100644 index 00000000000..f2a541058cb --- /dev/null +++ b/clients/client-python/tests/unittests/dto/test_securable_object_dto.py @@ -0,0 +1,74 @@ +# 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.metadata_object import MetadataObject +from gravitino.dto.authorization.privilege_dto import PrivilegeDTO +from gravitino.api.authorization.privileges import Privilege +from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO + + +class TestSecurableObjectDTO(unittest.TestCase): + def test_simple_name(self): + obj = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, []) + self.assertEqual("my_catalog", obj.name()) + self.assertIsNone(obj.parent()) + self.assertEqual("my_catalog", obj.full_name()) + + def test_dotted_name(self): + obj = SecurableObjectDTO("my_catalog.my_schema", MetadataObject.Type.SCHEMA, []) + self.assertEqual("my_schema", obj.name()) + self.assertEqual("my_catalog", obj.parent()) + + def test_deeply_nested_name(self): + obj = SecurableObjectDTO("catalog.schema.table", MetadataObject.Type.TABLE, []) + self.assertEqual("table", obj.name()) + self.assertEqual("catalog.schema", obj.parent()) + + def test_type(self): + obj = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, []) + self.assertEqual(MetadataObject.Type.CATALOG, obj.type()) + + def test_privileges(self): + privs = [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)] + obj = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, privs) + self.assertEqual(1, len(obj.privileges())) + self.assertIsInstance(obj.privileges()[0], PrivilegeDTO) + + def test_privileges_immutability(self): + privs = [PrivilegeDTO(Privilege.Name.USE_CATALOG, Privilege.Condition.ALLOW)] + obj = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, privs) + obj.privileges().append( + PrivilegeDTO(Privilege.Name.CREATE_TABLE, Privilege.Condition.ALLOW) + ) + self.assertEqual(1, len(obj.privileges())) + + def test_equality_and_hash(self): + obj1 = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, []) + obj2 = SecurableObjectDTO("my_catalog", MetadataObject.Type.CATALOG, []) + obj3 = SecurableObjectDTO("other_catalog", MetadataObject.Type.CATALOG, []) + self.assertEqual(obj1, obj2) + self.assertEqual(hash(obj1), hash(obj2)) + self.assertNotEqual(obj1, obj3) + + def test_builder_validations(self): + with self.assertRaises(ValueError): + SecurableObjectDTO.builder().with_type(MetadataObject.Type.CATALOG).build() + + with self.assertRaises(ValueError): + SecurableObjectDTO.builder().with_full_name("test").build() diff --git a/clients/client-python/tests/unittests/test_error_handler.py b/clients/client-python/tests/unittests/test_error_handler.py index 10bd9cb2819..dbd36c99504 100644 --- a/clients/client-python/tests/unittests/test_error_handler.py +++ b/clients/client-python/tests/unittests/test_error_handler.py @@ -25,6 +25,8 @@ ConnectionFailedException, ForbiddenException, IllegalArgumentException, + IllegalMetadataObjectException, + IllegalPrivilegeException, InternalError, MetalakeAlreadyExistsException, MetalakeNotInUseException, @@ -32,10 +34,11 @@ NoSuchCatalogException, NoSuchCredentialException, NoSuchFilesetException, + NoSuchGroupException, + NoSuchMetadataObjectException, NoSuchMetalakeException, NoSuchPartitionException, NoSuchRoleException, - NoSuchGroupException, NoSuchSchemaException, NoSuchTableException, NoSuchUserException, @@ -44,6 +47,7 @@ NotInUseException, PartitionAlreadyExistsException, RESTException, + RoleAlreadyExistsException, SchemaAlreadyExistsException, TableAlreadyExistsException, UnsupportedOperationException, @@ -56,6 +60,10 @@ ) from gravitino.exceptions.handlers.fileset_error_handler import FILESET_ERROR_HANDLER from gravitino.exceptions.handlers.group_error_handler import GROUP_ERROR_HANDLER +from gravitino.exceptions.handlers.permission_error_handler import ( + PERMISSION_ERROR_HANDLER, +) +from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER from gravitino.exceptions.handlers.metalake_error_handler import METALAKE_ERROR_HANDLER from gravitino.exceptions.handlers.partition_error_handler import ( PARTITION_ERROR_HANDLER, @@ -535,3 +543,143 @@ def test_group_error_handler(self): GROUP_ERROR_HANDLER.handle( ErrorResponse.generate_error_response(Exception, "mock error") ) + + def test_role_error_handler(self): + with self.assertRaises(IllegalPrivilegeException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + IllegalPrivilegeException, "mock error" + ) + ) + + with self.assertRaises(IllegalMetadataObjectException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + IllegalMetadataObjectException, "mock error" + ) + ) + + with self.assertRaises(NoSuchMetalakeException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + NoSuchMetalakeException, "mock error" + ) + ) + + with self.assertRaises(NoSuchRoleException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(NoSuchRoleException, "mock error") + ) + + with self.assertRaises(NoSuchMetadataObjectException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + NoSuchMetadataObjectException, "mock error" + ) + ) + + with self.assertRaises(RoleAlreadyExistsException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + RoleAlreadyExistsException, "mock error" + ) + ) + + with self.assertRaises(UnsupportedOperationException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + UnsupportedOperationException, "mock error" + ) + ) + + with self.assertRaises(ForbiddenException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(ForbiddenException, "mock error") + ) + + with self.assertRaises(MetalakeNotInUseException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + MetalakeNotInUseException, "mock error" + ) + ) + + with self.assertRaises(RuntimeError): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(InternalError, "mock error") + ) + + with self.assertRaises(RESTException): + ROLE_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(Exception, "mock error") + ) + + def test_permission_error_handler(self): + with self.assertRaises(IllegalPrivilegeException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + IllegalPrivilegeException, "mock error" + ) + ) + + with self.assertRaises(IllegalMetadataObjectException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + IllegalMetadataObjectException, "mock error" + ) + ) + + with self.assertRaises(NoSuchMetalakeException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + NoSuchMetalakeException, "mock error" + ) + ) + + with self.assertRaises(NoSuchUserException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(NoSuchUserException, "mock error") + ) + + with self.assertRaises(NoSuchGroupException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + NoSuchGroupException, "mock error" + ) + ) + + with self.assertRaises(NoSuchRoleException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(NoSuchRoleException, "mock error") + ) + + with self.assertRaises(NoSuchMetadataObjectException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + NoSuchMetadataObjectException, "mock error" + ) + ) + + with self.assertRaises(UnsupportedOperationException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + UnsupportedOperationException, "mock error" + ) + ) + + with self.assertRaises(MetalakeNotInUseException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response( + MetalakeNotInUseException, "mock error" + ) + ) + + with self.assertRaises(RuntimeError): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(InternalError, "mock error") + ) + + with self.assertRaises(RESTException): + PERMISSION_ERROR_HANDLER.handle( + ErrorResponse.generate_error_response(Exception, "mock error") + )