feat(python): expose Permissions and remaining user auth methods#3727
feat(python): expose Permissions and remaining user auth methods#3727ethanlin01x wants to merge 13 commits into
Conversation
63a8908 to
84a9f3b
Compare
Mirror the Rust Permissions, GlobalPermissions, StreamPermissions, and TopicPermissions types as PyO3 classes so user permissions can be built and inspected from Python. Stream and topic dict keys are typed u32 to match the wire format, so out-of-range IDs fail at construction instead of being silently truncated. The client methods that consume these classes follow in the next commits.
create_user hardcoded None for permissions, so every user came out unprivileged and UserInfoDetails gave no way to inspect grants. Wire the Permissions argument through create_user and add the permissions getter so grants round-trip through get_user. The credential helpers shared by the user tests move to tests/utils.py for the new enforcement tests.
Permissions set at creation were frozen: there was no way to grant, replace, or revoke them afterwards. update_permissions is a full replacement and None clears the permissions entirely, matching the Rust client semantics.
Password rotation required deleting and recreating the user, losing its id and permissions. change_password verifies the current password server-side, and a user can rotate its own credentials without any admin permission.
Sessions could only be abandoned by dropping the connection, leaving the server-side session authenticated until the socket closed. logout_user ends the session explicitly; a later login_user on the same client starts a fresh one.
84a9f3b to
22bc32c
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3727 +/- ##
============================================
+ Coverage 74.34% 74.38% +0.03%
Complexity 950 950
============================================
Files 1303 1304 +1
Lines 148712 148954 +242
Branches 124225 124225
============================================
+ Hits 110562 110796 +234
- Misses 34673 34681 +8
Partials 3477 3477
🚀 New features to boost your workflow:
|
|
/ready |
| #[new] | ||
| #[pyo3(signature = (global_=None, streams=None))] | ||
| fn new( | ||
| #[gen_stub(override_type(type_repr = "GlobalPermissions | None"))] global_: Option< |
There was a problem hiding this comment.
Why global_? why not global?
There was a problem hiding this comment.
global is a reserved keyword in Python.
The trailing underscore follows the PEP 8 convention for names that collide with keywords (like class_). An alternative would be global_permissions, but I kept global_ to mirror the Rust field name. Let me know if you'd prefer the longer name.
There was a problem hiding this comment.
let's go with global_permissions
There was a problem hiding this comment.
Renamed, 44b214c
One question: should streams / topics keep mirroring the Rust field names, or would you prefer stream_permissions / topic_permissions for symmetry?
There was a problem hiding this comment.
Let's remove the following tests:
test_all_global_permission_flags_set_togethertest_all_stream_permission_flags_set_togethertest_all_topic_permission_flags_set_together
they do not add much to the current test set.
There was a problem hiding this comment.
Let us add the following tests:
- User without
manage_userscannot update another user’s permissions. - User with
manage_userscan update another user’s permissions. - Failed update leaves target permissions unchanged.
- calling logout twice fails
There was a problem hiding this comment.
Let's also add the following tests:
- User without
manage_userscannot change another user's password. - User with
manage_userscan change another user's password. - Failed unauthorized change leaves the old credentials valid.
There was a problem hiding this comment.
Added, 364162a. The "cannot change" and "old credentials stay valid" cases share one test (test_failed_password_change_leaves_target_credentials_valid), since the former's setup and assertions are a strict subset of the latter's.
| async def test_create_user_without_permissions_has_none( | ||
| self, iggy_client: IggyClient, unique_name | ||
| ): |
There was a problem hiding this comment.
test_create_user_without_permissions_has_none is valid but need not be a separate integration test. Add permissions is None assertions to existing test_create_and_get_user.
|
/ready |
There was a problem hiding this comment.
Let's also add the following tests:
- User without
manage_userscannot change another user's password. - User with
manage_userscan change another user's password. - Failed unauthorized change leaves the old credentials valid.
| /// Raises: | ||
| /// PyValueError: If a string identifier is invalid. | ||
| /// PyRuntimeError: If the request fails. | ||
| #[pyo3(signature = (user_id, permissions=None))] |
There was a problem hiding this comment.
Let's make the permission explicit with #[pyo3(signature = (user_id, permissions))] so that clearing the permissions will have to be explicitly specified with None.
There was a problem hiding this comment.
GlobalPermissions, StreamPermissions, and TopicPermissions accept positional booleans. Ten adjacent booleans make this legal and dangerous:
GlobalPermissions(False, False, True, ...)A shifted argument can grant the wrong privilege without any error. These are new security-sensitive APIs. Add * to their PyO3 signatures so permission flags must be named. Existing tests already use keyword arguments.
There was a problem hiding this comment.
Empty streams and topics dictionaries are preserved locally but cannot be represented distinctly on the wire. Both become empty vectors and decode as None, so Permissions(streams={}) and StreamPermissions(topics={}) fail round-trip equality. Please normalize empty dictionaries to None in these constructors and cover both cases.
| /// Args: | ||
| /// manage_servers: Allow managing servers; includes `read_servers`. | ||
| /// read_servers: Allow reading server info (stats, clients). | ||
| /// manage_users: Allow managing users; includes `read_users`. | ||
| /// read_users: Allow reading user info. | ||
| /// manage_streams: Allow managing all streams; includes `manage_topics`. | ||
| /// read_streams: Allow reading all streams; includes `read_topics`. | ||
| /// manage_topics: Allow managing all topics; includes `read_topics`. | ||
| /// read_topics: Allow reading all topics and consumer groups. | ||
| /// poll_messages: Allow polling messages from all streams. | ||
| /// send_messages: Allow sending messages to all streams. |
There was a problem hiding this comment.
This documentation does not actually describe permission inheritance:
read_topicsalso permits polling message contents.manage_topicspermits polling and sending messages.manage_streamsinherits topic management and therefore permits
polling and sending.- Stream
read_topicsand topicread_topicpermit polling. - Stream and topic management permissions permit sending.
There was a problem hiding this comment.
Fixed, aff6f0f.
Documented the direct inclusion edges per the server permissioner rules with a note that they apply transitively. Also covered a few grants beyond the listed ones: read_topics / read_topic additionally allow managing (including creating and deleting) consumer groups, poll_messages allows managing consumer offsets, and stream-level read_stream also permits polling via read_topics.
The trailing underscore avoided the Python keyword collision but reads poorly at call sites. Reviewer settled on the explicit long name.
|
/ready |
62507bf to
e1f5e2e
Compare
The wire format encodes an empty streams or topics map identically to
an absent one, so a Permissions(streams={}) fetched back from the
server compared unequal to what was stored. Collapse empty maps to
None at construction to keep round-trip equality.
update_permissions defaulted permissions to None, so forgetting the argument silently wiped a user's permissions; clearing now requires an explicit None. The permission constructors accepted up to ten adjacent positional booleans, where a shifted argument grants the wrong privilege without any error; the flags are now keyword-only.
The flag docs only stated same-level inclusion, hiding that read and manage flags cascade down to message operations: read_topics permits polling message contents, manage_topics permits polling and sending, and manage_streams inherits both through manage_topics. Document the direct inclusion edges per the server permissioner rules, note that they apply transitively, and spell out the grants hidden behind read flags: consumer group management under read_topics and consumer offset management under poll_messages.
A user with manage_users can change another user's password; a user without it is denied and the target's old password remains valid.
e1f5e2e to
364162a
Compare
|
/ready |
Which issue does this PR address?
Closes #3726
Rationale
The Python SDK could create users but not grant them access to anything:
create_userhardcodedNonefor permissions, andupdate_permissions,change_password, and logout_user` were unexposed.What changed?
Before, the Python SDK could create users but not grant them access to anything:
create_userhardcodedNonefor permissions, andupdate_permissions,change_password, andlogout_userwere unexposed.Now the Rust
Permissionshierarchy is mirrored as PyO3 classes,create_usertakes an optionalpermissionsargument,UserInfoDetailsexposes apermissionsgetter, and the three missing methods complete theUserClientsurface, with the underlying Rust client handling the wire encoding.Local Execution
AI Usage
Claude was used to help generate and review this PR and all the changes are checked by the human.