Skip to content

Commit a7d8f36

Browse files
[#10782] feat(client-python): add User authorization management (#11058)
### What changes were proposed in this pull request? This PR is split from #10783 to make review easier. It focuses on the **User** part of the authorization model; Group, Role, Privilege, SecurableObject and grant/revoke operations will follow in subsequent PRs. Changes in this PR: 1. **API layer** (`gravitino/api/authorization/`) - `User` — user interface with `name()` / `roles()` accessors 2. **DTO layer** (`gravitino/dto/authorization/`) - `UserDTO` — immutable DTO with builder pattern, serialization and audit info 3. **Request / Response DTOs** (`gravitino/dto/`) - `UserAddRequest` - `UserResponse` / `UserListResponse` / `UserNamesListResponse` - `RemoveResponse` (shared boolean-remove response used across authorization operations) 4. **Client layer** (`gravitino/client/gravitino_metalake.py`) - Five new public methods on `GravitinoMetalake`: `add_user` / `get_user` / `remove_user` / `list_users` / `list_user_names` 5. **Exception handling** (`gravitino/exceptions/`) - `NoSuchUserException`, `UserAlreadyExistsException` - `UserErrorHandler` mapping REST error codes to the above ### Why are the changes needed? The Python SDK currently has no authorization management capabilities, while the Java SDK and REST API have full support. This PR starts bringing the Python SDK to feature parity with the Java SDK, beginning with User management as the most foundational entity. Fix: #11098 ### Does this PR introduce _any_ user-facing change? Yes. New public API: - `GravitinoMetalake.add_user(name)` / `get_user(name)` / `remove_user(name)` / `list_users()` / `list_user_names()` - New exceptions: `NoSuchUserException`, `UserAlreadyExistsException` No existing APIs or behaviors are changed. ### How was this patch tested? 28 new unit tests (all passing, `ruff` + `pylint`): - **DTO tests** (`tests/unittests/dto/test_user_dto.py`, `tests/unittests/dto/responses/test_user_response.py`) — cover builder, serialization / deserialization roundtrip, equality - **Client-level mock tests** (`tests/unittests/client/test_metalake_user_operations.py`) — cover URL, HTTP method, request body, response parsing, and error propagation for all five `GravitinoMetalake` methods - **Error-handler tests** (added to `tests/unittests/test_error_handler.py`) — verify the full error code mapping table for `UserErrorHandler` Integration tests will be added in the final PR of the split together with grant/revoke operations, where end-to-end multi-entity behavior needs to be validated against a real server. cc @jerryshao — could you help review this when you have time? Thanks! --------- Co-authored-by: Sun Yuhan <sunyuhan1998@users.noreply.github.com> Co-authored-by: Jerry Shao <jerryshao@datastrato.com>
1 parent 640ac60 commit a7d8f36

14 files changed

Lines changed: 1072 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_client.py

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

2222
from gravitino.api.authorization.owner import Owner
23+
from gravitino.api.authorization.user import User
2324
from gravitino.api.catalog import Catalog
2425
from gravitino.api.catalog_change import CatalogChange
2526
from gravitino.api.job.job_handle import JobHandle
@@ -369,3 +370,71 @@ def set_owner(
369370
UnsupportedOperationException: If the operation is not supported.
370371
"""
371372
self.get_metalake().set_owner(metadata_object, owner_name, owner_type)
373+
374+
# User operations
375+
376+
def add_user(self, user: str) -> User:
377+
"""Add a user to the metalake.
378+
379+
Args:
380+
user: The name of the user.
381+
382+
Returns:
383+
The added User object.
384+
385+
Raises:
386+
UserAlreadyExistsException: If a user with the same name already exists.
387+
NoSuchMetalakeException: If the metalake does not exist.
388+
"""
389+
return self.get_metalake().add_user(user)
390+
391+
def remove_user(self, user: str) -> bool:
392+
"""Remove a user from the metalake.
393+
394+
Args:
395+
user: The name of the user.
396+
397+
Returns:
398+
True if the user was removed, False if the user did not exist.
399+
400+
Raises:
401+
NoSuchMetalakeException: If the metalake does not exist.
402+
"""
403+
return self.get_metalake().remove_user(user)
404+
405+
def get_user(self, user: str) -> User:
406+
"""Get a user by name from the metalake.
407+
408+
Args:
409+
user: The name of the user.
410+
411+
Returns:
412+
The User object.
413+
414+
Raises:
415+
NoSuchUserException: If the user does not exist.
416+
NoSuchMetalakeException: If the metalake does not exist.
417+
"""
418+
return self.get_metalake().get_user(user)
419+
420+
def list_users(self) -> list[User]:
421+
"""List all users with details under the metalake.
422+
423+
Returns:
424+
A list of User objects.
425+
426+
Raises:
427+
NoSuchMetalakeException: If the metalake does not exist.
428+
"""
429+
return self.get_metalake().list_users()
430+
431+
def list_user_names(self) -> list[str]:
432+
"""List all user names under the metalake.
433+
434+
Returns:
435+
A list of user name strings.
436+
437+
Raises:
438+
NoSuchMetalakeException: If the metalake does not exist.
439+
"""
440+
return self.get_metalake().list_user_names()

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

Lines changed: 116 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,106 @@ 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+
791+
Args:
792+
user: The name of the user.
793+
794+
Returns:
795+
The added User object.
796+
797+
Raises:
798+
UserAlreadyExistsException: If a user with the same name already exists.
799+
NoSuchMetalakeException: If the metalake does not exist.
800+
"""
801+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
802+
req = UserAddRequest(user)
803+
req.validate()
804+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
805+
response = self.rest_client.post(
806+
url, json=req, error_handler=USER_ERROR_HANDLER
807+
)
808+
resp = UserResponse.from_json(response.body, infer_missing=True)
809+
resp.validate()
810+
return resp.user()
811+
812+
def remove_user(self, user: str) -> bool:
813+
"""Remove a user from this metalake.
814+
815+
Args:
816+
user: The name of the user.
817+
818+
Returns:
819+
True if the user was removed, False if the user did not exist.
820+
821+
Raises:
822+
NoSuchMetalakeException: If the metalake does not exist.
823+
"""
824+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
825+
url = self.API_METALAKES_USER_PATH.format(
826+
encode_string(self.name()), encode_string(user)
827+
)
828+
response = self.rest_client.delete(url, error_handler=USER_ERROR_HANDLER)
829+
remove_response = RemoveResponse.from_json(response.body, infer_missing=True)
830+
remove_response.validate()
831+
return remove_response.removed()
832+
833+
def get_user(self, user: str) -> User:
834+
"""Get a user by name from this metalake.
835+
836+
Args:
837+
user: The name of the user.
838+
839+
Returns:
840+
The User object.
841+
842+
Raises:
843+
NoSuchUserException: If the user does not exist.
844+
NoSuchMetalakeException: If the metalake does not exist.
845+
"""
846+
Precondition.check_string_not_empty(user, "user name must not be null or empty")
847+
url = self.API_METALAKES_USER_PATH.format(
848+
encode_string(self.name()), encode_string(user)
849+
)
850+
response = self.rest_client.get(url, error_handler=USER_ERROR_HANDLER)
851+
resp = UserResponse.from_json(response.body, infer_missing=True)
852+
resp.validate()
853+
return resp.user()
854+
855+
def list_users(self) -> list[User]:
856+
"""List all users with details under this metalake.
857+
858+
Returns:
859+
A list of User objects.
860+
861+
Raises:
862+
NoSuchMetalakeException: If the metalake does not exist.
863+
"""
864+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
865+
response = self.rest_client.get(
866+
url, params={"details": "true"}, error_handler=USER_ERROR_HANDLER
867+
)
868+
resp = UserListResponse.from_json(response.body, infer_missing=True)
869+
resp.validate()
870+
return resp.users()
871+
872+
def list_user_names(self) -> list[str]:
873+
"""List all user names under this metalake.
874+
875+
Returns:
876+
A list of user name strings.
877+
878+
Raises:
879+
NoSuchMetalakeException: If the metalake does not exist.
880+
"""
881+
url = self.API_METALAKES_USERS_PATH.format(encode_string(self.name()))
882+
response = self.rest_client.get(url, error_handler=USER_ERROR_HANDLER)
883+
resp = UserNamesListResponse.from_json(response.body, infer_missing=True)
884+
resp.validate()
885+
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.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: 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, UserDTO):
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() -> UserDTO.Builder:
56+
return UserDTO.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 UserDTO 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) -> UserDTO.Builder:
76+
self._name = name
77+
return self
78+
79+
def with_roles(self, roles: list[str]) -> UserDTO.Builder:
80+
if roles is not None:
81+
self._roles = tuple(roles)
82+
return self
83+
84+
def with_audit(self, audit: AuditDTO) -> UserDTO.Builder:
85+
self._audit = audit
86+
return self
87+
88+
def build(self) -> UserDTO:
89+
if not self._name:
90+
raise ValueError("name cannot be null or empty")
91+
return UserDTO(self._name, self._roles, self._audit)

0 commit comments

Comments
 (0)