Skip to content

Commit 687f945

Browse files
committed
[#10782] feat(client-python): add User authorization management
Add User management capabilities to the Python client, supporting the same set of User operations already exposed by the Java client and REST API layer. This is part of a larger split of #10783, which introduced the full authorization model in a single PR. To make review easier, the change is being broken down by entity; subsequent PRs will add Group, Role, Privilege, SecurableObject and grant/revoke operations. Changes - Add User interface and UserDTO (Builder-based, immutable) - Add UserAddRequest / UserResponse / UserListResponse / UserNamesListResponse DTOs, plus a shared RemoveResponse - Add add_user / get_user / remove_user / list_users / list_user_names methods to GravitinoMetalake - Add UserErrorHandler to map User-related REST errors to NoSuchUserException, UserAlreadyExistsException, etc. - Add 28 unit tests covering DTO serialization, response parsing, GravitinoMetalake HTTP interactions (URL, method, body, error propagation) and the error-handler mapping table
1 parent a4ff285 commit 687f945

13 files changed

Lines changed: 857 additions & 0 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
@@ -20,9 +20,11 @@
2020
from gravitino.api.authorization.privileges import Privileges
2121
from gravitino.api.authorization.role import Role
2222
from gravitino.api.authorization.securable_objects import SecurableObjects
23+
from gravitino.api.authorization.user import User
2324

2425
__all__ = [
2526
"Role",
2627
"SecurableObjects",
2728
"Privileges",
29+
"User",
2830
]
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 User(Auditable):
26+
"""The interface of a user. The user is a basic entity in the authorization system."""
27+
28+
@abstractmethod
29+
def name(self) -> str:
30+
"""
31+
The name of the user.
32+
33+
Returns:
34+
str: The name of the user.
35+
"""
36+
raise NotImplementedError()
37+
38+
@abstractmethod
39+
def roles(self) -> list[str]:
40+
"""
41+
The roles of the user. A user can have multiple roles.
42+
Every role binds several privileges.
43+
44+
Returns:
45+
list[str]: The role names of the user.
46+
"""
47+
raise NotImplementedError()

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

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

2121
from gravitino.api.authorization.owner import Owner
22+
from gravitino.api.authorization.user import User
2223
from gravitino.api.catalog import Catalog
2324
from gravitino.api.catalog_change import CatalogChange
2425
from gravitino.api.job.job_handle import JobHandle
@@ -45,6 +46,7 @@
4546
from gravitino.dto.requests.owner_set_request import OwnerSetRequest
4647
from gravitino.dto.requests.tag_create_request import TagCreateRequest
4748
from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
49+
from gravitino.dto.requests.user_add_request import UserAddRequest
4850
from gravitino.dto.responses.catalog_list_response import CatalogListResponse
4951
from gravitino.dto.responses.catalog_response import CatalogResponse
5052
from gravitino.dto.responses.drop_response import DropResponse
@@ -54,16 +56,23 @@
5456
from gravitino.dto.responses.job_template_list_response import JobTemplateListResponse
5557
from gravitino.dto.responses.job_template_response import JobTemplateResponse
5658
from gravitino.dto.responses.owner_response import OwnerResponse
59+
from gravitino.dto.responses.remove_response import RemoveResponse
5760
from gravitino.dto.responses.set_response import SetResponse
5861
from gravitino.dto.responses.tag_response import (
5962
TagListResponse,
6063
TagNamesListResponse,
6164
TagResponse,
6265
)
66+
from gravitino.dto.responses.user_response import (
67+
UserListResponse,
68+
UserNamesListResponse,
69+
UserResponse,
70+
)
6371
from gravitino.exceptions.handlers.catalog_error_handler import CATALOG_ERROR_HANDLER
6472
from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
6573
from gravitino.exceptions.handlers.owner_error_handler import OWNER_ERROR_HANDLER
6674
from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
75+
from gravitino.exceptions.handlers.user_error_handler import USER_ERROR_HANDLER
6776
from gravitino.rest.rest_utils import encode_string
6877
from gravitino.utils.http_client import HTTPClient
6978
from gravitino.utils.precondition import Precondition
@@ -92,6 +101,10 @@ class GravitinoMetalake(
92101
API_METALAKES_TAG_PATH = "api/metalakes/{}/tags/{}"
93102
API_METALAKES_TAGS_PATH = "api/metalakes/{}/tags"
94103

104+
# Authorization paths
105+
API_METALAKES_USERS_PATH = "api/metalakes/{}/users"
106+
API_METALAKES_USER_PATH = "api/metalakes/{}/users/{}"
107+
95108
def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = None):
96109
super().__init__(
97110
_name=metalake.name(),
@@ -767,3 +780,60 @@ def set_owner(
767780
)
768781
set_resp = SetResponse.from_json(response.body, infer_missing=True)
769782
set_resp.validate()
783+
784+
####################
785+
# User operations
786+
####################
787+
788+
def add_user(self, user: str) -> User:
789+
"""Add a user to this metalake."""
790+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
791+
req = UserAddRequest(user)
792+
req.validate()
793+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
794+
response = self.rest_client.post(
795+
url, json=req, error_handler=USER_ERROR_HANDLER
796+
)
797+
resp = UserResponse.from_json(response.body, infer_missing=True)
798+
resp.validate()
799+
return resp.user()
800+
801+
def remove_user(self, user: str) -> bool:
802+
"""Remove a user from this metalake."""
803+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
804+
url = self.API_METALAKES_USER_PATH.format(
805+
encode_string(self.name()), encode_string(user)
806+
)
807+
response = self.rest_client.delete(url, error_handler=USER_ERROR_HANDLER)
808+
remove_response = RemoveResponse.from_json(response.body, infer_missing=True)
809+
remove_response.validate()
810+
return remove_response.removed()
811+
812+
def get_user(self, user: str) -> User:
813+
"""Get a user by name from this metalake."""
814+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
815+
url = self.API_METALAKES_USER_PATH.format(
816+
encode_string(self.name()), encode_string(user)
817+
)
818+
response = self.rest_client.get(url, error_handler=USER_ERROR_HANDLER)
819+
resp = UserResponse.from_json(response.body, infer_missing=True)
820+
resp.validate()
821+
return resp.user()
822+
823+
def list_users(self) -> list[User]:
824+
"""List all users with details under this metalake."""
825+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
826+
response = self.rest_client.get(
827+
url, params={"details": "true"}, error_handler=USER_ERROR_HANDLER
828+
)
829+
resp = UserListResponse.from_json(response.body, infer_missing=True)
830+
resp.validate()
831+
return resp.users()
832+
833+
def list_user_names(self) -> list[str]:
834+
"""List all user names under this metalake."""
835+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
836+
response = self.rest_client.get(url, error_handler=USER_ERROR_HANDLER)
837+
resp = UserNamesListResponse.from_json(response.body, infer_missing=True)
838+
resp.validate()
839+
return resp.names()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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.user import User
26+
from gravitino.dto.audit_dto import AuditDTO
27+
28+
29+
@dataclass_json
30+
@dataclass
31+
class UserDTO(User):
32+
"""Represents a User Data Transfer Object (DTO)."""
33+
34+
_name: str = field(metadata=config(field_name="name"))
35+
_roles: list[str] = field(default_factory=list, metadata=config(field_name="roles"))
36+
_audit: Optional[AuditDTO] = field(
37+
default=None, metadata=config(field_name="audit")
38+
)
39+
40+
def __eq__(self, other: object) -> bool:
41+
if not isinstance(other, UserDTO):
42+
return False
43+
return (
44+
self._name == other._name
45+
and self._roles == other._roles
46+
and self._audit == other._audit
47+
)
48+
49+
def __hash__(self) -> int:
50+
return hash((self._name, tuple(self._roles), self._audit))
51+
52+
@staticmethod
53+
def builder() -> UserDTO.Builder:
54+
return UserDTO.Builder()
55+
56+
def name(self) -> str:
57+
return self._name
58+
59+
def roles(self) -> list[str]:
60+
return self._roles if self._roles else []
61+
62+
def audit_info(self) -> Optional[AuditDTO]:
63+
return self._audit
64+
65+
class Builder:
66+
"""Helper class to build a UserDTO object."""
67+
68+
def __init__(self) -> None:
69+
self._name: str = ""
70+
self._roles: list[str] = []
71+
self._audit: Optional[AuditDTO] = None
72+
73+
def with_name(self, name: str) -> UserDTO.Builder:
74+
self._name = name
75+
return self
76+
77+
def with_roles(self, roles: list[str]) -> UserDTO.Builder:
78+
if roles is not None:
79+
self._roles = roles
80+
return self
81+
82+
def with_audit(self, audit: AuditDTO) -> UserDTO.Builder:
83+
self._audit = audit
84+
return self
85+
86+
def build(self) -> UserDTO:
87+
if not self._name:
88+
raise ValueError("name cannot be null or empty")
89+
return UserDTO(self._name, self._roles, self._audit)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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.rest.rest_message import RESTRequest
25+
from gravitino.utils.precondition import Precondition
26+
27+
28+
@dataclass_json
29+
@dataclass
30+
class UserAddRequest(RESTRequest):
31+
"""Represents a request to add a user."""
32+
33+
_name: str = field(metadata=config(field_name="name"))
34+
35+
def validate(self) -> None:
36+
Precondition.check_string_not_empty(
37+
self._name, "name is required and cannot be empty"
38+
)
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 dataclasses import dataclass, field
21+
22+
from dataclasses_json import config, dataclass_json
23+
24+
from gravitino.dto.responses.base_response import BaseResponse
25+
from gravitino.utils.precondition import Precondition
26+
27+
28+
@dataclass_json
29+
@dataclass
30+
class RemoveResponse(BaseResponse):
31+
"""Represents a response for a remove operation."""
32+
33+
_removed: bool = field(metadata=config(field_name="removed"))
34+
35+
def removed(self) -> bool:
36+
return self._removed
37+
38+
def validate(self) -> None:
39+
"""Validates the response.
40+
41+
Raises:
42+
IllegalArgumentException: If the removed field is not set.
43+
"""
44+
Precondition.check_argument(
45+
self._removed is not None,
46+
"Remove response must contain 'removed' field",
47+
)

0 commit comments

Comments
 (0)