diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b980c2e0d..a6bb3035b3 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,105 @@ 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 `read_streams` and + `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` and + `send_messages`. + """ + @property + def read_topics(self) -> builtins.bool: + r""" + 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 and managing consumer + offsets 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`. + + 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 `read_streams` + and `manage_topics`. + read_streams: Allow reading all streams; includes `read_topics`. + 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. + """ + @typing.final class IggyClient: r""" @@ -371,16 +474,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`. @@ -425,6 +528,57 @@ class IggyClient: 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 + ) -> 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. + """ + 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 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. @@ -818,6 +972,39 @@ 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_permissions(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_permissions: GlobalPermissions | None = None, + streams: dict[int, StreamPermissions] | None = None, + ) -> Permissions: + r""" + Create permissions from global permissions and optional per-stream permissions. + + Args: + global_permissions: Global permissions as `GlobalPermissions | None`; + defaults to all denied. + streams: Per-stream permissions keyed by stream ID as + `dict[int, StreamPermissions] | None`; an empty dict is + treated as `None`. + """ + class PollingStrategy: @typing.final class Offset(PollingStrategy): @@ -916,6 +1103,88 @@ 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` and + `manage_topics`. + """ + @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` + and `send_messages`. + """ + @property + def read_topics(self) -> builtins.bool: + r""" + 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 and managing its consumer + offsets 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`. + + 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` + and `manage_topics`. + read_stream: Allow reading the stream; includes `read_topics`. + 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 + treated as `None`. + """ + @typing.final class Topic: @property @@ -972,6 +1241,62 @@ 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. + 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` and + `send_messages`. + """ + @property + def read_topic(self) -> builtins.bool: + r""" + 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 and managing its consumer + offsets 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`. + + 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` 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. + """ + @typing.final class UserInfo: @property @@ -1017,6 +1342,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..a7f0cfd514 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)) @@ -264,6 +268,97 @@ 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))] + #[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(()) + }) + } + + /// 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(()) + }) + } + + /// 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/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..ca1a5d22d2 --- /dev/null +++ b/foreign/python/src/permissions.rs @@ -0,0 +1,458 @@ +// 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_permissions: Global permissions as `GlobalPermissions | None`; + /// defaults to all denied. + /// streams: Per-stream permissions keyed by stream ID as + /// `dict[int, StreamPermissions] | None`; an empty dict is + /// treated as `None`. + #[new] + #[pyo3(signature = (global_permissions=None, streams=None))] + fn new( + #[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_permissions + .map(|global| global.inner) + .unwrap_or_default(), + // 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() + }), + }, + } + } + + /// The global permissions, applied to all streams. + #[getter] + fn global_permissions(&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`. + /// + /// 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 `read_streams` + /// and `manage_topics`. + /// read_streams: Allow reading all streams; includes `read_topics`. + /// 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 = ( + *, + 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 `read_streams` and + /// `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` and + /// `send_messages`. + #[getter] + fn manage_topics(&self) -> bool { + self.inner.manage_topics + } + + /// 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 and managing consumer + /// offsets 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`. + /// + /// 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` + /// and `manage_topics`. + /// read_stream: Allow reading the stream; includes `read_topics`. + /// 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 + /// treated as `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, + // 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)) + .collect() + }), + }, + } + } + + /// Whether managing the stream is allowed; includes `read_stream` and + /// `manage_topics`. + #[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` + /// and `send_messages`. + #[getter] + fn manage_topics(&self) -> bool { + self.inner.manage_topics + } + + /// 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 and managing its consumer + /// offsets 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. +/// They extend the stream and global permissions, they do not override them. +#[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`. + /// + /// 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` 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 = ( + *, + 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` and + /// `send_messages`. + #[getter] + fn manage_topic(&self) -> bool { + self.inner.manage_topic + } + + /// 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 and managing its consumer + /// offsets 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/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 new file mode 100644 index 0000000000..e3b54f11cd --- /dev/null +++ b/foreign/python/tests/test_permissions.py @@ -0,0 +1,695 @@ +# 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, + IggyClient, + Permissions, + StreamPermissions, + TopicPermissions, +) + +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 = [ + "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_permissions + 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) + + @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) + + @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_nested_stream_and_topic_permissions_are_preserved(self): + """Test stream and topic permission dicts survive construction.""" + permissions = Permissions( + global_permissions=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_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): + 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_permissions=GlobalPermissions(read_streams=True), + streams={3: StreamPermissions(poll_messages=True)}, + ) + second = Permissions( + global_permissions=GlobalPermissions(read_streams=True), + streams={3: StreamPermissions(poll_messages=True)}, + ) + different = Permissions( + global_permissions=GlobalPermissions(manage_streams=True) + ) + + assert first == second + assert first != different + + +class TestCreateUserWithPermissions: + """Test create_user with the permissions argument.""" + + @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_permissions=GlobalPermissions(read_users=True, read_streams=True), + streams={}, + ) + + 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 + assert fetched_permissions.streams is None + + 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_permissions=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) + + +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_permissions=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_permissions=GlobalPermissions(read_streams=True, read_users=True) + ) + created = await iggy_client.create_user(username, password, permissions=initial) + + replacement = Permissions( + global_permissions=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_permissions.read_streams is False + assert fetched_permissions.global_permissions.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_permissions=GlobalPermissions(read_streams=True) + ) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + + await iggy_client.update_permissions(created.id, 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_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_permissions=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_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_permissions=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_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_permissions=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_permissions=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_permissions=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 + ): + """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_permissions=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.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)], + 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), + ) + + +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_permissions=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_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 + ): + """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_permissions=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 9e94a59350..a15b714b1d 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,19 +43,21 @@ 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) 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 @@ -66,7 +71,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 +81,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 +95,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 +107,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 +123,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 +139,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 +230,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 +259,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 +281,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 +310,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 +328,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 +346,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 +366,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 +383,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 +401,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 +438,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 +456,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 +475,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 +487,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 +518,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 +529,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 +541,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 +557,27 @@ 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_permissions=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( @@ -588,6 +611,9 @@ async def test_deleted_username_is_reusable_with_fresh_credentials( "create_user", "update_user", "delete_user", + "update_permissions", + "change_password", + "logout_user", ], ) @pytest.mark.asyncio @@ -604,6 +630,9 @@ 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, None), + "change_password": (username, "secret", "secret2"), + "logout_user": (), } method = getattr(client, method_name) args = args_by_method[method_name] 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