Skip to content

Commit 67c867c

Browse files
authored
[#11094] feat(client-python): add Group authorization management (#11109)
## Summary This PR adds Group authorization management support to the Gravitino Python client SDK, following the pattern established in #11058 (User management). It is split from the original monolithic PR #10783. ### Changes - **API**: `Group` interface with `name()` and `roles()` methods - **DTO**: `GroupDTO` with immutable roles (`tuple[str, ...]` internally, defensive `list` copy via `roles()`); audit null validation in Builder - **Request/Response**: `GroupAddRequest`, `GroupResponse`, `GroupListResponse`, `GroupNamesListResponse` - **Error Handler**: `GroupErrorHandler` mapping REST error codes to `NoSuchGroupException`, `GroupAlreadyExistsException`, etc. - **Client**: 5 CRUD methods on both `GravitinoMetalake` and `GravitinoClient` (`add_group`, `remove_group`, `get_group`, `list_groups`, `list_group_names`) - **Tests**: Unit tests (33 cases) and integration tests (6 cases) ### Sub-task of - #10782 (Authorization management for Python SDK) ## Related issues - Closes #11094 ## Reviewers @jerryshao --------- Co-authored-by: Sun Yuhan <sunyuhan1998@users.noreply.github.com>
1 parent ce08516 commit 67c867c

17 files changed

Lines changed: 1231 additions & 27 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717

1818
from __future__ import annotations
1919

20+
from gravitino.api.authorization.group import Group
2021
from gravitino.api.authorization.privileges import Privileges
2122
from gravitino.api.authorization.role import Role
2223
from gravitino.api.authorization.securable_objects import SecurableObjects
2324
from gravitino.api.authorization.user import User
2425

2526
__all__ = [
27+
"Group",
2628
"Role",
2729
"SecurableObjects",
2830
"Privileges",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 abc import abstractmethod
21+
22+
from gravitino.api.auditable import Auditable
23+
24+
25+
class Group(Auditable):
26+
"""The interface of a group. The group is a collection of users in the authorization system."""
27+
28+
@abstractmethod
29+
def name(self) -> str:
30+
"""
31+
The name of the group.
32+
33+
Returns:
34+
str: The name of the group.
35+
"""
36+
raise NotImplementedError()
37+
38+
@abstractmethod
39+
def roles(self) -> list[str]:
40+
"""
41+
The roles of the group. A group can have multiple roles.
42+
Every role binds several privileges.
43+
44+
Returns:
45+
list[str]: The role names of the group.
46+
"""
47+
raise NotImplementedError()

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from typing import Dict, List, Optional
2121

22+
from gravitino.api.authorization.group import Group
2223
from gravitino.api.authorization.owner import Owner
2324
from gravitino.api.authorization.user import User
2425
from gravitino.api.catalog import Catalog
@@ -438,3 +439,71 @@ def list_user_names(self) -> list[str]:
438439
NoSuchMetalakeException: If the metalake does not exist.
439440
"""
440441
return self.get_metalake().list_user_names()
442+
443+
# Group operations
444+
445+
def add_group(self, group: str) -> Group:
446+
"""Add a group to the metalake.
447+
448+
Args:
449+
group: The name of the group.
450+
451+
Returns:
452+
The added Group object.
453+
454+
Raises:
455+
GroupAlreadyExistsException: If a group with the same name already exists.
456+
NoSuchMetalakeException: If the metalake does not exist.
457+
"""
458+
return self.get_metalake().add_group(group)
459+
460+
def remove_group(self, group: str) -> bool:
461+
"""Remove a group from the metalake.
462+
463+
Args:
464+
group: The name of the group.
465+
466+
Returns:
467+
True if the group was removed, False if the group did not exist.
468+
469+
Raises:
470+
NoSuchMetalakeException: If the metalake does not exist.
471+
"""
472+
return self.get_metalake().remove_group(group)
473+
474+
def get_group(self, group: str) -> Group:
475+
"""Get a group by name from the metalake.
476+
477+
Args:
478+
group: The name of the group.
479+
480+
Returns:
481+
The Group object.
482+
483+
Raises:
484+
NoSuchGroupException: If the group does not exist.
485+
NoSuchMetalakeException: If the metalake does not exist.
486+
"""
487+
return self.get_metalake().get_group(group)
488+
489+
def list_groups(self) -> list[Group]:
490+
"""List all groups with details under the metalake.
491+
492+
Returns:
493+
A list of Group objects.
494+
495+
Raises:
496+
NoSuchMetalakeException: If the metalake does not exist.
497+
"""
498+
return self.get_metalake().list_groups()
499+
500+
def list_group_names(self) -> list[str]:
501+
"""List all group names under the metalake.
502+
503+
Returns:
504+
A list of group name strings.
505+
506+
Raises:
507+
NoSuchMetalakeException: If the metalake does not exist.
508+
"""
509+
return self.get_metalake().list_group_names()

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

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
from typing import Dict, List, Optional
2020

21+
from gravitino.api.authorization.group import Group
2122
from gravitino.api.authorization.owner import Owner
2223
from gravitino.api.authorization.user import User
2324
from gravitino.api.catalog import Catalog
@@ -47,6 +48,7 @@
4748
from gravitino.dto.requests.tag_create_request import TagCreateRequest
4849
from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
4950
from gravitino.dto.requests.user_add_request import UserAddRequest
51+
from gravitino.dto.requests.group_add_request import GroupAddRequest
5052
from gravitino.dto.responses.catalog_list_response import CatalogListResponse
5153
from gravitino.dto.responses.catalog_response import CatalogResponse
5254
from gravitino.dto.responses.drop_response import DropResponse
@@ -68,7 +70,13 @@
6870
UserNamesListResponse,
6971
UserResponse,
7072
)
73+
from gravitino.dto.responses.group_response import (
74+
GroupListResponse,
75+
GroupNamesListResponse,
76+
GroupResponse,
77+
)
7178
from gravitino.exceptions.handlers.catalog_error_handler import CATALOG_ERROR_HANDLER
79+
from gravitino.exceptions.handlers.group_error_handler import GROUP_ERROR_HANDLER
7280
from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
7381
from gravitino.exceptions.handlers.owner_error_handler import OWNER_ERROR_HANDLER
7482
from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
@@ -104,6 +112,8 @@ class GravitinoMetalake(
104112
# Authorization paths
105113
API_METALAKES_USERS_PATH = "api/metalakes/{}/users"
106114
API_METALAKES_USER_PATH = "api/metalakes/{}/users/{}"
115+
API_METALAKES_GROUPS_PATH = "api/metalakes/{}/groups"
116+
API_METALAKES_GROUP_PATH = "api/metalakes/{}/groups/{}"
107117

108118
def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = None):
109119
super().__init__(
@@ -883,3 +893,112 @@ def list_user_names(self) -> list[str]:
883893
resp = UserNamesListResponse.from_json(response.body, infer_missing=True)
884894
resp.validate()
885895
return resp.names()
896+
897+
#####################
898+
# Group operations
899+
#####################
900+
901+
def add_group(self, group: str) -> Group:
902+
"""Add a group to this metalake.
903+
904+
Args:
905+
group: The name of the group.
906+
907+
Returns:
908+
The added Group object.
909+
910+
Raises:
911+
GroupAlreadyExistsException: If a group with the same name already exists.
912+
NoSuchMetalakeException: If the metalake does not exist.
913+
"""
914+
Precondition.check_string_not_empty(
915+
group, "group name must not be null or empty"
916+
)
917+
req = GroupAddRequest(group)
918+
req.validate()
919+
url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
920+
response = self.rest_client.post(
921+
url, json=req, error_handler=GROUP_ERROR_HANDLER
922+
)
923+
resp = GroupResponse.from_json(response.body, infer_missing=True)
924+
resp.validate()
925+
return resp.group()
926+
927+
def remove_group(self, group: str) -> bool:
928+
"""Remove a group from this metalake.
929+
930+
Args:
931+
group: The name of the group.
932+
933+
Returns:
934+
True if the group was removed, False if the group did not exist.
935+
936+
Raises:
937+
NoSuchMetalakeException: If the metalake does not exist.
938+
"""
939+
Precondition.check_string_not_empty(
940+
group, "group name must not be null or empty"
941+
)
942+
url = self.API_METALAKES_GROUP_PATH.format(
943+
encode_string(self.name()), encode_string(group)
944+
)
945+
response = self.rest_client.delete(url, error_handler=GROUP_ERROR_HANDLER)
946+
remove_response = RemoveResponse.from_json(response.body, infer_missing=True)
947+
remove_response.validate()
948+
return remove_response.removed()
949+
950+
def get_group(self, group: str) -> Group:
951+
"""Get a group by name from this metalake.
952+
953+
Args:
954+
group: The name of the group.
955+
956+
Returns:
957+
The Group object.
958+
959+
Raises:
960+
NoSuchGroupException: If the group does not exist.
961+
NoSuchMetalakeException: If the metalake does not exist.
962+
"""
963+
Precondition.check_string_not_empty(
964+
group, "group name must not be null or empty"
965+
)
966+
url = self.API_METALAKES_GROUP_PATH.format(
967+
encode_string(self.name()), encode_string(group)
968+
)
969+
response = self.rest_client.get(url, error_handler=GROUP_ERROR_HANDLER)
970+
resp = GroupResponse.from_json(response.body, infer_missing=True)
971+
resp.validate()
972+
return resp.group()
973+
974+
def list_groups(self) -> list[Group]:
975+
"""List all groups with details under this metalake.
976+
977+
Returns:
978+
A list of Group objects.
979+
980+
Raises:
981+
NoSuchMetalakeException: If the metalake does not exist.
982+
"""
983+
url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
984+
response = self.rest_client.get(
985+
url, params={"details": "true"}, error_handler=GROUP_ERROR_HANDLER
986+
)
987+
resp = GroupListResponse.from_json(response.body, infer_missing=True)
988+
resp.validate()
989+
return resp.groups()
990+
991+
def list_group_names(self) -> list[str]:
992+
"""List all group names under this metalake.
993+
994+
Returns:
995+
A list of group name strings.
996+
997+
Raises:
998+
NoSuchMetalakeException: If the metalake does not exist.
999+
"""
1000+
url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
1001+
response = self.rest_client.get(url, error_handler=GROUP_ERROR_HANDLER)
1002+
resp = GroupNamesListResponse.from_json(response.body, infer_missing=True)
1003+
resp.validate()
1004+
return resp.names()
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
from typing import Optional
22+
23+
from dataclasses_json import config, dataclass_json
24+
25+
from gravitino.api.authorization.group import Group
26+
from gravitino.dto.audit_dto import AuditDTO
27+
28+
29+
@dataclass_json
30+
@dataclass
31+
class GroupDTO(Group):
32+
"""Represents a Group Data Transfer Object (DTO)."""
33+
34+
_name: str = field(metadata=config(field_name="name"))
35+
_roles: tuple[str, ...] = field(
36+
default_factory=tuple, metadata=config(field_name="roles")
37+
)
38+
_audit: Optional[AuditDTO] = field(
39+
default=None, metadata=config(field_name="audit")
40+
)
41+
42+
def __eq__(self, other: object) -> bool:
43+
if not isinstance(other, GroupDTO):
44+
return False
45+
return (
46+
self._name == other._name
47+
and self._roles == other._roles
48+
and self._audit == other._audit
49+
)
50+
51+
def __hash__(self) -> int:
52+
return hash((self._name, tuple(self._roles), self._audit))
53+
54+
@staticmethod
55+
def builder() -> GroupDTO.Builder:
56+
return GroupDTO.Builder()
57+
58+
def name(self) -> str:
59+
return self._name
60+
61+
def roles(self) -> list[str]:
62+
return list(self._roles) if self._roles else []
63+
64+
def audit_info(self) -> Optional[AuditDTO]:
65+
return self._audit
66+
67+
class Builder:
68+
"""Helper class to build a GroupDTO object."""
69+
70+
def __init__(self) -> None:
71+
self._name: str = ""
72+
self._roles: tuple[str, ...] = ()
73+
self._audit: Optional[AuditDTO] = None
74+
75+
def with_name(self, name: str) -> GroupDTO.Builder:
76+
self._name = name
77+
return self
78+
79+
def with_roles(self, roles: list[str]) -> GroupDTO.Builder:
80+
if roles is not None:
81+
self._roles = tuple(roles)
82+
return self
83+
84+
def with_audit(self, audit: AuditDTO) -> GroupDTO.Builder:
85+
self._audit = audit
86+
return self
87+
88+
def build(self) -> GroupDTO:
89+
if not self._name:
90+
raise ValueError("name cannot be null or empty")
91+
return GroupDTO(self._name, self._roles, self._audit)

0 commit comments

Comments
 (0)