From f1db0d6be3ca39d20e927c5148f94d13597650e3 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 23:14:11 +0800 Subject: [PATCH 01/13] feat(python): add the Permissions class hierarchy 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. --- foreign/python/apache_iggy.pyi | 229 +++++++++++++ foreign/python/src/lib.rs | 6 + foreign/python/src/permissions.rs | 406 +++++++++++++++++++++++ foreign/python/tests/test_permissions.py | 176 ++++++++++ 4 files changed, 817 insertions(+) create mode 100644 foreign/python/src/permissions.rs create mode 100644 foreign/python/tests/test_permissions.py diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b980c2e0d..4dab36b604 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -32,14 +32,18 @@ __all__ = [ "ConsumerGroup", "ConsumerGroupDetails", "ConsumerGroupMember", + "GlobalPermissions", "IggyClient", "IggyConsumer", + "Permissions", "PollingStrategy", "ReceiveMessage", "SendMessage", "StreamDetails", + "StreamPermissions", "Topic", "TopicDetails", + "TopicPermissions", "UserInfo", "UserInfoDetails", "UserStatus", @@ -307,6 +311,91 @@ class ConsumerGroupMember: Gets the collection of partitions the consumer group member is consuming. """ +@typing.final +class GlobalPermissions: + r""" + Global permissions, applied to all streams without specifying them one by one. + """ + @property + def manage_servers(self) -> builtins.bool: + r""" + Whether managing servers is allowed; includes `read_servers`. + """ + @property + def read_servers(self) -> builtins.bool: + r""" + Whether reading server info (stats, clients) is allowed. + """ + @property + def manage_users(self) -> builtins.bool: + r""" + Whether managing users is allowed; includes `read_users`. + """ + @property + def read_users(self) -> builtins.bool: + r""" + Whether reading user info is allowed. + """ + @property + def manage_streams(self) -> builtins.bool: + r""" + Whether managing all streams is allowed; includes `manage_topics`. + """ + @property + def read_streams(self) -> builtins.bool: + r""" + Whether reading all streams is allowed; includes `read_topics`. + """ + @property + def manage_topics(self) -> builtins.bool: + r""" + Whether managing all topics is allowed; includes `read_topics`. + """ + @property + def read_topics(self) -> builtins.bool: + r""" + Whether reading all topics and consumer groups is allowed. + """ + @property + def poll_messages(self) -> builtins.bool: + r""" + Whether polling messages from all streams is allowed. + """ + @property + def send_messages(self) -> builtins.bool: + r""" + Whether sending messages to all streams is allowed. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__( + cls, + manage_servers: builtins.bool = False, + read_servers: builtins.bool = False, + manage_users: builtins.bool = False, + read_users: builtins.bool = False, + manage_streams: builtins.bool = False, + read_streams: builtins.bool = False, + manage_topics: builtins.bool = False, + read_topics: builtins.bool = False, + poll_messages: builtins.bool = False, + send_messages: builtins.bool = False, + ) -> GlobalPermissions: + r""" + Create global permissions. Every flag defaults to `False`. + + 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. + """ + @typing.final class IggyClient: r""" @@ -818,6 +907,37 @@ class IggyConsumer: Returns an awaitable that completes when shutdown is signaled or a PyRuntimeError on failure. """ +@typing.final +class Permissions: + r""" + The permissions of a user: global permissions applied to all streams, + optionally extended by per-stream permissions. + """ + @property + def global_(self) -> GlobalPermissions: + r""" + The global permissions, applied to all streams. + """ + @property + def streams(self) -> dict[int, StreamPermissions] | None: + r""" + The per-stream permissions keyed by stream ID, or `None` when not set. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__( + cls, + global_: GlobalPermissions | None = None, + streams: dict[int, StreamPermissions] | None = None, + ) -> Permissions: + r""" + Create permissions from global permissions and optional per-stream permissions. + + Args: + global_: Global permissions as `GlobalPermissions | None`; defaults to all denied. + streams: Per-stream permissions keyed by stream ID as + `dict[int, StreamPermissions] | None`. + """ + class PollingStrategy: @typing.final class Offset(PollingStrategy): @@ -916,6 +1036,72 @@ class StreamDetails: @property def topics_count(self) -> builtins.int: ... +@typing.final +class StreamPermissions: + r""" + Permissions for a specific stream and all its topics, optionally refined per topic. + They extend the global permissions, they do not override them. + """ + @property + def manage_stream(self) -> builtins.bool: + r""" + Whether managing the stream is allowed; includes `read_stream`. + """ + @property + def read_stream(self) -> builtins.bool: + r""" + Whether reading the stream is allowed; includes `read_topics`. + """ + @property + def manage_topics(self) -> builtins.bool: + r""" + Whether managing the stream topics is allowed; includes `read_topics`. + """ + @property + def read_topics(self) -> builtins.bool: + r""" + Whether reading the stream topics and consumer groups is allowed. + """ + @property + def poll_messages(self) -> builtins.bool: + r""" + Whether polling messages from the stream is allowed. + """ + @property + def send_messages(self) -> builtins.bool: + r""" + Whether sending messages to the stream is allowed. + """ + @property + def topics(self) -> dict[int, TopicPermissions] | None: + r""" + The per-topic permissions keyed by topic ID, or `None` when not set. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__( + cls, + manage_stream: builtins.bool = False, + read_stream: builtins.bool = False, + manage_topics: builtins.bool = False, + read_topics: builtins.bool = False, + poll_messages: builtins.bool = False, + send_messages: builtins.bool = False, + topics: dict[int, TopicPermissions] | None = None, + ) -> StreamPermissions: + r""" + Create stream permissions. Every flag defaults to `False`. + + Args: + manage_stream: Allow managing the stream; includes `read_stream`. + read_stream: Allow reading the stream; includes `read_topics`. + manage_topics: Allow managing the stream topics; includes `read_topics`. + read_topics: Allow reading the stream topics and consumer groups. + poll_messages: Allow polling messages from the stream. + send_messages: Allow sending messages to the stream. + topics: Per-topic permissions keyed by topic ID as + `dict[int, TopicPermissions] | None`. + """ + @typing.final class Topic: @property @@ -972,6 +1158,49 @@ class TopicDetails: Replication factor for the topic. """ +@typing.final +class TopicPermissions: + r""" + Permissions for a specific topic of a stream. The lowest level of permissions. + """ + @property + def manage_topic(self) -> builtins.bool: + r""" + Whether managing the topic is allowed; includes `read_topic`. + """ + @property + def read_topic(self) -> builtins.bool: + r""" + Whether reading the topic and its consumer groups is allowed. + """ + @property + def poll_messages(self) -> builtins.bool: + r""" + Whether polling messages from the topic is allowed. + """ + @property + def send_messages(self) -> builtins.bool: + r""" + Whether sending messages to the topic is allowed. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __new__( + cls, + manage_topic: builtins.bool = False, + read_topic: builtins.bool = False, + poll_messages: builtins.bool = False, + send_messages: builtins.bool = False, + ) -> TopicPermissions: + r""" + Create topic permissions. Every flag defaults to `False`. + + Args: + manage_topic: Allow managing the topic; includes `read_topic`. + read_topic: Allow reading the topic and its consumer groups. + poll_messages: Allow polling messages from the topic. + send_messages: Allow sending messages to the topic. + """ + @typing.final class UserInfo: @property diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index f831fd703a..bad1c7cf11 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -18,6 +18,7 @@ pub mod client; mod consumer; mod identifier; +mod permissions; mod receive_message; mod send_message; mod stream; @@ -29,6 +30,7 @@ use consumer::{ AutoCommit, AutoCommitAfter, AutoCommitWhen, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator, }; +use permissions::{GlobalPermissions, Permissions, StreamPermissions, TopicPermissions}; use pyo3::prelude::*; use receive_message::{PollingStrategy, ReceiveMessage}; use send_message::SendMessage; @@ -57,5 +59,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/foreign/python/src/permissions.rs b/foreign/python/src/permissions.rs new file mode 100644 index 0000000000..93ea6dc48a --- /dev/null +++ b/foreign/python/src/permissions.rs @@ -0,0 +1,406 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use iggy::prelude::{ + GlobalPermissions as RustGlobalPermissions, Permissions as RustPermissions, + StreamPermissions as RustStreamPermissions, TopicPermissions as RustTopicPermissions, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use std::collections::BTreeMap; + +/// The permissions of a user: global permissions applied to all streams, +/// optionally extended by per-stream permissions. +#[derive(Debug, Clone, PartialEq)] +#[gen_stub_pyclass] +#[pyclass(eq, from_py_object)] +pub struct Permissions { + pub(crate) inner: RustPermissions, +} + +impl From for Permissions { + fn from(permissions: RustPermissions) -> Self { + Self { inner: permissions } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl Permissions { + /// Create permissions from global permissions and optional per-stream permissions. + /// + /// Args: + /// global_: Global permissions as `GlobalPermissions | None`; defaults to all denied. + /// streams: Per-stream permissions keyed by stream ID as + /// `dict[int, StreamPermissions] | None`. + #[new] + #[pyo3(signature = (global_=None, streams=None))] + fn new( + #[gen_stub(override_type(type_repr = "GlobalPermissions | None"))] global_: Option< + GlobalPermissions, + >, + #[gen_stub(override_type(type_repr = "dict[int, StreamPermissions] | None"))] + streams: Option>, + ) -> Self { + Self { + inner: RustPermissions { + global: global_.map(|global| global.inner).unwrap_or_default(), + streams: streams.map(|streams| { + streams + .into_iter() + .map(|(stream_id, stream)| (stream_id as usize, stream.inner)) + .collect() + }), + }, + } + } + + /// The global permissions, applied to all streams. + #[getter] + fn global_(&self) -> GlobalPermissions { + GlobalPermissions { + inner: self.inner.global.clone(), + } + } + + /// The per-stream permissions keyed by stream ID, or `None` when not set. + #[getter] + #[gen_stub(override_return_type(type_repr = "dict[int, StreamPermissions] | None"))] + fn streams(&self) -> Option> { + self.inner.streams.as_ref().map(|streams| { + streams + .iter() + .map(|(stream_id, stream)| { + // IDs are u32 on the wire, so the cast cannot truncate. + ( + *stream_id as u32, + StreamPermissions { + inner: stream.clone(), + }, + ) + }) + .collect() + }) + } +} + +/// Global permissions, applied to all streams without specifying them one by one. +#[derive(Debug, Clone, PartialEq)] +#[gen_stub_pyclass] +#[pyclass(eq, from_py_object)] +pub struct GlobalPermissions { + pub(crate) inner: RustGlobalPermissions, +} + +#[gen_stub_pymethods] +#[pymethods] +impl GlobalPermissions { + /// Create global permissions. Every flag defaults to `False`. + /// + /// 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. + #[new] + #[pyo3(signature = ( + manage_servers=false, + read_servers=false, + manage_users=false, + read_users=false, + manage_streams=false, + read_streams=false, + manage_topics=false, + read_topics=false, + poll_messages=false, + send_messages=false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + manage_servers: bool, + read_servers: bool, + manage_users: bool, + read_users: bool, + manage_streams: bool, + read_streams: bool, + manage_topics: bool, + read_topics: bool, + poll_messages: bool, + send_messages: bool, + ) -> Self { + Self { + inner: RustGlobalPermissions { + manage_servers, + read_servers, + manage_users, + read_users, + manage_streams, + read_streams, + manage_topics, + read_topics, + poll_messages, + send_messages, + }, + } + } + + /// Whether managing servers is allowed; includes `read_servers`. + #[getter] + fn manage_servers(&self) -> bool { + self.inner.manage_servers + } + + /// Whether reading server info (stats, clients) is allowed. + #[getter] + fn read_servers(&self) -> bool { + self.inner.read_servers + } + + /// Whether managing users is allowed; includes `read_users`. + #[getter] + fn manage_users(&self) -> bool { + self.inner.manage_users + } + + /// Whether reading user info is allowed. + #[getter] + fn read_users(&self) -> bool { + self.inner.read_users + } + + /// Whether managing all streams is allowed; includes `manage_topics`. + #[getter] + fn manage_streams(&self) -> bool { + self.inner.manage_streams + } + + /// Whether reading all streams is allowed; includes `read_topics`. + #[getter] + fn read_streams(&self) -> bool { + self.inner.read_streams + } + + /// Whether managing all topics is allowed; includes `read_topics`. + #[getter] + fn manage_topics(&self) -> bool { + self.inner.manage_topics + } + + /// Whether reading all topics and consumer groups is allowed. + #[getter] + fn read_topics(&self) -> bool { + self.inner.read_topics + } + + /// Whether polling messages from all streams is allowed. + #[getter] + fn poll_messages(&self) -> bool { + self.inner.poll_messages + } + + /// Whether sending messages to all streams is allowed. + #[getter] + fn send_messages(&self) -> bool { + self.inner.send_messages + } +} + +/// Permissions for a specific stream and all its topics, optionally refined per topic. +/// They extend the global permissions, they do not override them. +#[derive(Debug, Clone, PartialEq)] +#[gen_stub_pyclass] +#[pyclass(eq, from_py_object)] +pub struct StreamPermissions { + pub(crate) inner: RustStreamPermissions, +} + +#[gen_stub_pymethods] +#[pymethods] +impl StreamPermissions { + /// Create stream permissions. Every flag defaults to `False`. + /// + /// Args: + /// manage_stream: Allow managing the stream; includes `read_stream`. + /// read_stream: Allow reading the stream; includes `read_topics`. + /// manage_topics: Allow managing the stream topics; includes `read_topics`. + /// read_topics: Allow reading the stream topics and consumer groups. + /// poll_messages: Allow polling messages from the stream. + /// send_messages: Allow sending messages to the stream. + /// topics: Per-topic permissions keyed by topic ID as + /// `dict[int, TopicPermissions] | None`. + #[new] + #[pyo3(signature = ( + manage_stream=false, + read_stream=false, + manage_topics=false, + read_topics=false, + poll_messages=false, + send_messages=false, + topics=None, + ))] + fn new( + manage_stream: bool, + read_stream: bool, + manage_topics: bool, + read_topics: bool, + poll_messages: bool, + send_messages: bool, + #[gen_stub(override_type(type_repr = "dict[int, TopicPermissions] | None"))] topics: Option< + BTreeMap, + >, + ) -> Self { + Self { + inner: RustStreamPermissions { + manage_stream, + read_stream, + manage_topics, + read_topics, + poll_messages, + send_messages, + topics: topics.map(|topics| { + topics + .into_iter() + .map(|(topic_id, topic)| (topic_id as usize, topic.inner)) + .collect() + }), + }, + } + } + + /// Whether managing the stream is allowed; includes `read_stream`. + #[getter] + fn manage_stream(&self) -> bool { + self.inner.manage_stream + } + + /// Whether reading the stream is allowed; includes `read_topics`. + #[getter] + fn read_stream(&self) -> bool { + self.inner.read_stream + } + + /// Whether managing the stream topics is allowed; includes `read_topics`. + #[getter] + fn manage_topics(&self) -> bool { + self.inner.manage_topics + } + + /// Whether reading the stream topics and consumer groups is allowed. + #[getter] + fn read_topics(&self) -> bool { + self.inner.read_topics + } + + /// Whether polling messages from the stream is allowed. + #[getter] + fn poll_messages(&self) -> bool { + self.inner.poll_messages + } + + /// Whether sending messages to the stream is allowed. + #[getter] + fn send_messages(&self) -> bool { + self.inner.send_messages + } + + /// The per-topic permissions keyed by topic ID, or `None` when not set. + #[getter] + #[gen_stub(override_return_type(type_repr = "dict[int, TopicPermissions] | None"))] + fn topics(&self) -> Option> { + self.inner.topics.as_ref().map(|topics| { + topics + .iter() + .map(|(topic_id, topic)| { + // IDs are u32 on the wire, so the cast cannot truncate. + ( + *topic_id as u32, + TopicPermissions { + inner: topic.clone(), + }, + ) + }) + .collect() + }) + } +} + +/// Permissions for a specific topic of a stream. The lowest level of permissions. +#[derive(Debug, Clone, PartialEq)] +#[gen_stub_pyclass] +#[pyclass(eq, from_py_object)] +pub struct TopicPermissions { + pub(crate) inner: RustTopicPermissions, +} + +#[gen_stub_pymethods] +#[pymethods] +impl TopicPermissions { + /// Create topic permissions. Every flag defaults to `False`. + /// + /// Args: + /// manage_topic: Allow managing the topic; includes `read_topic`. + /// read_topic: Allow reading the topic and its consumer groups. + /// poll_messages: Allow polling messages from the topic. + /// send_messages: Allow sending messages to the topic. + #[new] + #[pyo3(signature = ( + manage_topic=false, + read_topic=false, + poll_messages=false, + send_messages=false, + ))] + fn new(manage_topic: bool, read_topic: bool, poll_messages: bool, send_messages: bool) -> Self { + Self { + inner: RustTopicPermissions { + manage_topic, + read_topic, + poll_messages, + send_messages, + }, + } + } + + /// Whether managing the topic is allowed; includes `read_topic`. + #[getter] + fn manage_topic(&self) -> bool { + self.inner.manage_topic + } + + /// Whether reading the topic and its consumer groups is allowed. + #[getter] + fn read_topic(&self) -> bool { + self.inner.read_topic + } + + /// Whether polling messages from the topic is allowed. + #[getter] + fn poll_messages(&self) -> bool { + self.inner.poll_messages + } + + /// Whether sending messages to the topic is allowed. + #[getter] + fn send_messages(&self) -> bool { + self.inner.send_messages + } +} diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py new file mode 100644 index 0000000000..295d56f487 --- /dev/null +++ b/foreign/python/tests/test_permissions.py @@ -0,0 +1,176 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Any + +import pytest + +from apache_iggy import ( + GlobalPermissions, + Permissions, + StreamPermissions, + TopicPermissions, +) + +GLOBAL_PERMISSION_FLAGS = [ + "manage_servers", + "read_servers", + "manage_users", + "read_users", + "manage_streams", + "read_streams", + "manage_topics", + "read_topics", + "poll_messages", + "send_messages", +] + +STREAM_PERMISSION_FLAGS = [ + "manage_stream", + "read_stream", + "manage_topics", + "read_topics", + "poll_messages", + "send_messages", +] + +TOPIC_PERMISSION_FLAGS = [ + "manage_topic", + "read_topic", + "poll_messages", + "send_messages", +] + + +class TestPermissionsModel: + """Test constructing the Permissions classes without a server.""" + + def test_default_permissions_deny_everything(self): + """Test Permissions() has every global flag off and no streams.""" + permissions = Permissions() + + assert permissions.streams is None + global_permissions = permissions.global_ + assert global_permissions.manage_servers is False + assert global_permissions.read_servers is False + assert global_permissions.manage_users is False + assert global_permissions.read_users is False + assert global_permissions.manage_streams is False + assert global_permissions.read_streams is False + assert global_permissions.manage_topics is False + assert global_permissions.read_topics is False + assert global_permissions.poll_messages is False + assert global_permissions.send_messages is False + + @pytest.mark.parametrize("flag", GLOBAL_PERMISSION_FLAGS) + def test_each_global_permission_flag_round_trips_alone(self, flag): + """Test setting one flag flips only that flag.""" + global_permissions = GlobalPermissions(**{flag: True}) + + for name in GLOBAL_PERMISSION_FLAGS: + assert getattr(global_permissions, name) is (name == flag) + + def test_all_global_permission_flags_set_together(self): + """Test all flags can be enabled at once.""" + global_permissions = GlobalPermissions( + **dict.fromkeys(GLOBAL_PERMISSION_FLAGS, True) + ) + + for name in GLOBAL_PERMISSION_FLAGS: + assert getattr(global_permissions, name) is True + + @pytest.mark.parametrize("flag", STREAM_PERMISSION_FLAGS) + def test_each_stream_permission_flag_round_trips_alone(self, flag): + """Test setting one flag flips only that flag.""" + flags: dict[str, Any] = {flag: True} + stream_permissions = StreamPermissions(**flags) + + for name in STREAM_PERMISSION_FLAGS: + assert getattr(stream_permissions, name) is (name == flag) + + def test_all_stream_permission_flags_set_together(self): + """Test all flags can be enabled at once.""" + flags: dict[str, Any] = dict.fromkeys(STREAM_PERMISSION_FLAGS, True) + stream_permissions = StreamPermissions(**flags) + + for name in STREAM_PERMISSION_FLAGS: + assert getattr(stream_permissions, name) is True + + @pytest.mark.parametrize("flag", TOPIC_PERMISSION_FLAGS) + def test_each_topic_permission_flag_round_trips_alone(self, flag): + """Test setting one flag flips only that flag.""" + topic_permissions = TopicPermissions(**{flag: True}) + + for name in TOPIC_PERMISSION_FLAGS: + assert getattr(topic_permissions, name) is (name == flag) + + def test_all_topic_permission_flags_set_together(self): + """Test all flags can be enabled at once.""" + topic_permissions = TopicPermissions( + **dict.fromkeys(TOPIC_PERMISSION_FLAGS, True) + ) + + for name in TOPIC_PERMISSION_FLAGS: + assert getattr(topic_permissions, name) is True + + def test_nested_stream_and_topic_permissions_are_preserved(self): + """Test stream and topic permission dicts survive construction.""" + permissions = Permissions( + global_=GlobalPermissions(read_servers=True), + streams={ + 1: StreamPermissions( + read_stream=True, + topics={7: TopicPermissions(poll_messages=True)}, + ), + 42: StreamPermissions(manage_stream=True), + }, + ) + + streams = permissions.streams + assert streams is not None + assert set(streams) == {1, 42} + assert streams[1].read_stream is True + assert streams[1].manage_stream is False + topics = streams[1].topics + assert topics is not None + assert set(topics) == {7} + assert topics[7].poll_messages is True + assert topics[7].manage_topic is False + assert streams[42].manage_stream is True + assert streams[42].topics is None + + def test_stream_id_above_u32_is_rejected(self): + """Test dict keys outside the u32 wire range fail at construction.""" + with pytest.raises(OverflowError): + Permissions(streams={2**32: StreamPermissions(read_stream=True)}) + with pytest.raises(OverflowError): + StreamPermissions(topics={2**32: TopicPermissions(read_topic=True)}) + + def test_permissions_equality(self): + """Test structurally identical Permissions compare equal.""" + first = Permissions( + global_=GlobalPermissions(read_streams=True), + streams={3: StreamPermissions(poll_messages=True)}, + ) + second = Permissions( + global_=GlobalPermissions(read_streams=True), + streams={3: StreamPermissions(poll_messages=True)}, + ) + different = Permissions(global_=GlobalPermissions(manage_streams=True)) + + assert first == second + assert first != different From 3d33ed6eaea4db00491992b7abad4ffa3d044e8d Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 23:14:48 +0800 Subject: [PATCH 02/13] feat(python): accept permissions in create_user and expose them on users 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. --- foreign/python/apache_iggy.pyi | 9 +- foreign/python/src/client.rs | 12 +- foreign/python/src/user.rs | 9 ++ foreign/python/tests/test_permissions.py | 153 +++++++++++++++++++++++ foreign/python/tests/test_user.py | 103 ++++++++------- foreign/python/tests/utils.py | 23 ++++ 6 files changed, 261 insertions(+), 48 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 4dab36b604..c0dbed2684 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -460,16 +460,16 @@ class IggyClient: username: builtins.str, password: builtins.str, status: UserStatus | None = None, + permissions: Permissions | None = None, ) -> collections.abc.Awaitable[UserInfoDetails]: r""" Create a new user. - The user is created without permissions. - Args: username: Username as `str`. password: Password as `str`. status: User status as `UserStatus | None`; defaults to `UserStatus.Active`. + permissions: Permissions as `Permissions | None`; the user has none when `None`. Returns: An awaitable that resolves to the created `UserInfoDetails`. @@ -1246,6 +1246,11 @@ class UserInfoDetails: r""" The username of the user. """ + @property + def permissions(self) -> Permissions | None: + r""" + The permissions of the user, or `None` when the user has none assigned. + """ @typing.final class UserStatus(enum.Enum): diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index b860f19b5d..07c078291f 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -34,6 +34,7 @@ use crate::consumer::{ IggyConsumer, py_delta_to_iggy_duration, }; use crate::identifier::PyIdentifier; +use crate::permissions::Permissions as PyPermissions; use crate::receive_message::{PollingStrategy, ReceiveMessage}; use crate::send_message::SendMessage; use crate::stream::StreamDetails; @@ -171,19 +172,18 @@ impl IggyClient { /// Create a new user. /// - /// The user is created without permissions. - /// /// Args: /// username: Username as `str`. /// password: Password as `str`. /// status: User status as `UserStatus | None`; defaults to `UserStatus.Active`. + /// permissions: Permissions as `Permissions | None`; the user has none when `None`. /// /// Returns: /// An awaitable that resolves to the created `UserInfoDetails`. /// /// Raises: /// PyRuntimeError: If an argument is invalid or the request fails. - #[pyo3(signature = (username, password, status=None))] + #[pyo3(signature = (username, password, status=None, permissions=None))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails]", imports=("collections.abc")))] fn create_user<'a>( &self, @@ -191,13 +191,17 @@ impl IggyClient { username: String, password: String, #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: Option, + #[gen_stub(override_type(type_repr = "Permissions | None"))] permissions: Option< + PyPermissions, + >, ) -> PyResult> { let status = status.map_or(UserStatus::Active, UserStatus::from); + let permissions = permissions.map(|permissions| permissions.inner); let inner = self.inner.clone(); future_into_py(py, async move { let user = inner - .create_user(&username, &password, status, None) + .create_user(&username, &password, status, permissions) .await .map_err(|e| PyErr::new::(e.to_string()))?; Ok(PyUserInfoDetails::from(user)) diff --git a/foreign/python/src/user.rs b/foreign/python/src/user.rs index 1f9e6ddb04..52a01a4b24 100644 --- a/foreign/python/src/user.rs +++ b/foreign/python/src/user.rs @@ -21,6 +21,8 @@ use iggy::prelude::{ use pyo3::prelude::*; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; +use crate::permissions::Permissions; + /// The status of a user account. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[gen_stub_pyclass_enum] @@ -128,4 +130,11 @@ impl UserInfoDetails { pub fn username(&self) -> String { self.inner.username.to_string() } + + /// The permissions of the user, or `None` when the user has none assigned. + #[getter] + #[gen_stub(override_return_type(type_repr = "Permissions | None"))] + pub fn permissions(&self) -> Option { + self.inner.permissions.clone().map(Permissions::from) + } } diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 295d56f487..461205ef45 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -21,11 +21,17 @@ from apache_iggy import ( GlobalPermissions, + IggyClient, Permissions, StreamPermissions, TopicPermissions, ) +from .utils import ( + login_fresh_client, + unique_credentials, +) + GLOBAL_PERMISSION_FLAGS = [ "manage_servers", "read_servers", @@ -174,3 +180,150 @@ def test_permissions_equality(self): assert first == second assert first != different + + +class TestCreateUserWithPermissions: + """Test create_user with the permissions argument.""" + + @pytest.mark.asyncio + async def test_create_user_without_permissions_has_none( + self, iggy_client: IggyClient, unique_name + ): + """Test a user created without permissions reports None.""" + username, password = unique_credentials(unique_name) + + created = await iggy_client.create_user(username, password) + assert created.permissions is None + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + assert fetched.permissions is None + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_create_user_with_global_permissions_round_trips( + self, iggy_client: IggyClient, unique_name + ): + """Test global permissions set at creation come back from get_user.""" + username, password = unique_credentials(unique_name) + permissions = Permissions( + global_=GlobalPermissions(read_users=True, read_streams=True) + ) + + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + assert created.permissions == permissions + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + assert fetched.permissions == permissions + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_create_user_with_nested_permissions_round_trips( + self, iggy_client: IggyClient, unique_name + ): + """Test per-stream and per-topic permissions survive the round trip.""" + username, password = unique_credentials(unique_name) + stream_name = unique_name(prefix="perm-stream-") + await iggy_client.create_stream(stream_name) + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + topic_name = unique_name(prefix="perm-topic-") + await iggy_client.create_topic(stream_name, topic_name, partitions_count=1) + topic = await iggy_client.get_topic(stream_name, topic_name) + assert topic is not None + + permissions = Permissions( + streams={ + stream.id: StreamPermissions( + read_stream=True, + poll_messages=True, + topics={topic.id: TopicPermissions(send_messages=True)}, + ) + } + ) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + assert created.permissions == permissions + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + fetched_permissions = fetched.permissions + assert fetched_permissions is not None + assert fetched_permissions == permissions + streams = fetched_permissions.streams + assert streams is not None + assert streams[stream.id].read_stream is True + topics = streams[stream.id].topics + assert topics is not None + assert topics[topic.id].send_messages is True + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_user_with_manage_streams_can_create_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test a granted global permission is enforced for the new user.""" + username, password = unique_credentials(unique_name) + permissions = Permissions(global_=GlobalPermissions(manage_streams=True)) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + + client = await login_fresh_client(username, password) + stream_name = unique_name(prefix="granted-") + await client.create_stream(stream_name) + + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_user_without_permissions_cannot_create_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test a user created without permissions is denied stream creation.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + client = await login_fresh_client(username, password) + with pytest.raises(RuntimeError): + await client.create_stream(unique_name(prefix="denied-")) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_per_stream_permission_is_scoped_to_that_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test read access to one stream does not leak to another stream.""" + readable_name = unique_name(prefix="readable-") + hidden_name = unique_name(prefix="hidden-") + await iggy_client.create_stream(readable_name) + await iggy_client.create_stream(hidden_name) + readable = await iggy_client.get_stream(readable_name) + assert readable is not None + + username, password = unique_credentials(unique_name) + permissions = Permissions( + streams={readable.id: StreamPermissions(read_stream=True)} + ) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + + client = await login_fresh_client(username, password) + visible = await client.get_stream(readable_name) + assert visible is not None + assert visible.id == readable.id + with pytest.raises(RuntimeError): + await client.get_stream(hidden_name) + + await iggy_client.delete_user(created.id) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 9e94a59350..e6fc609cae 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -17,21 +17,24 @@ import pytest -from apache_iggy import IggyClient, UserInfoDetails, UserStatus - -from .utils import get_server_config, wait_for_ping, wait_for_server - -# Server-side limits: usernames are 3-50 bytes, passwords 3-100 bytes. -MIN_USERNAME_BYTES = 3 -MAX_USERNAME_BYTES = 50 -MIN_PASSWORD_BYTES = 3 -MAX_PASSWORD_BYTES = 100 - +from apache_iggy import ( + GlobalPermissions, + IggyClient, + Permissions, + UserInfoDetails, + UserStatus, +) -def _unique_credentials(unique_name) -> tuple[str, str]: - username = unique_name(max_bytes=MAX_USERNAME_BYTES) - password = unique_name(max_bytes=MAX_PASSWORD_BYTES) - return username, password +from .utils import ( + MAX_PASSWORD_BYTES, + MAX_USERNAME_BYTES, + MIN_PASSWORD_BYTES, + MIN_USERNAME_BYTES, + get_server_config, + unique_credentials, + wait_for_ping, + wait_for_server, +) class TestCreateUser: @@ -40,7 +43,7 @@ class TestCreateUser: @pytest.mark.asyncio async def test_create_and_get_user(self, iggy_client: IggyClient, unique_name): """Test user creation returns details and the user is retrievable.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password, UserStatus.Active) assert isinstance(created, UserInfoDetails) @@ -66,7 +69,7 @@ async def test_create_user_defaults_to_active_status( self, iggy_client: IggyClient, unique_name ): """Test create_user without an explicit status creates an active user.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) assert created.status == UserStatus.Active @@ -76,7 +79,7 @@ async def test_create_user_defaults_to_active_status( @pytest.mark.asyncio async def test_create_inactive_user(self, iggy_client: IggyClient, unique_name): """Test create_user with UserStatus.Inactive creates an inactive user.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password, UserStatus.Inactive) assert created.status == UserStatus.Inactive @@ -90,7 +93,7 @@ async def test_create_inactive_user(self, iggy_client: IggyClient, unique_name): @pytest.mark.asyncio async def test_duplicate_username_fails(self, iggy_client: IggyClient, unique_name): """Test create_user rejects an already taken username.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) @@ -102,7 +105,7 @@ async def test_duplicate_username_fails(self, iggy_client: IggyClient, unique_na @pytest.mark.asyncio async def test_created_user_can_login(self, iggy_client: IggyClient, unique_name): """Test a freshly created user can authenticate with its credentials.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) host, port = get_server_config() @@ -118,7 +121,7 @@ async def test_inactive_user_cannot_login( self, iggy_client: IggyClient, unique_name ): """Test an inactive user is denied login even with correct credentials.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password, UserStatus.Inactive) host, port = get_server_config() @@ -134,7 +137,7 @@ async def test_deleted_user_cannot_login( self, iggy_client: IggyClient, unique_name ): """Test a deleted user cannot start a fresh authenticated session.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -225,7 +228,7 @@ async def test_get_nonexistent_user_returns_none( assert user_by_name is None # Deleting a freshly created user guarantees its id is vacant. - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -254,7 +257,7 @@ async def test_get_user_returns_same_result_when_called_repeatedly( self, iggy_client: IggyClient, unique_name ): """Test repeated get_user calls return the same user view.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) first = await iggy_client.get_user(username) @@ -276,8 +279,8 @@ async def test_get_users_lists_root_and_created_users( self, iggy_client: IggyClient, unique_name ): """Test get_users returns the root user and newly created users.""" - first_username, first_password = _unique_credentials(unique_name) - second_username, second_password = _unique_credentials(unique_name) + first_username, first_password = unique_credentials(unique_name) + second_username, second_password = unique_credentials(unique_name) first = await iggy_client.create_user(first_username, first_password) second = await iggy_client.create_user(second_username, second_password) @@ -305,7 +308,7 @@ class TestUpdateUser: @pytest.mark.asyncio async def test_update_username(self, iggy_client: IggyClient, unique_name): """Test update_user renames a user.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) new_username = unique_name(max_bytes=MAX_USERNAME_BYTES) created = await iggy_client.create_user(username, password) @@ -323,7 +326,7 @@ async def test_update_username(self, iggy_client: IggyClient, unique_name): @pytest.mark.asyncio async def test_update_status(self, iggy_client: IggyClient, unique_name): """Test update_user changes the user status.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) assert created.status == UserStatus.Active @@ -341,7 +344,7 @@ async def test_update_username_and_status_together( self, iggy_client: IggyClient, unique_name ): """Test update_user applies a new username and status in one call.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) new_username = unique_name(max_bytes=MAX_USERNAME_BYTES) created = await iggy_client.create_user(username, password) @@ -361,7 +364,7 @@ async def test_update_user_with_no_fields_is_a_noop( self, iggy_client: IggyClient, unique_name ): """Test the server permits an update with neither username nor status.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.update_user(created.id) @@ -378,7 +381,7 @@ async def test_update_user_applied_repeatedly_is_idempotent( self, iggy_client: IggyClient, unique_name ): """Test applying the same update twice succeeds and keeps the state.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.update_user(created.id, status=UserStatus.Inactive) @@ -396,8 +399,8 @@ async def test_update_username_to_existing_username_fails( self, iggy_client: IggyClient, unique_name ): """Test update_user rejects renaming to a username another user holds.""" - first_username, first_password = _unique_credentials(unique_name) - second_username, second_password = _unique_credentials(unique_name) + first_username, first_password = unique_credentials(unique_name) + second_username, second_password = unique_credentials(unique_name) first = await iggy_client.create_user(first_username, first_password) second = await iggy_client.create_user(second_username, second_password) @@ -433,7 +436,7 @@ async def test_update_user_rejects_out_of_bounds_username( self, iggy_client: IggyClient, unique_name, new_username ): """Test update_user rejects usernames outside the 3-50 byte range.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) with pytest.raises(RuntimeError): @@ -451,7 +454,7 @@ async def test_update_user_accepts_multibyte_username( self, iggy_client: IggyClient, unique_name ): """Test update_user accepts a non-ascii username within the byte limit.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) new_username = f"사용자{unique_name(min_bytes=4, max_bytes=8)}" @@ -470,7 +473,7 @@ class TestDeleteUser: @pytest.mark.asyncio async def test_delete_user_by_name(self, iggy_client: IggyClient, unique_name): """Test delete_user removes a user addressed by username.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) await iggy_client.create_user(username, password) await iggy_client.delete_user(username) @@ -482,7 +485,7 @@ async def test_delete_user_by_numeric_id( self, iggy_client: IggyClient, unique_name ): """Test delete_user removes a user addressed by numeric id.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -513,7 +516,7 @@ async def test_delete_root_user_fails( @pytest.mark.asyncio async def test_delete_user_twice_fails(self, iggy_client: IggyClient, unique_name): """Test deleting the same user twice fails on the second call.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -524,7 +527,7 @@ async def test_delete_user_twice_fails(self, iggy_client: IggyClient, unique_nam @pytest.mark.asyncio async def test_delete_inactive_user(self, iggy_client: IggyClient, unique_name): """Test an inactive user can be deleted.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password, UserStatus.Inactive) await iggy_client.delete_user(created.id) @@ -536,7 +539,7 @@ async def test_deleted_user_disappears_from_listings( self, iggy_client: IggyClient, unique_name ): """Test a deleted user is absent from both get_user and get_users.""" - username, password = _unique_credentials(unique_name) + username, password = unique_credentials(unique_name) created = await iggy_client.create_user(username, password) await iggy_client.delete_user(created.id) @@ -552,9 +555,25 @@ async def test_deleted_user_live_session_loses_identity( self, iggy_client: IggyClient, unique_name ): """Test a session owned by a deleted user can no longer act as that user.""" - # TODO: Re-enable once permission management lands in the Python SDK. - # With only unprivileged users available, this test cannot prove its claim. - pass + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user( + username, + password, + permissions=Permissions(global_=GlobalPermissions(read_users=True)), + ) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + await wait_for_ping(client) + await client.login_user(username, password) + users_before = await client.get_users() + assert any(user.id == created.id for user in users_before) + + await iggy_client.delete_user(created.id) + + with pytest.raises(RuntimeError): + await client.get_users() @pytest.mark.asyncio async def test_deleted_username_is_reusable_with_fresh_credentials( diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py index bbddc29de1..b37a53831e 100644 --- a/foreign/python/tests/utils.py +++ b/foreign/python/tests/utils.py @@ -26,6 +26,12 @@ from apache_iggy import IggyClient +# Server-side limits: usernames are 3-50 bytes, passwords 3-100 bytes. +MIN_USERNAME_BYTES = 3 +MAX_USERNAME_BYTES = 50 +MIN_PASSWORD_BYTES = 3 +MAX_PASSWORD_BYTES = 100 + def get_server_config() -> tuple[str, int]: """ @@ -107,3 +113,20 @@ async def wait_for_ping( f"Server not responding to ping after {timeout}s" ) from err await asyncio.sleep(interval) + + +def unique_credentials(unique_name) -> tuple[str, str]: + """Return a unique (username, password) pair within the server limits.""" + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + password = unique_name(max_bytes=MAX_PASSWORD_BYTES) + return username, password + + +async def login_fresh_client(username: str, password: str) -> IggyClient: + """Connect a new client to the configured server and log in.""" + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + await wait_for_ping(client) + await client.login_user(username, password) + return client From 1dd5c722911ba403ab96cc38e1eca457509a9051 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 23:15:17 +0800 Subject: [PATCH 03/13] feat(python): add update_permissions to IggyClient 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. --- foreign/python/apache_iggy.pyi | 22 +++++ foreign/python/src/client.rs | 38 +++++++++ foreign/python/tests/test_permissions.py | 100 +++++++++++++++++++++++ foreign/python/tests/test_user.py | 2 + 4 files changed, 162 insertions(+) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index c0dbed2684..20e9e1bdba 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -510,6 +510,28 @@ class IggyClient: Returns: An awaitable that resolves to `None` when the user is deleted. + Raises: + PyValueError: If a string identifier is invalid. + PyRuntimeError: If the request fails. + """ + def update_permissions( + self, + user_id: builtins.str | builtins.int, + permissions: Permissions | None = None, + ) -> collections.abc.Awaitable[None]: + r""" + Update the permissions of a user by unique ID or username. + + This is a full replacement: the given permissions overwrite the previous + ones, and `None` removes them entirely. + + Args: + user_id: User identifier as `str | int`. + permissions: New permissions as `Permissions | None`. + + Returns: + An awaitable that resolves to `None` when the permissions are updated. + Raises: PyValueError: If a string identifier is invalid. PyRuntimeError: If the request fails. diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 07c078291f..2f68205021 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -268,6 +268,44 @@ impl IggyClient { }) } + /// Update the permissions of a user by unique ID or username. + /// + /// This is a full replacement: the given permissions overwrite the previous + /// ones, and `None` removes them entirely. + /// + /// Args: + /// user_id: User identifier as `str | int`. + /// permissions: New permissions as `Permissions | None`. + /// + /// Returns: + /// An awaitable that resolves to `None` when the permissions are updated. + /// + /// Raises: + /// PyValueError: If a string identifier is invalid. + /// PyRuntimeError: If the request fails. + #[pyo3(signature = (user_id, permissions=None))] + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn update_permissions<'a>( + &self, + py: Python<'a>, + user_id: PyIdentifier, + #[gen_stub(override_type(type_repr = "Permissions | None"))] permissions: Option< + PyPermissions, + >, + ) -> PyResult> { + let user_id = Identifier::try_from(user_id)?; + let permissions = permissions.map(|permissions| permissions.inner); + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .update_permissions(&user_id, permissions) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(()) + }) + } + /// Connects the IggyClient to its service. /// Returns Ok(()) on successful connection or a PyRuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 461205ef45..8d555355c3 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -28,6 +28,7 @@ ) from .utils import ( + MAX_USERNAME_BYTES, login_fresh_client, unique_credentials, ) @@ -327,3 +328,102 @@ async def test_per_stream_permission_is_scoped_to_that_stream( await client.get_stream(hidden_name) await iggy_client.delete_user(created.id) + + +class TestUpdatePermissions: + """Test permission updates via update_permissions.""" + + @pytest.mark.asyncio + async def test_update_permissions_grants_and_round_trips( + self, iggy_client: IggyClient, unique_name + ): + """Test permissions granted after creation come back from get_user.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + assert created.permissions is None + + permissions = Permissions(global_=GlobalPermissions(read_streams=True)) + await iggy_client.update_permissions(created.id, permissions) + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + assert fetched.permissions == permissions + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_permissions_replaces_previous_permissions( + self, iggy_client: IggyClient, unique_name + ): + """Test update_permissions overwrites instead of merging.""" + username, password = unique_credentials(unique_name) + initial = Permissions( + global_=GlobalPermissions(read_streams=True, read_users=True) + ) + created = await iggy_client.create_user(username, password, permissions=initial) + + replacement = Permissions(global_=GlobalPermissions(read_servers=True)) + await iggy_client.update_permissions(created.id, replacement) + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + fetched_permissions = fetched.permissions + assert fetched_permissions is not None + assert fetched_permissions == replacement + assert fetched_permissions.global_.read_streams is False + assert fetched_permissions.global_.read_users is False + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_permissions_none_clears_permissions( + self, iggy_client: IggyClient, unique_name + ): + """Test update_permissions with None removes existing permissions.""" + username, password = unique_credentials(unique_name) + permissions = Permissions(global_=GlobalPermissions(read_streams=True)) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + + await iggy_client.update_permissions(created.id) + + fetched = await iggy_client.get_user(created.id) + assert fetched is not None + assert fetched.permissions is None + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_permissions_takes_effect_for_new_session( + self, iggy_client: IggyClient, unique_name + ): + """Test a permission granted after creation is enforced on next login.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + denied_client = await login_fresh_client(username, password) + with pytest.raises(RuntimeError): + await denied_client.create_stream(unique_name(prefix="before-grant-")) + + await iggy_client.update_permissions( + created.id, Permissions(global_=GlobalPermissions(manage_streams=True)) + ) + + granted_client = await login_fresh_client(username, password) + stream_name = unique_name(prefix="after-grant-") + await granted_client.create_stream(stream_name) + assert await iggy_client.get_stream(stream_name) is not None + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_permissions_nonexistent_user_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test update_permissions raises for a non-existent user.""" + with pytest.raises(RuntimeError): + await iggy_client.update_permissions( + unique_name(max_bytes=MAX_USERNAME_BYTES), + Permissions(global_=GlobalPermissions(read_streams=True)), + ) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index e6fc609cae..21ed29cea6 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -607,6 +607,7 @@ async def test_deleted_username_is_reusable_with_fresh_credentials( "create_user", "update_user", "delete_user", + "update_permissions", ], ) @pytest.mark.asyncio @@ -623,6 +624,7 @@ async def test_user_management_requires_connection_and_auth(method_name, unique_ "create_user": (username, "secret"), "update_user": (username,), "delete_user": (username,), + "update_permissions": (username,), } method = getattr(client, method_name) args = args_by_method[method_name] From 95507328bef8525e81602ab05908a7be64310fbb Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 23:15:41 +0800 Subject: [PATCH 04/13] feat(python): add change_password to IggyClient 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. --- foreign/python/apache_iggy.pyi | 21 +++++ foreign/python/src/client.rs | 33 ++++++++ foreign/python/tests/test_permissions.py | 100 +++++++++++++++++++++++ foreign/python/tests/test_user.py | 2 + 4 files changed, 156 insertions(+) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 20e9e1bdba..4d980766b6 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -536,6 +536,27 @@ class IggyClient: PyValueError: If a string identifier is invalid. PyRuntimeError: If the request fails. """ + def change_password( + self, + user_id: builtins.str | builtins.int, + current_password: builtins.str, + new_password: builtins.str, + ) -> collections.abc.Awaitable[None]: + r""" + Change the password of a user by unique ID or username. + + Args: + user_id: User identifier as `str | int`. + current_password: Current password as `str`. + new_password: New password as `str`. + + Returns: + An awaitable that resolves to `None` when the password is changed. + + Raises: + PyValueError: If a string identifier is invalid. + PyRuntimeError: If the current password is wrong or the request fails. + """ def connect(self) -> collections.abc.Awaitable[None]: r""" Connects the IggyClient to its service. diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 2f68205021..5bf57b84f3 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -306,6 +306,39 @@ impl IggyClient { }) } + /// Change the password of a user by unique ID or username. + /// + /// Args: + /// user_id: User identifier as `str | int`. + /// current_password: Current password as `str`. + /// new_password: New password as `str`. + /// + /// Returns: + /// An awaitable that resolves to `None` when the password is changed. + /// + /// Raises: + /// PyValueError: If a string identifier is invalid. + /// PyRuntimeError: If the current password is wrong or the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn change_password<'a>( + &self, + py: Python<'a>, + user_id: PyIdentifier, + current_password: String, + new_password: String, + ) -> PyResult> { + let user_id = Identifier::try_from(user_id)?; + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .change_password(&user_id, ¤t_password, &new_password) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(()) + }) + } + /// Connects the IggyClient to its service. /// Returns Ok(()) on successful connection or a PyRuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 8d555355c3..d86e93c268 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -28,9 +28,13 @@ ) from .utils import ( + MAX_PASSWORD_BYTES, MAX_USERNAME_BYTES, + MIN_PASSWORD_BYTES, + get_server_config, login_fresh_client, unique_credentials, + wait_for_ping, ) GLOBAL_PERMISSION_FLAGS = [ @@ -427,3 +431,99 @@ async def test_update_permissions_nonexistent_user_fails( unique_name(max_bytes=MAX_USERNAME_BYTES), Permissions(global_=GlobalPermissions(read_streams=True)), ) + + +class TestChangePassword: + """Test password changes via change_password.""" + + @pytest.mark.asyncio + async def test_change_password_swaps_credentials( + self, iggy_client: IggyClient, unique_name + ): + """Test the new password works for login and the old one is rejected.""" + username, password = unique_credentials(unique_name) + new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES) + created = await iggy_client.create_user(username, password) + + await iggy_client.change_password(created.id, password, new_password) + + host, port = get_server_config() + client = IggyClient(f"{host}:{port}") + await client.connect() + await wait_for_ping(client) + with pytest.raises(RuntimeError): + await client.login_user(username, password) + await client.login_user(username, new_password) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_change_password_rejects_wrong_current_password( + self, iggy_client: IggyClient, unique_name + ): + """Test change_password fails when the current password is wrong.""" + username = unique_name(max_bytes=MAX_USERNAME_BYTES) + # Keep one byte of headroom so the wrong password stays within the + # server limit and the failure is about the mismatch, not the length. + password = unique_name(max_bytes=MAX_PASSWORD_BYTES - 1) + created = await iggy_client.create_user(username, password) + + with pytest.raises(RuntimeError): + await iggy_client.change_password( + created.id, f"{password}x", unique_name(max_bytes=MAX_PASSWORD_BYTES) + ) + + client = await login_fresh_client(username, password) + await client.ping() + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_user_can_change_own_password( + self, iggy_client: IggyClient, unique_name + ): + """Test a user without permissions can change its own password.""" + username, password = unique_credentials(unique_name) + new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES) + created = await iggy_client.create_user(username, password) + + client = await login_fresh_client(username, password) + await client.change_password(created.id, password, new_password) + + relogin = await login_fresh_client(username, new_password) + await relogin.ping() + + await iggy_client.delete_user(created.id) + + @pytest.mark.parametrize( + "new_password", + ["a" * (MIN_PASSWORD_BYTES - 1), "a" * (MAX_PASSWORD_BYTES + 1)], + ids=["too-short", "too-long"], + ) + @pytest.mark.asyncio + async def test_change_password_rejects_out_of_bounds_new_password( + self, iggy_client: IggyClient, unique_name, new_password + ): + """Test change_password rejects new passwords outside the 3-100 byte range.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + with pytest.raises(RuntimeError): + await iggy_client.change_password(created.id, password, new_password) + + client = await login_fresh_client(username, password) + await client.ping() + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_change_password_nonexistent_user_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test change_password raises for a non-existent user.""" + with pytest.raises(RuntimeError): + await iggy_client.change_password( + unique_name(max_bytes=MAX_USERNAME_BYTES), + unique_name(max_bytes=MAX_PASSWORD_BYTES), + unique_name(max_bytes=MAX_PASSWORD_BYTES), + ) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 21ed29cea6..66eb42088c 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -608,6 +608,7 @@ async def test_deleted_username_is_reusable_with_fresh_credentials( "update_user", "delete_user", "update_permissions", + "change_password", ], ) @pytest.mark.asyncio @@ -625,6 +626,7 @@ async def test_user_management_requires_connection_and_auth(method_name, unique_ "update_user": (username,), "delete_user": (username,), "update_permissions": (username,), + "change_password": (username, "secret", "secret2"), } method = getattr(client, method_name) args = args_by_method[method_name] From 22bc32c7f15455bf782643e5d912f65c00d31c83 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 21 Jul 2026 23:16:00 +0800 Subject: [PATCH 05/13] feat(python): add logout_user to IggyClient 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. --- foreign/python/apache_iggy.pyi | 10 ++++++ foreign/python/src/client.rs | 20 +++++++++++ foreign/python/tests/test_permissions.py | 46 ++++++++++++++++++++++++ foreign/python/tests/test_user.py | 2 ++ 4 files changed, 78 insertions(+) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 4d980766b6..728b90a6b7 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -557,6 +557,16 @@ class IggyClient: PyValueError: If a string identifier is invalid. PyRuntimeError: If the current password is wrong or the request fails. """ + def logout_user(self) -> collections.abc.Awaitable[None]: + r""" + Log out the currently authenticated user. + + Returns: + An awaitable that resolves to `None` when the user is logged out. + + Raises: + PyRuntimeError: If the request fails. + """ def connect(self) -> collections.abc.Awaitable[None]: r""" Connects the IggyClient to its service. diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 5bf57b84f3..31ce5b1436 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -339,6 +339,26 @@ impl IggyClient { }) } + /// Log out the currently authenticated user. + /// + /// Returns: + /// An awaitable that resolves to `None` when the user is logged out. + /// + /// Raises: + /// PyRuntimeError: If the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn logout_user<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .logout_user() + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(()) + }) + } + /// Connects the IggyClient to its service. /// Returns Ok(()) on successful connection or a PyRuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index d86e93c268..6b37aa145b 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -527,3 +527,49 @@ async def test_change_password_nonexistent_user_fails( unique_name(max_bytes=MAX_PASSWORD_BYTES), unique_name(max_bytes=MAX_PASSWORD_BYTES), ) + + +class TestLogoutUser: + """Test session termination via logout_user.""" + + @pytest.mark.asyncio + async def test_logout_user_ends_the_session( + self, iggy_client: IggyClient, unique_name + ): + """Test authenticated calls fail after logout.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user( + username, + password, + permissions=Permissions(global_=GlobalPermissions(read_users=True)), + ) + + client = await login_fresh_client(username, password) + assert await client.get_user(username) is not None + + await client.logout_user() + + with pytest.raises(RuntimeError): + await client.get_user(username) + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_login_after_logout_restores_the_session( + self, iggy_client: IggyClient, unique_name + ): + """Test the same client can log in again after logging out.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user( + username, + password, + permissions=Permissions(global_=GlobalPermissions(read_users=True)), + ) + + client = await login_fresh_client(username, password) + await client.logout_user() + + await client.login_user(username, password) + assert await client.get_user(username) is not None + + await iggy_client.delete_user(created.id) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 66eb42088c..8b71ba582a 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -609,6 +609,7 @@ async def test_deleted_username_is_reusable_with_fresh_credentials( "delete_user", "update_permissions", "change_password", + "logout_user", ], ) @pytest.mark.asyncio @@ -627,6 +628,7 @@ async def test_user_management_requires_connection_and_auth(method_name, unique_ "delete_user": (username,), "update_permissions": (username,), "change_password": (username, "secret", "secret2"), + "logout_user": (), } method = getattr(client, method_name) args = args_by_method[method_name] From 41497daccfcb432c121167f3d28875ce694eda65 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 23 Jul 2026 23:49:36 +0800 Subject: [PATCH 06/13] test(python): drop redundant all-flags permission tests --- foreign/python/tests/test_permissions.py | 26 ------------------------ 1 file changed, 26 deletions(-) diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 6b37aa145b..53e80de2e9 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -95,15 +95,6 @@ def test_each_global_permission_flag_round_trips_alone(self, flag): for name in GLOBAL_PERMISSION_FLAGS: assert getattr(global_permissions, name) is (name == flag) - def test_all_global_permission_flags_set_together(self): - """Test all flags can be enabled at once.""" - global_permissions = GlobalPermissions( - **dict.fromkeys(GLOBAL_PERMISSION_FLAGS, True) - ) - - for name in GLOBAL_PERMISSION_FLAGS: - assert getattr(global_permissions, name) is True - @pytest.mark.parametrize("flag", STREAM_PERMISSION_FLAGS) def test_each_stream_permission_flag_round_trips_alone(self, flag): """Test setting one flag flips only that flag.""" @@ -113,14 +104,6 @@ def test_each_stream_permission_flag_round_trips_alone(self, flag): for name in STREAM_PERMISSION_FLAGS: assert getattr(stream_permissions, name) is (name == flag) - def test_all_stream_permission_flags_set_together(self): - """Test all flags can be enabled at once.""" - flags: dict[str, Any] = dict.fromkeys(STREAM_PERMISSION_FLAGS, True) - stream_permissions = StreamPermissions(**flags) - - for name in STREAM_PERMISSION_FLAGS: - assert getattr(stream_permissions, name) is True - @pytest.mark.parametrize("flag", TOPIC_PERMISSION_FLAGS) def test_each_topic_permission_flag_round_trips_alone(self, flag): """Test setting one flag flips only that flag.""" @@ -129,15 +112,6 @@ def test_each_topic_permission_flag_round_trips_alone(self, flag): for name in TOPIC_PERMISSION_FLAGS: assert getattr(topic_permissions, name) is (name == flag) - def test_all_topic_permission_flags_set_together(self): - """Test all flags can be enabled at once.""" - topic_permissions = TopicPermissions( - **dict.fromkeys(TOPIC_PERMISSION_FLAGS, True) - ) - - for name in TOPIC_PERMISSION_FLAGS: - assert getattr(topic_permissions, name) is True - def test_nested_stream_and_topic_permissions_are_preserved(self): """Test stream and topic permission dicts survive construction.""" permissions = Permissions( From da2c17f537e3005aed8dde52bc86a5cd2a8d76e2 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 23 Jul 2026 23:49:47 +0800 Subject: [PATCH 07/13] test(python): move no-permissions check into test_create_and_get_user --- foreign/python/tests/test_permissions.py | 16 ---------------- foreign/python/tests/test_user.py | 2 ++ 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 53e80de2e9..e1d49b29dc 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -164,22 +164,6 @@ def test_permissions_equality(self): class TestCreateUserWithPermissions: """Test create_user with the permissions argument.""" - @pytest.mark.asyncio - async def test_create_user_without_permissions_has_none( - self, iggy_client: IggyClient, unique_name - ): - """Test a user created without permissions reports None.""" - username, password = unique_credentials(unique_name) - - created = await iggy_client.create_user(username, password) - assert created.permissions is None - - fetched = await iggy_client.get_user(created.id) - assert fetched is not None - assert fetched.permissions is None - - await iggy_client.delete_user(created.id) - @pytest.mark.asyncio async def test_create_user_with_global_permissions_round_trips( self, iggy_client: IggyClient, unique_name diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 8b71ba582a..ff5e50cb77 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -50,12 +50,14 @@ async def test_create_and_get_user(self, iggy_client: IggyClient, unique_name): assert isinstance(created.id, int) assert created.username == username assert created.status == UserStatus.Active + assert created.permissions is None user_by_name = await iggy_client.get_user(username) assert user_by_name is not None assert user_by_name.id == created.id assert user_by_name.username == username assert user_by_name.status == UserStatus.Active + assert user_by_name.permissions is None user_by_id = await iggy_client.get_user(created.id) assert user_by_id is not None From 47fcb74f1a3c284e4281c4f18c7bfb48ae783aa2 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 23 Jul 2026 23:49:53 +0800 Subject: [PATCH 08/13] test(python): cover permission update authorization and double logout --- foreign/python/tests/test_permissions.py | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index e1d49b29dc..fe2994e395 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -379,6 +379,76 @@ async def test_update_permissions_takes_effect_for_new_session( await iggy_client.delete_user(created.id) + @pytest.mark.asyncio + async def test_user_without_manage_users_cannot_update_permissions( + self, iggy_client: IggyClient, unique_name + ): + """Test a user lacking manage_users is denied updating another user.""" + actor_username, actor_password = unique_credentials(unique_name) + actor = await iggy_client.create_user(actor_username, actor_password) + target_username, target_password = unique_credentials(unique_name) + target = await iggy_client.create_user(target_username, target_password) + + actor_client = await login_fresh_client(actor_username, actor_password) + with pytest.raises(RuntimeError): + await actor_client.update_permissions( + target.id, Permissions(global_=GlobalPermissions(read_streams=True)) + ) + + await iggy_client.delete_user(actor.id) + await iggy_client.delete_user(target.id) + + @pytest.mark.asyncio + async def test_user_with_manage_users_can_update_permissions( + self, iggy_client: IggyClient, unique_name + ): + """Test a user granted manage_users can update another user.""" + actor_username, actor_password = unique_credentials(unique_name) + actor = await iggy_client.create_user( + actor_username, + actor_password, + permissions=Permissions(global_=GlobalPermissions(manage_users=True)), + ) + target_username, target_password = unique_credentials(unique_name) + target = await iggy_client.create_user(target_username, target_password) + + granted = Permissions(global_=GlobalPermissions(read_streams=True)) + actor_client = await login_fresh_client(actor_username, actor_password) + await actor_client.update_permissions(target.id, granted) + + fetched = await iggy_client.get_user(target.id) + assert fetched is not None + assert fetched.permissions == granted + + await iggy_client.delete_user(actor.id) + await iggy_client.delete_user(target.id) + + @pytest.mark.asyncio + async def test_failed_update_leaves_target_permissions_unchanged( + self, iggy_client: IggyClient, unique_name + ): + """Test a denied update does not alter the target's permissions.""" + actor_username, actor_password = unique_credentials(unique_name) + actor = await iggy_client.create_user(actor_username, actor_password) + target_username, target_password = unique_credentials(unique_name) + initial = Permissions(global_=GlobalPermissions(read_users=True)) + target = await iggy_client.create_user( + target_username, target_password, permissions=initial + ) + + actor_client = await login_fresh_client(actor_username, actor_password) + with pytest.raises(RuntimeError): + await actor_client.update_permissions( + target.id, Permissions(global_=GlobalPermissions(manage_servers=True)) + ) + + fetched = await iggy_client.get_user(target.id) + assert fetched is not None + assert fetched.permissions == initial + + await iggy_client.delete_user(actor.id) + await iggy_client.delete_user(target.id) + @pytest.mark.asyncio async def test_update_permissions_nonexistent_user_fails( self, iggy_client: IggyClient, unique_name @@ -512,6 +582,20 @@ async def test_logout_user_ends_the_session( await iggy_client.delete_user(created.id) + @pytest.mark.asyncio + async def test_logout_twice_fails(self, iggy_client: IggyClient, unique_name): + """Test a second logout on the same session raises.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + client = await login_fresh_client(username, password) + await client.logout_user() + + with pytest.raises(RuntimeError): + await client.logout_user() + + await iggy_client.delete_user(created.id) + @pytest.mark.asyncio async def test_login_after_logout_restores_the_session( self, iggy_client: IggyClient, unique_name From 44b214ce6d7f7915681f453d8191a9659167523b Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 26 Jul 2026 16:20:56 +0800 Subject: [PATCH 09/13] refactor(python): rename global_ to global_permissions The trailing underscore avoided the Python keyword collision but reads poorly at call sites. Reviewer settled on the explicit long name. --- foreign/python/apache_iggy.pyi | 7 +-- foreign/python/src/permissions.rs | 16 +++--- foreign/python/tests/test_permissions.py | 63 +++++++++++++++--------- foreign/python/tests/test_user.py | 4 +- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 728b90a6b7..2fcd0fd68c 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -967,7 +967,7 @@ class Permissions: optionally extended by per-stream permissions. """ @property - def global_(self) -> GlobalPermissions: + def global_permissions(self) -> GlobalPermissions: r""" The global permissions, applied to all streams. """ @@ -979,14 +979,15 @@ class Permissions: def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, - global_: GlobalPermissions | None = None, + global_permissions: GlobalPermissions | None = None, streams: dict[int, StreamPermissions] | None = None, ) -> Permissions: r""" Create permissions from global permissions and optional per-stream permissions. Args: - global_: Global permissions as `GlobalPermissions | None`; defaults to all denied. + global_permissions: Global permissions as `GlobalPermissions | None`; + defaults to all denied. streams: Per-stream permissions keyed by stream ID as `dict[int, StreamPermissions] | None`. """ diff --git a/foreign/python/src/permissions.rs b/foreign/python/src/permissions.rs index 93ea6dc48a..de67221193 100644 --- a/foreign/python/src/permissions.rs +++ b/foreign/python/src/permissions.rs @@ -44,21 +44,23 @@ impl Permissions { /// Create permissions from global permissions and optional per-stream permissions. /// /// Args: - /// global_: Global permissions as `GlobalPermissions | None`; defaults to all denied. + /// global_permissions: Global permissions as `GlobalPermissions | None`; + /// defaults to all denied. /// streams: Per-stream permissions keyed by stream ID as /// `dict[int, StreamPermissions] | None`. #[new] - #[pyo3(signature = (global_=None, streams=None))] + #[pyo3(signature = (global_permissions=None, streams=None))] fn new( - #[gen_stub(override_type(type_repr = "GlobalPermissions | None"))] global_: Option< - GlobalPermissions, - >, + #[gen_stub(override_type(type_repr = "GlobalPermissions | None"))] + global_permissions: Option, #[gen_stub(override_type(type_repr = "dict[int, StreamPermissions] | None"))] streams: Option>, ) -> Self { Self { inner: RustPermissions { - global: global_.map(|global| global.inner).unwrap_or_default(), + global: global_permissions + .map(|global| global.inner) + .unwrap_or_default(), streams: streams.map(|streams| { streams .into_iter() @@ -71,7 +73,7 @@ impl Permissions { /// The global permissions, applied to all streams. #[getter] - fn global_(&self) -> GlobalPermissions { + fn global_permissions(&self) -> GlobalPermissions { GlobalPermissions { inner: self.inner.global.clone(), } diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index fe2994e395..3b1643b0a3 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -75,7 +75,7 @@ def test_default_permissions_deny_everything(self): permissions = Permissions() assert permissions.streams is None - global_permissions = permissions.global_ + global_permissions = permissions.global_permissions assert global_permissions.manage_servers is False assert global_permissions.read_servers is False assert global_permissions.manage_users is False @@ -115,7 +115,7 @@ def test_each_topic_permission_flag_round_trips_alone(self, flag): def test_nested_stream_and_topic_permissions_are_preserved(self): """Test stream and topic permission dicts survive construction.""" permissions = Permissions( - global_=GlobalPermissions(read_servers=True), + global_permissions=GlobalPermissions(read_servers=True), streams={ 1: StreamPermissions( read_stream=True, @@ -148,14 +148,16 @@ def test_stream_id_above_u32_is_rejected(self): def test_permissions_equality(self): """Test structurally identical Permissions compare equal.""" first = Permissions( - global_=GlobalPermissions(read_streams=True), + global_permissions=GlobalPermissions(read_streams=True), streams={3: StreamPermissions(poll_messages=True)}, ) second = Permissions( - global_=GlobalPermissions(read_streams=True), + global_permissions=GlobalPermissions(read_streams=True), streams={3: StreamPermissions(poll_messages=True)}, ) - different = Permissions(global_=GlobalPermissions(manage_streams=True)) + different = Permissions( + global_permissions=GlobalPermissions(manage_streams=True) + ) assert first == second assert first != different @@ -171,7 +173,7 @@ async def test_create_user_with_global_permissions_round_trips( """Test global permissions set at creation come back from get_user.""" username, password = unique_credentials(unique_name) permissions = Permissions( - global_=GlobalPermissions(read_users=True, read_streams=True) + global_permissions=GlobalPermissions(read_users=True, read_streams=True) ) created = await iggy_client.create_user( @@ -234,7 +236,9 @@ async def test_user_with_manage_streams_can_create_stream( ): """Test a granted global permission is enforced for the new user.""" username, password = unique_credentials(unique_name) - permissions = Permissions(global_=GlobalPermissions(manage_streams=True)) + permissions = Permissions( + global_permissions=GlobalPermissions(manage_streams=True) + ) created = await iggy_client.create_user( username, password, permissions=permissions ) @@ -304,7 +308,9 @@ async def test_update_permissions_grants_and_round_trips( created = await iggy_client.create_user(username, password) assert created.permissions is None - permissions = Permissions(global_=GlobalPermissions(read_streams=True)) + permissions = Permissions( + global_permissions=GlobalPermissions(read_streams=True) + ) await iggy_client.update_permissions(created.id, permissions) fetched = await iggy_client.get_user(created.id) @@ -320,11 +326,13 @@ async def test_update_permissions_replaces_previous_permissions( """Test update_permissions overwrites instead of merging.""" username, password = unique_credentials(unique_name) initial = Permissions( - global_=GlobalPermissions(read_streams=True, read_users=True) + global_permissions=GlobalPermissions(read_streams=True, read_users=True) ) created = await iggy_client.create_user(username, password, permissions=initial) - replacement = Permissions(global_=GlobalPermissions(read_servers=True)) + replacement = Permissions( + global_permissions=GlobalPermissions(read_servers=True) + ) await iggy_client.update_permissions(created.id, replacement) fetched = await iggy_client.get_user(created.id) @@ -332,8 +340,8 @@ async def test_update_permissions_replaces_previous_permissions( fetched_permissions = fetched.permissions assert fetched_permissions is not None assert fetched_permissions == replacement - assert fetched_permissions.global_.read_streams is False - assert fetched_permissions.global_.read_users is False + assert fetched_permissions.global_permissions.read_streams is False + assert fetched_permissions.global_permissions.read_users is False await iggy_client.delete_user(created.id) @@ -343,7 +351,9 @@ async def test_update_permissions_none_clears_permissions( ): """Test update_permissions with None removes existing permissions.""" username, password = unique_credentials(unique_name) - permissions = Permissions(global_=GlobalPermissions(read_streams=True)) + permissions = Permissions( + global_permissions=GlobalPermissions(read_streams=True) + ) created = await iggy_client.create_user( username, password, permissions=permissions ) @@ -369,7 +379,8 @@ async def test_update_permissions_takes_effect_for_new_session( await denied_client.create_stream(unique_name(prefix="before-grant-")) await iggy_client.update_permissions( - created.id, Permissions(global_=GlobalPermissions(manage_streams=True)) + created.id, + Permissions(global_permissions=GlobalPermissions(manage_streams=True)), ) granted_client = await login_fresh_client(username, password) @@ -392,7 +403,8 @@ async def test_user_without_manage_users_cannot_update_permissions( actor_client = await login_fresh_client(actor_username, actor_password) with pytest.raises(RuntimeError): await actor_client.update_permissions( - target.id, Permissions(global_=GlobalPermissions(read_streams=True)) + target.id, + Permissions(global_permissions=GlobalPermissions(read_streams=True)), ) await iggy_client.delete_user(actor.id) @@ -407,12 +419,14 @@ async def test_user_with_manage_users_can_update_permissions( actor = await iggy_client.create_user( actor_username, actor_password, - permissions=Permissions(global_=GlobalPermissions(manage_users=True)), + permissions=Permissions( + global_permissions=GlobalPermissions(manage_users=True) + ), ) target_username, target_password = unique_credentials(unique_name) target = await iggy_client.create_user(target_username, target_password) - granted = Permissions(global_=GlobalPermissions(read_streams=True)) + granted = Permissions(global_permissions=GlobalPermissions(read_streams=True)) actor_client = await login_fresh_client(actor_username, actor_password) await actor_client.update_permissions(target.id, granted) @@ -431,7 +445,7 @@ async def test_failed_update_leaves_target_permissions_unchanged( actor_username, actor_password = unique_credentials(unique_name) actor = await iggy_client.create_user(actor_username, actor_password) target_username, target_password = unique_credentials(unique_name) - initial = Permissions(global_=GlobalPermissions(read_users=True)) + initial = Permissions(global_permissions=GlobalPermissions(read_users=True)) target = await iggy_client.create_user( target_username, target_password, permissions=initial ) @@ -439,7 +453,8 @@ async def test_failed_update_leaves_target_permissions_unchanged( actor_client = await login_fresh_client(actor_username, actor_password) with pytest.raises(RuntimeError): await actor_client.update_permissions( - target.id, Permissions(global_=GlobalPermissions(manage_servers=True)) + target.id, + Permissions(global_permissions=GlobalPermissions(manage_servers=True)), ) fetched = await iggy_client.get_user(target.id) @@ -457,7 +472,7 @@ async def test_update_permissions_nonexistent_user_fails( with pytest.raises(RuntimeError): await iggy_client.update_permissions( unique_name(max_bytes=MAX_USERNAME_BYTES), - Permissions(global_=GlobalPermissions(read_streams=True)), + Permissions(global_permissions=GlobalPermissions(read_streams=True)), ) @@ -569,7 +584,9 @@ async def test_logout_user_ends_the_session( created = await iggy_client.create_user( username, password, - permissions=Permissions(global_=GlobalPermissions(read_users=True)), + permissions=Permissions( + global_permissions=GlobalPermissions(read_users=True) + ), ) client = await login_fresh_client(username, password) @@ -605,7 +622,9 @@ async def test_login_after_logout_restores_the_session( created = await iggy_client.create_user( username, password, - permissions=Permissions(global_=GlobalPermissions(read_users=True)), + permissions=Permissions( + global_permissions=GlobalPermissions(read_users=True) + ), ) client = await login_fresh_client(username, password) diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index ff5e50cb77..687f2520b7 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -561,7 +561,9 @@ async def test_deleted_user_live_session_loses_identity( created = await iggy_client.create_user( username, password, - permissions=Permissions(global_=GlobalPermissions(read_users=True)), + permissions=Permissions( + global_permissions=GlobalPermissions(read_users=True) + ), ) host, port = get_server_config() From 1ff9e5f85d9bd21782d9d51685945718e51f7430 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 26 Jul 2026 16:21:43 +0800 Subject: [PATCH 10/13] fix(python): normalize empty permission maps to None 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. --- foreign/python/apache_iggy.pyi | 6 ++++-- foreign/python/src/permissions.rs | 26 ++++++++++++++++-------- foreign/python/tests/test_permissions.py | 15 ++++++++++++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 2fcd0fd68c..5ec2b816ba 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -989,7 +989,8 @@ class Permissions: global_permissions: Global permissions as `GlobalPermissions | None`; defaults to all denied. streams: Per-stream permissions keyed by stream ID as - `dict[int, StreamPermissions] | None`. + `dict[int, StreamPermissions] | None`; an empty dict is + treated as `None`. """ class PollingStrategy: @@ -1153,7 +1154,8 @@ class StreamPermissions: poll_messages: Allow polling messages from the stream. send_messages: Allow sending messages to the stream. topics: Per-topic permissions keyed by topic ID as - `dict[int, TopicPermissions] | None`. + `dict[int, TopicPermissions] | None`; an empty dict is + treated as `None`. """ @typing.final diff --git a/foreign/python/src/permissions.rs b/foreign/python/src/permissions.rs index de67221193..7dc0727328 100644 --- a/foreign/python/src/permissions.rs +++ b/foreign/python/src/permissions.rs @@ -47,7 +47,8 @@ impl Permissions { /// global_permissions: Global permissions as `GlobalPermissions | None`; /// defaults to all denied. /// streams: Per-stream permissions keyed by stream ID as - /// `dict[int, StreamPermissions] | None`. + /// `dict[int, StreamPermissions] | None`; an empty dict is + /// treated as `None`. #[new] #[pyo3(signature = (global_permissions=None, streams=None))] fn new( @@ -61,12 +62,16 @@ impl Permissions { global: global_permissions .map(|global| global.inner) .unwrap_or_default(), - streams: streams.map(|streams| { - streams - .into_iter() - .map(|(stream_id, stream)| (stream_id as usize, stream.inner)) - .collect() - }), + // The wire format encodes an empty map the same as no map, so + // normalize here to keep round-trip equality. + streams: streams + .filter(|streams| !streams.is_empty()) + .map(|streams| { + streams + .into_iter() + .map(|(stream_id, stream)| (stream_id as usize, stream.inner)) + .collect() + }), }, } } @@ -249,7 +254,8 @@ impl StreamPermissions { /// poll_messages: Allow polling messages from the stream. /// send_messages: Allow sending messages to the stream. /// topics: Per-topic permissions keyed by topic ID as - /// `dict[int, TopicPermissions] | None`. + /// `dict[int, TopicPermissions] | None`; an empty dict is + /// treated as `None`. #[new] #[pyo3(signature = ( manage_stream=false, @@ -279,7 +285,9 @@ impl StreamPermissions { read_topics, poll_messages, send_messages, - topics: topics.map(|topics| { + // The wire format encodes an empty map the same as no map, so + // normalize here to keep round-trip equality. + topics: topics.filter(|topics| !topics.is_empty()).map(|topics| { topics .into_iter() .map(|(topic_id, topic)| (topic_id as usize, topic.inner)) diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 3b1643b0a3..be8baace22 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -138,6 +138,13 @@ def test_nested_stream_and_topic_permissions_are_preserved(self): assert streams[42].manage_stream is True assert streams[42].topics is None + def test_empty_permission_dicts_normalize_to_none(self): + """Test empty stream and topic dicts are equivalent to None.""" + assert Permissions(streams={}).streams is None + assert Permissions(streams={}) == Permissions() + assert StreamPermissions(topics={}).topics is None + assert StreamPermissions(topics={}) == StreamPermissions() + def test_stream_id_above_u32_is_rejected(self): """Test dict keys outside the u32 wire range fail at construction.""" with pytest.raises(OverflowError): @@ -173,7 +180,8 @@ async def test_create_user_with_global_permissions_round_trips( """Test global permissions set at creation come back from get_user.""" username, password = unique_credentials(unique_name) permissions = Permissions( - global_permissions=GlobalPermissions(read_users=True, read_streams=True) + global_permissions=GlobalPermissions(read_users=True, read_streams=True), + streams={}, ) created = await iggy_client.create_user( @@ -183,7 +191,10 @@ async def test_create_user_with_global_permissions_round_trips( fetched = await iggy_client.get_user(created.id) assert fetched is not None - assert fetched.permissions == permissions + fetched_permissions = fetched.permissions + assert fetched_permissions is not None + assert fetched_permissions == permissions + assert fetched_permissions.streams is None await iggy_client.delete_user(created.id) From 306a0dca2494d78d8706c0c013ee4ca528cdc62e Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 26 Jul 2026 16:22:47 +0800 Subject: [PATCH 11/13] refactor(python): make permission arguments explicit 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. --- foreign/python/apache_iggy.pyi | 7 ++++--- foreign/python/src/client.rs | 2 +- foreign/python/src/permissions.rs | 3 +++ foreign/python/tests/test_permissions.py | 2 +- foreign/python/tests/test_user.py | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5ec2b816ba..6c74f3db6d 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -369,6 +369,7 @@ class GlobalPermissions: def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, + *, manage_servers: builtins.bool = False, read_servers: builtins.bool = False, manage_users: builtins.bool = False, @@ -515,9 +516,7 @@ class IggyClient: PyRuntimeError: If the request fails. """ def update_permissions( - self, - user_id: builtins.str | builtins.int, - permissions: Permissions | None = None, + self, user_id: builtins.str | builtins.int, permissions: Permissions | None ) -> collections.abc.Awaitable[None]: r""" Update the permissions of a user by unique ID or username. @@ -1135,6 +1134,7 @@ class StreamPermissions: def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, + *, manage_stream: builtins.bool = False, read_stream: builtins.bool = False, manage_topics: builtins.bool = False, @@ -1242,6 +1242,7 @@ class TopicPermissions: def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, + *, manage_topic: builtins.bool = False, read_topic: builtins.bool = False, poll_messages: builtins.bool = False, diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 31ce5b1436..a7f0cfd514 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -283,7 +283,7 @@ impl IggyClient { /// Raises: /// PyValueError: If a string identifier is invalid. /// PyRuntimeError: If the request fails. - #[pyo3(signature = (user_id, permissions=None))] + #[pyo3(signature = (user_id, permissions))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn update_permissions<'a>( &self, diff --git a/foreign/python/src/permissions.rs b/foreign/python/src/permissions.rs index 7dc0727328..86dc0fef5c 100644 --- a/foreign/python/src/permissions.rs +++ b/foreign/python/src/permissions.rs @@ -131,6 +131,7 @@ impl GlobalPermissions { /// send_messages: Allow sending messages to all streams. #[new] #[pyo3(signature = ( + *, manage_servers=false, read_servers=false, manage_users=false, @@ -258,6 +259,7 @@ impl StreamPermissions { /// treated as `None`. #[new] #[pyo3(signature = ( + *, manage_stream=false, read_stream=false, manage_topics=false, @@ -374,6 +376,7 @@ impl TopicPermissions { /// send_messages: Allow sending messages to the topic. #[new] #[pyo3(signature = ( + *, manage_topic=false, read_topic=false, poll_messages=false, diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index be8baace22..9411bff231 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -369,7 +369,7 @@ async def test_update_permissions_none_clears_permissions( username, password, permissions=permissions ) - await iggy_client.update_permissions(created.id) + await iggy_client.update_permissions(created.id, None) fetched = await iggy_client.get_user(created.id) assert fetched is not None diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index 687f2520b7..a15b714b1d 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -630,7 +630,7 @@ async def test_user_management_requires_connection_and_auth(method_name, unique_ "create_user": (username, "secret"), "update_user": (username,), "delete_user": (username,), - "update_permissions": (username,), + "update_permissions": (username, None), "change_password": (username, "secret", "secret2"), "logout_user": (), } From aff6f0fbbb66af888588aaeb6f43f354965d2d83 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 26 Jul 2026 17:04:09 +0800 Subject: [PATCH 12/13] docs(python): describe permission inheritance across levels 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. --- foreign/python/apache_iggy.pyi | 83 +++++++++++++++++++++++-------- foreign/python/src/permissions.rs | 83 +++++++++++++++++++++++-------- 2 files changed, 122 insertions(+), 44 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 6c74f3db6d..a6bb3035b3 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -339,7 +339,8 @@ class GlobalPermissions: @property def manage_streams(self) -> builtins.bool: r""" - Whether managing all streams is allowed; includes `manage_topics`. + Whether managing all streams is allowed; includes `read_streams` and + `manage_topics`. """ @property def read_streams(self) -> builtins.bool: @@ -349,17 +350,20 @@ class GlobalPermissions: @property def manage_topics(self) -> builtins.bool: r""" - Whether managing all topics is allowed; includes `read_topics`. + Whether managing all topics is allowed; includes `read_topics` and + `send_messages`. """ @property def read_topics(self) -> builtins.bool: r""" - Whether reading all topics and consumer groups is allowed. + Whether reading all topics and managing consumer groups is allowed; + includes `poll_messages`. """ @property def poll_messages(self) -> builtins.bool: r""" - Whether polling messages from all streams is allowed. + Whether polling messages from all streams and managing consumer + offsets is allowed. """ @property def send_messages(self) -> builtins.bool: @@ -384,16 +388,25 @@ class GlobalPermissions: r""" Create global permissions. Every flag defaults to `False`. + The `includes` notes below are transitive: a flag also grants everything + its included flags grant. For example `manage_streams` includes + `manage_topics`, and through it `read_topics`, `poll_messages`, and + `send_messages`. + 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`. + manage_streams: Allow managing all streams; includes `read_streams` + and `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. + manage_topics: Allow managing all topics; includes `read_topics` + and `send_messages`. + read_topics: Allow reading all topics and managing consumer groups + (including create and delete); includes `poll_messages`. + poll_messages: Allow polling messages from all streams and managing + consumer offsets. send_messages: Allow sending messages to all streams. """ @@ -1099,7 +1112,8 @@ class StreamPermissions: @property def manage_stream(self) -> builtins.bool: r""" - Whether managing the stream is allowed; includes `read_stream`. + Whether managing the stream is allowed; includes `read_stream` and + `manage_topics`. """ @property def read_stream(self) -> builtins.bool: @@ -1109,17 +1123,20 @@ class StreamPermissions: @property def manage_topics(self) -> builtins.bool: r""" - Whether managing the stream topics is allowed; includes `read_topics`. + Whether managing the stream topics is allowed; includes `read_topics` + and `send_messages`. """ @property def read_topics(self) -> builtins.bool: r""" - Whether reading the stream topics and consumer groups is allowed. + Whether reading the stream topics and managing their consumer groups + is allowed; includes `poll_messages`. """ @property def poll_messages(self) -> builtins.bool: r""" - Whether polling messages from the stream is allowed. + Whether polling messages from the stream and managing its consumer + offsets is allowed. """ @property def send_messages(self) -> builtins.bool: @@ -1146,12 +1163,22 @@ class StreamPermissions: r""" Create stream permissions. Every flag defaults to `False`. + The `includes` notes below are transitive: a flag also grants everything + its included flags grant. For example `manage_stream` includes + `manage_topics`, and through it `read_topics`, `poll_messages`, and + `send_messages`. + Args: - manage_stream: Allow managing the stream; includes `read_stream`. + manage_stream: Allow managing the stream; includes `read_stream` + and `manage_topics`. read_stream: Allow reading the stream; includes `read_topics`. - manage_topics: Allow managing the stream topics; includes `read_topics`. - read_topics: Allow reading the stream topics and consumer groups. - poll_messages: Allow polling messages from the stream. + manage_topics: Allow managing the stream topics; includes + `read_topics` and `send_messages`. + read_topics: Allow reading the stream topics and managing their + consumer groups (including create and delete); includes + `poll_messages`. + poll_messages: Allow polling messages from the stream and managing + its consumer offsets. send_messages: Allow sending messages to the stream. topics: Per-topic permissions keyed by topic ID as `dict[int, TopicPermissions] | None`; an empty dict is @@ -1218,21 +1245,25 @@ class TopicDetails: class TopicPermissions: r""" Permissions for a specific topic of a stream. The lowest level of permissions. + They extend the stream and global permissions, they do not override them. """ @property def manage_topic(self) -> builtins.bool: r""" - Whether managing the topic is allowed; includes `read_topic`. + Whether managing the topic is allowed; includes `read_topic` and + `send_messages`. """ @property def read_topic(self) -> builtins.bool: r""" - Whether reading the topic and its consumer groups is allowed. + Whether reading the topic and managing its consumer groups is allowed; + includes `poll_messages`. """ @property def poll_messages(self) -> builtins.bool: r""" - Whether polling messages from the topic is allowed. + Whether polling messages from the topic and managing its consumer + offsets is allowed. """ @property def send_messages(self) -> builtins.bool: @@ -1251,10 +1282,18 @@ class TopicPermissions: r""" Create topic permissions. Every flag defaults to `False`. + The `includes` notes below are transitive: a flag also grants everything + its included flags grant. For example `manage_topic` includes + `read_topic` and `send_messages`, and through `read_topic` also + `poll_messages`. + Args: - manage_topic: Allow managing the topic; includes `read_topic`. - read_topic: Allow reading the topic and its consumer groups. - poll_messages: Allow polling messages from the topic. + manage_topic: Allow managing the topic; includes `read_topic` and + `send_messages`. + read_topic: Allow reading the topic and managing its consumer + groups (including create and delete); includes `poll_messages`. + poll_messages: Allow polling messages from the topic and managing + its consumer offsets. send_messages: Allow sending messages to the topic. """ diff --git a/foreign/python/src/permissions.rs b/foreign/python/src/permissions.rs index 86dc0fef5c..ca1a5d22d2 100644 --- a/foreign/python/src/permissions.rs +++ b/foreign/python/src/permissions.rs @@ -118,16 +118,25 @@ pub struct GlobalPermissions { impl GlobalPermissions { /// Create global permissions. Every flag defaults to `False`. /// + /// The `includes` notes below are transitive: a flag also grants everything + /// its included flags grant. For example `manage_streams` includes + /// `manage_topics`, and through it `read_topics`, `poll_messages`, and + /// `send_messages`. + /// /// 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`. + /// manage_streams: Allow managing all streams; includes `read_streams` + /// and `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. + /// manage_topics: Allow managing all topics; includes `read_topics` + /// and `send_messages`. + /// read_topics: Allow reading all topics and managing consumer groups + /// (including create and delete); includes `poll_messages`. + /// poll_messages: Allow polling messages from all streams and managing + /// consumer offsets. /// send_messages: Allow sending messages to all streams. #[new] #[pyo3(signature = ( @@ -196,7 +205,8 @@ impl GlobalPermissions { self.inner.read_users } - /// Whether managing all streams is allowed; includes `manage_topics`. + /// Whether managing all streams is allowed; includes `read_streams` and + /// `manage_topics`. #[getter] fn manage_streams(&self) -> bool { self.inner.manage_streams @@ -208,19 +218,22 @@ impl GlobalPermissions { self.inner.read_streams } - /// Whether managing all topics is allowed; includes `read_topics`. + /// Whether managing all topics is allowed; includes `read_topics` and + /// `send_messages`. #[getter] fn manage_topics(&self) -> bool { self.inner.manage_topics } - /// Whether reading all topics and consumer groups is allowed. + /// Whether reading all topics and managing consumer groups is allowed; + /// includes `poll_messages`. #[getter] fn read_topics(&self) -> bool { self.inner.read_topics } - /// Whether polling messages from all streams is allowed. + /// Whether polling messages from all streams and managing consumer + /// offsets is allowed. #[getter] fn poll_messages(&self) -> bool { self.inner.poll_messages @@ -247,12 +260,22 @@ pub struct StreamPermissions { impl StreamPermissions { /// Create stream permissions. Every flag defaults to `False`. /// + /// The `includes` notes below are transitive: a flag also grants everything + /// its included flags grant. For example `manage_stream` includes + /// `manage_topics`, and through it `read_topics`, `poll_messages`, and + /// `send_messages`. + /// /// Args: - /// manage_stream: Allow managing the stream; includes `read_stream`. + /// manage_stream: Allow managing the stream; includes `read_stream` + /// and `manage_topics`. /// read_stream: Allow reading the stream; includes `read_topics`. - /// manage_topics: Allow managing the stream topics; includes `read_topics`. - /// read_topics: Allow reading the stream topics and consumer groups. - /// poll_messages: Allow polling messages from the stream. + /// manage_topics: Allow managing the stream topics; includes + /// `read_topics` and `send_messages`. + /// read_topics: Allow reading the stream topics and managing their + /// consumer groups (including create and delete); includes + /// `poll_messages`. + /// poll_messages: Allow polling messages from the stream and managing + /// its consumer offsets. /// send_messages: Allow sending messages to the stream. /// topics: Per-topic permissions keyed by topic ID as /// `dict[int, TopicPermissions] | None`; an empty dict is @@ -299,7 +322,8 @@ impl StreamPermissions { } } - /// Whether managing the stream is allowed; includes `read_stream`. + /// Whether managing the stream is allowed; includes `read_stream` and + /// `manage_topics`. #[getter] fn manage_stream(&self) -> bool { self.inner.manage_stream @@ -311,19 +335,22 @@ impl StreamPermissions { self.inner.read_stream } - /// Whether managing the stream topics is allowed; includes `read_topics`. + /// Whether managing the stream topics is allowed; includes `read_topics` + /// and `send_messages`. #[getter] fn manage_topics(&self) -> bool { self.inner.manage_topics } - /// Whether reading the stream topics and consumer groups is allowed. + /// Whether reading the stream topics and managing their consumer groups + /// is allowed; includes `poll_messages`. #[getter] fn read_topics(&self) -> bool { self.inner.read_topics } - /// Whether polling messages from the stream is allowed. + /// Whether polling messages from the stream and managing its consumer + /// offsets is allowed. #[getter] fn poll_messages(&self) -> bool { self.inner.poll_messages @@ -357,6 +384,7 @@ impl StreamPermissions { } /// Permissions for a specific topic of a stream. The lowest level of permissions. +/// They extend the stream and global permissions, they do not override them. #[derive(Debug, Clone, PartialEq)] #[gen_stub_pyclass] #[pyclass(eq, from_py_object)] @@ -369,10 +397,18 @@ pub struct TopicPermissions { impl TopicPermissions { /// Create topic permissions. Every flag defaults to `False`. /// + /// The `includes` notes below are transitive: a flag also grants everything + /// its included flags grant. For example `manage_topic` includes + /// `read_topic` and `send_messages`, and through `read_topic` also + /// `poll_messages`. + /// /// Args: - /// manage_topic: Allow managing the topic; includes `read_topic`. - /// read_topic: Allow reading the topic and its consumer groups. - /// poll_messages: Allow polling messages from the topic. + /// manage_topic: Allow managing the topic; includes `read_topic` and + /// `send_messages`. + /// read_topic: Allow reading the topic and managing its consumer + /// groups (including create and delete); includes `poll_messages`. + /// poll_messages: Allow polling messages from the topic and managing + /// its consumer offsets. /// send_messages: Allow sending messages to the topic. #[new] #[pyo3(signature = ( @@ -393,19 +429,22 @@ impl TopicPermissions { } } - /// Whether managing the topic is allowed; includes `read_topic`. + /// Whether managing the topic is allowed; includes `read_topic` and + /// `send_messages`. #[getter] fn manage_topic(&self) -> bool { self.inner.manage_topic } - /// Whether reading the topic and its consumer groups is allowed. + /// Whether reading the topic and managing its consumer groups is allowed; + /// includes `poll_messages`. #[getter] fn read_topic(&self) -> bool { self.inner.read_topic } - /// Whether polling messages from the topic is allowed. + /// Whether polling messages from the topic and managing its consumer + /// offsets is allowed. #[getter] fn poll_messages(&self) -> bool { self.inner.poll_messages From 364162a85522de4db910967021cf6d8356df77cf Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sun, 26 Jul 2026 17:17:09 +0800 Subject: [PATCH 13/13] test(python): cover change_password authorization 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. --- foreign/python/tests/test_permissions.py | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/foreign/python/tests/test_permissions.py b/foreign/python/tests/test_permissions.py index 9411bff231..e3b54f11cd 100644 --- a/foreign/python/tests/test_permissions.py +++ b/foreign/python/tests/test_permissions.py @@ -549,6 +549,54 @@ async def test_user_can_change_own_password( await iggy_client.delete_user(created.id) + @pytest.mark.asyncio + async def test_user_with_manage_users_can_change_other_password( + self, iggy_client: IggyClient, unique_name + ): + """Test a user granted manage_users can change another user's password.""" + actor_username, actor_password = unique_credentials(unique_name) + actor = await iggy_client.create_user( + actor_username, + actor_password, + permissions=Permissions( + global_permissions=GlobalPermissions(manage_users=True) + ), + ) + target_username, target_password = unique_credentials(unique_name) + target = await iggy_client.create_user(target_username, target_password) + new_password = unique_name(max_bytes=MAX_PASSWORD_BYTES) + + actor_client = await login_fresh_client(actor_username, actor_password) + await actor_client.change_password(target.id, target_password, new_password) + + relogin = await login_fresh_client(target_username, new_password) + await relogin.ping() + + await iggy_client.delete_user(actor.id) + await iggy_client.delete_user(target.id) + + @pytest.mark.asyncio + async def test_failed_password_change_leaves_target_credentials_valid( + self, iggy_client: IggyClient, unique_name + ): + """Test a user lacking manage_users is denied and old credentials stay valid.""" + actor_username, actor_password = unique_credentials(unique_name) + actor = await iggy_client.create_user(actor_username, actor_password) + target_username, target_password = unique_credentials(unique_name) + target = await iggy_client.create_user(target_username, target_password) + + actor_client = await login_fresh_client(actor_username, actor_password) + with pytest.raises(RuntimeError): + await actor_client.change_password( + target.id, target_password, unique_name(max_bytes=MAX_PASSWORD_BYTES) + ) + + target_client = await login_fresh_client(target_username, target_password) + await target_client.ping() + + await iggy_client.delete_user(actor.id) + await iggy_client.delete_user(target.id) + @pytest.mark.parametrize( "new_password", ["a" * (MIN_PASSWORD_BYTES - 1), "a" * (MAX_PASSWORD_BYTES + 1)],