Skip to content

Commit 7990fb1

Browse files
authored
[#11095] feat(client-python): add Role CRUD and authorization data structures (#11210)
### What changes were proposed in this pull request? Add Role CRUD operations and authorization data structures to the Python client SDK: - **DTOs**: `PrivilegeDTO`, `SecurableObjectDTO`, `RoleDTO` with JSON serialization support - **Role CRUD**: `create_role`, `get_role`, `delete_role`, `list_role_names` on `GravitinoMetalake` and `GravitinoClient` - **DTOConverters**: `to/from_privilege_dto` and `to/from_securable_object_dto` conversion methods - **Fix**: `GenericPrivilege.__eq__` now checks `isinstance(value, Privilege)` for `PrivilegeDTO` compatibility - **Tests**: Unit tests (DTO, response, client delegate, error handler) and integration tests against a live server ### Why are the changes needed? This is the Role authorization piece of issue #10782. User (#11058) and Group (#11094) management are already merged. Role CRUD is a prerequisite for Grant/Revoke operations, which will follow in a separate PR. Fix: #11095 ### Does this PR introduce _any_ user-facing change? Yes — new public APIs on `GravitinoClient` and `GravitinoMetalake`: - `create_role(role_name, properties=None, securable_objects=None)` - `get_role(role_name)` - `delete_role(role_name) -> bool` - `list_role_names() -> list[str]` ### How was this patch tested? - Unit tests: all passed - Integration tests: 4 passed against a live Gravitino server with authorization enabled - Linting: `ruff check` clean, `pylint` 10/10 --------- Co-authored-by: Sun Yuhan <sunyuhan1998@users.noreply.github.com>
1 parent 5befcef commit 7990fb1

17 files changed

Lines changed: 1486 additions & 2 deletions

clients/client-python/gravitino/api/authorization/privileges.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,9 @@ def __hash__(self) -> int:
265265
return hash((self._condition, self._name))
266266

267267
def __eq__(self, value: object) -> bool:
268-
if not isinstance(value, GenericPrivilege):
268+
if not isinstance(value, Privilege):
269269
return False
270-
return self._condition == value._condition and self._name == value._name
270+
return self._condition == value.condition() and self._name == value.name()
271271

272272

273273
class CreateCatalog(GenericPrivilege):

clients/client-python/gravitino/client/dto_converters.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from __future__ import annotations
1919

20+
from gravitino.api.authorization.privileges import Privilege
21+
from gravitino.api.authorization.securable_objects import SecurableObject
2022
from gravitino.api.catalog import Catalog
2123
from gravitino.api.catalog_change import CatalogChange
2224
from gravitino.api.job.job_template import JobTemplate, JobType
@@ -36,6 +38,8 @@
3638
from gravitino.client.fileset_catalog import FilesetCatalog
3739
from gravitino.client.generic_model_catalog import GenericModelCatalog
3840
from gravitino.client.relational_catalog import RelationalCatalog
41+
from gravitino.dto.authorization.privilege_dto import PrivilegeDTO
42+
from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO
3943
from gravitino.dto.catalog_dto import CatalogDTO
4044
from gravitino.dto.job.job_template_dto import JobTemplateDTO
4145
from gravitino.dto.job.shell_job_template_dto import ShellJobTemplateDTO
@@ -325,3 +329,12 @@ def to_tag_update_request(
325329
return TagUpdateRequest.RemoveTagPropertyRequest(change.removed_property)
326330

327331
raise IllegalArgumentException(f"Unknown change type: {type(change)}")
332+
333+
@staticmethod
334+
def to_privilege_dto(privilege: Privilege) -> PrivilegeDTO:
335+
return PrivilegeDTO(privilege.name(), privilege.condition())
336+
337+
@staticmethod
338+
def to_securable_object_dto(obj: SecurableObject) -> SecurableObjectDTO:
339+
privilege_dtos = [DTOConverters.to_privilege_dto(p) for p in obj.privileges()]
340+
return SecurableObjectDTO(obj.full_name(), obj.type(), privilege_dtos)

clients/client-python/gravitino/client/gravitino_client.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
from gravitino.api.authorization.group import Group
2323
from gravitino.api.authorization.owner import Owner
24+
from gravitino.api.authorization.role import Role
25+
from gravitino.api.authorization.securable_objects import SecurableObject
2426
from gravitino.api.authorization.user import User
2527
from gravitino.api.catalog import Catalog
2628
from gravitino.api.catalog_change import CatalogChange
@@ -507,3 +509,67 @@ def list_group_names(self) -> list[str]:
507509
NoSuchMetalakeException: If the metalake does not exist.
508510
"""
509511
return self.get_metalake().list_group_names()
512+
513+
# Role operations
514+
515+
def create_role(
516+
self,
517+
role_name: str,
518+
properties: Optional[Dict[str, str]] = None,
519+
securable_objects: Optional[List[SecurableObject]] = None,
520+
) -> Role:
521+
"""Create a new role under the metalake.
522+
523+
Args:
524+
role_name: The name of the role.
525+
properties: The properties of the role.
526+
securable_objects: The securable objects of the role.
527+
528+
Returns:
529+
The created Role object.
530+
531+
Raises:
532+
RoleAlreadyExistsException: If a role with the same name already exists.
533+
NoSuchMetalakeException: If the metalake does not exist.
534+
"""
535+
return self.get_metalake().create_role(role_name, properties, securable_objects)
536+
537+
def get_role(self, role_name: str) -> Role:
538+
"""Get a role by name from the metalake.
539+
540+
Args:
541+
role_name: The name of the role.
542+
543+
Returns:
544+
The Role object.
545+
546+
Raises:
547+
NoSuchRoleException: If the role does not exist.
548+
NoSuchMetalakeException: If the metalake does not exist.
549+
"""
550+
return self.get_metalake().get_role(role_name)
551+
552+
def delete_role(self, role_name: str) -> bool:
553+
"""Delete a role from the metalake.
554+
555+
Args:
556+
role_name: The name of the role.
557+
558+
Returns:
559+
True if the role was deleted, False otherwise.
560+
561+
Raises:
562+
NoSuchMetalakeException: If the metalake does not exist.
563+
"""
564+
return self.get_metalake().delete_role(role_name)
565+
566+
def list_role_names(self) -> list[str]:
567+
"""List all role names under the metalake.
568+
569+
Returns:
570+
A list of role name strings.
571+
572+
Raises:
573+
NoSuchMetalakeException: If the metalake does not exist.
574+
"""
575+
return self.get_metalake().list_role_names()

clients/client-python/gravitino/client/gravitino_metalake.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717

18+
# pylint: disable=too-many-lines
1819
import logging
1920
from typing import Dict, List, Optional
2021

2122
from gravitino.api.authorization.group import Group
2223
from gravitino.api.authorization.owner import Owner
24+
from gravitino.api.authorization.role import Role
25+
from gravitino.api.authorization.securable_objects import SecurableObject
2326
from gravitino.api.authorization.user import User
2427
from gravitino.api.catalog import Catalog
2528
from gravitino.api.catalog_change import CatalogChange
@@ -49,6 +52,7 @@
4952
from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
5053
from gravitino.dto.requests.user_add_request import UserAddRequest
5154
from gravitino.dto.requests.group_add_request import GroupAddRequest
55+
from gravitino.dto.requests.role_create_request import RoleCreateRequest
5256
from gravitino.dto.responses.catalog_list_response import CatalogListResponse
5357
from gravitino.dto.responses.catalog_response import CatalogResponse
5458
from gravitino.dto.responses.drop_response import DropResponse
@@ -75,8 +79,13 @@
7579
GroupNamesListResponse,
7680
GroupResponse,
7781
)
82+
from gravitino.dto.responses.role_response import (
83+
RoleNamesListResponse,
84+
RoleResponse,
85+
)
7886
from gravitino.exceptions.handlers.catalog_error_handler import CATALOG_ERROR_HANDLER
7987
from gravitino.exceptions.handlers.group_error_handler import GROUP_ERROR_HANDLER
88+
from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER
8089
from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
8190
from gravitino.exceptions.handlers.owner_error_handler import OWNER_ERROR_HANDLER
8291
from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
@@ -114,6 +123,8 @@ class GravitinoMetalake(
114123
API_METALAKES_USER_PATH = "api/metalakes/{}/users/{}"
115124
API_METALAKES_GROUPS_PATH = "api/metalakes/{}/groups"
116125
API_METALAKES_GROUP_PATH = "api/metalakes/{}/groups/{}"
126+
API_METALAKES_ROLES_PATH = "api/metalakes/{}/roles"
127+
API_METALAKES_ROLE_PATH = "api/metalakes/{}/roles/{}"
117128

118129
def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = None):
119130
super().__init__(
@@ -1002,3 +1013,106 @@ def list_group_names(self) -> list[str]:
10021013
resp = GroupNamesListResponse.from_json(response.body, infer_missing=True)
10031014
resp.validate()
10041015
return resp.names()
1016+
1017+
#####################
1018+
# Role operations
1019+
#####################
1020+
1021+
def create_role(
1022+
self,
1023+
role_name: str,
1024+
properties: Optional[Dict[str, str]] = None,
1025+
securable_objects: Optional[List[SecurableObject]] = None,
1026+
) -> Role:
1027+
"""Create a new role under this metalake.
1028+
1029+
Args:
1030+
role_name: The name of the role.
1031+
properties: The properties of the role.
1032+
securable_objects: The securable objects of the role.
1033+
1034+
Returns:
1035+
The created Role object.
1036+
1037+
Raises:
1038+
RoleAlreadyExistsException: If a role with the same name already exists.
1039+
NoSuchMetalakeException: If the metalake does not exist.
1040+
"""
1041+
Precondition.check_string_not_empty(
1042+
role_name, "role name must not be null or empty"
1043+
)
1044+
securable_object_dtos = [
1045+
DTOConverters.to_securable_object_dto(obj)
1046+
for obj in (securable_objects or [])
1047+
]
1048+
req = RoleCreateRequest(role_name, properties, securable_object_dtos)
1049+
req.validate()
1050+
url = self.API_METALAKES_ROLES_PATH.format(encode_string(self.name()))
1051+
response = self.rest_client.post(
1052+
url, json=req, error_handler=ROLE_ERROR_HANDLER
1053+
)
1054+
resp = RoleResponse.from_json(response.body, infer_missing=True)
1055+
resp.validate()
1056+
return resp.role()
1057+
1058+
def get_role(self, role_name: str) -> Role:
1059+
"""Get a role by name from this metalake.
1060+
1061+
Args:
1062+
role_name: The name of the role.
1063+
1064+
Returns:
1065+
The Role object.
1066+
1067+
Raises:
1068+
NoSuchRoleException: If the role does not exist.
1069+
NoSuchMetalakeException: If the metalake does not exist.
1070+
"""
1071+
Precondition.check_string_not_empty(
1072+
role_name, "role name must not be null or empty"
1073+
)
1074+
url = self.API_METALAKES_ROLE_PATH.format(
1075+
encode_string(self.name()), encode_string(role_name)
1076+
)
1077+
response = self.rest_client.get(url, error_handler=ROLE_ERROR_HANDLER)
1078+
resp = RoleResponse.from_json(response.body, infer_missing=True)
1079+
resp.validate()
1080+
return resp.role()
1081+
1082+
def delete_role(self, role_name: str) -> bool:
1083+
"""Delete a role from this metalake.
1084+
1085+
Args:
1086+
role_name: The name of the role.
1087+
1088+
Returns:
1089+
True if the role was deleted, False otherwise.
1090+
1091+
Raises:
1092+
NoSuchMetalakeException: If the metalake does not exist.
1093+
"""
1094+
Precondition.check_string_not_empty(
1095+
role_name, "role name must not be null or empty"
1096+
)
1097+
url = self.API_METALAKES_ROLE_PATH.format(
1098+
encode_string(self.name()), encode_string(role_name)
1099+
)
1100+
response = self.rest_client.delete(url, error_handler=ROLE_ERROR_HANDLER)
1101+
drop_response = DropResponse.from_json(response.body, infer_missing=True)
1102+
drop_response.validate()
1103+
return drop_response.dropped()
1104+
1105+
def list_role_names(self) -> list[str]:
1106+
"""List all role names under this metalake.
1107+
1108+
Returns:
1109+
A list of role name strings.
1110+
1111+
Raises:
1112+
NoSuchMetalakeException: If the metalake does not exist.
1113+
"""
1114+
url = self.API_METALAKES_ROLES_PATH.format(encode_string(self.name()))
1115+
response = self.rest_client.get(url, error_handler=ROLE_ERROR_HANDLER)
1116+
resp = RoleNamesListResponse.from_json(response.body, infer_missing=True)
1117+
resp.validate()
1118+
return resp.names()

clients/client-python/gravitino/dto/authorization/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@
1616
# under the License.
1717

1818
from gravitino.dto.authorization.owner_dto import OwnerDTO
19+
from gravitino.dto.authorization.privilege_dto import PrivilegeDTO
20+
from gravitino.dto.authorization.role_dto import RoleDTO
21+
from gravitino.dto.authorization.securable_object_dto import SecurableObjectDTO
22+
23+
__all__ = ["OwnerDTO", "PrivilegeDTO", "RoleDTO", "SecurableObjectDTO"]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
from dataclasses import dataclass, field
21+
22+
from dataclasses_json import config, dataclass_json
23+
24+
from gravitino.api.authorization.privileges import Privilege, Privileges
25+
from gravitino.api.metadata_object import MetadataObject
26+
27+
28+
def _encode_privilege_name(name: Privilege.Name) -> str:
29+
return name.name.lower()
30+
31+
32+
def _decode_privilege_name(val: str) -> Privilege.Name:
33+
return Privilege.Name[val.upper()]
34+
35+
36+
def _encode_condition(condition: Privilege.Condition) -> str:
37+
return condition.value.lower()
38+
39+
40+
def _decode_condition(val: str) -> Privilege.Condition:
41+
return Privilege.Condition(val.upper())
42+
43+
44+
@dataclass_json
45+
@dataclass
46+
class PrivilegeDTO(Privilege):
47+
"""Data transfer object representing a privilege."""
48+
49+
_name: Privilege.Name = field(
50+
metadata=config(
51+
field_name="name",
52+
encoder=_encode_privilege_name,
53+
decoder=_decode_privilege_name,
54+
)
55+
)
56+
_condition: Privilege.Condition = field(
57+
metadata=config(
58+
field_name="condition",
59+
encoder=_encode_condition,
60+
decoder=_decode_condition,
61+
)
62+
)
63+
64+
def name(self) -> Privilege.Name:
65+
return self._name
66+
67+
def simple_string(self) -> str:
68+
return f"{self._condition.value} {self._name.name.lower().replace('_', ' ')}"
69+
70+
def condition(self) -> Privilege.Condition:
71+
return self._condition
72+
73+
def can_bind_to(self, obj_type: MetadataObject.Type) -> bool:
74+
if self._condition == Privilege.Condition.ALLOW:
75+
return Privileges.allow(self._name.name).can_bind_to(obj_type)
76+
return Privileges.deny(self._name.name).can_bind_to(obj_type)
77+
78+
def __eq__(self, other: object) -> bool:
79+
if not isinstance(other, Privilege):
80+
return False
81+
return self._name == other.name() and self._condition == other.condition()
82+
83+
def __hash__(self) -> int:
84+
return hash((self._name, self._condition))
85+
86+
@staticmethod
87+
def builder() -> PrivilegeDTO.Builder:
88+
return PrivilegeDTO.Builder()
89+
90+
class Builder:
91+
"""Helper class to build a PrivilegeDTO object."""
92+
93+
def __init__(self) -> None:
94+
self._name: Privilege.Name = None
95+
self._condition: Privilege.Condition = None
96+
97+
def with_name(self, name: Privilege.Name) -> PrivilegeDTO.Builder:
98+
self._name = name
99+
return self
100+
101+
def with_condition(
102+
self, condition: Privilege.Condition
103+
) -> PrivilegeDTO.Builder:
104+
self._condition = condition
105+
return self
106+
107+
def build(self) -> PrivilegeDTO:
108+
if self._name is None:
109+
raise ValueError("name cannot be None")
110+
if self._condition is None:
111+
raise ValueError("condition cannot be None")
112+
return PrivilegeDTO(self._name, self._condition)

0 commit comments

Comments
 (0)