From f1654f7d8d6606acfa783ca288cf924d98fc9053 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:07:41 +0300 Subject: [PATCH 1/2] feat: log in as a Redis ACL user with connection.username Closes #48 --- docs/agents.md | 4 +- docs/guide/configuration.md | 44 ++++++++- redis_client_kit/config.py | 6 +- redis_client_kit/settings/redis.py | 1 + redis_client_kit/utils.py | 2 + tests/integration/test_redis_acl.py | 106 +++++++++++++++++++++ tests/unit/aio/test_client.py | 21 ++++ tests/unit/settings/test_redis_settings.py | 28 ++++++ tests/unit/sync/test_sync_client.py | 21 ++++ tests/unit/test_utils.py | 34 +++++++ 10 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_redis_acl.py diff --git a/docs/agents.md b/docs/agents.md index 7dcb5ef..a65dfe9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -119,7 +119,8 @@ client = create_async_redis_client(Settings()) ``` Every attribute the protocols name has to be there: the factory reads all of them and -raises `AttributeError` on the first one missing. +raises `AttributeError` on the first one missing. `connection.username` is optional and not +in the protocol: when the object has it, the client logs in as that ACL user. ## Settings @@ -143,6 +144,7 @@ raises `AttributeError` on the first one missing. |---|---|---|---| | `RedisConnectionSettings` | `host` | `"localhost"` | | | | `port` | `6379` | `1 ≤ port ≤ 65535` | +| | `username` | `None` | Redis ACL user; `None` logs in as `default` | | | `password` | `None` | `SecretStr`; read with `get_password()` | | | `db` | `0` | `0 ≤ db ≤ 15`, must be `0` in cluster mode | | | `client_name` | `None` | `CLIENT SETNAME` value | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 51daf54..3ed4acd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -69,6 +69,7 @@ settings = BaseRedisSettings( connection=RedisConnectionSettings( host="redis.example.com", port=6379, + username="myapp", # Redis ACL user; `default` when unset password="secret", # stored as SecretStr, read with get_password() db=0, client_name="myapp", @@ -104,11 +105,48 @@ class Settings(BaseRedisSettings): # REDIS_KEY_PREFIX=myapp # REDIS_CONNECTION__HOST=redis.example.com # REDIS_CONNECTION__PORT=6379 +# REDIS_CONNECTION__USERNAME=myapp # REDIS_CONNECTION__PASSWORD=secret settings = Settings() ``` +### ACL Users + +Without `username` the client logs in as Redis's `default` user. To share one Redis +between applications, give each its own ACL user (Redis 6+) and set `username` next to +`password`: + +```python +settings = BaseRedisSettings( + key_prefix="myapp", + connection=RedisConnectionSettings( + host="redis.example.com", + username="myapp", + password="secret", + ), +) +``` + +Every client the factories build gets it: sync and asyncio, single node and cluster. + +A rule that keeps the user to its own keys and still lets this library work: + +``` +ACL SETUSER myapp on >secret resetkeys ~myapp:* resetchannels &myapp:* -@all +@read +@write -@dangerous +ping +``` + +- `+ping`: `check_*_redis_health`, the provider's startup check and + `health_check_interval` all send `PING`, which is in neither `@read` nor `@write`. +- `-@dangerous`: `@write` includes `FLUSHDB` and `FLUSHALL`, and a key pattern does not + stop them, because they name no key. `-@dangerous` removes them, and `KEYS` with them. +- Add `+client|setname` when `client_name` is set; without it every connection fails. +- Add `+select` when `db` is not `0`, for the same reason. +- A health check's `write_key` has to match the key pattern, such as `myapp:probe`. + +`SCAN` still lists the names of every key in the database; add `-scan` if the names are +private too. + ## Connection Pool Settings Configure connection pool behavior: @@ -377,8 +415,10 @@ client = create_async_redis_client(settings) Every attribute the protocols name has to be there: the factory reads all of them but `metrics_enabled`, which only `PrometheusRedisMetricsProvider` reads, and raises -`AttributeError` on the first one missing. The protocols are not -`@runtime_checkable`, so `isinstance(settings, RedisSettingsProtocol)` raises `TypeError`. +`AttributeError` on the first one missing. `connection.username` is the one optional +attribute: give the connection object a `username: str | None` to log in as an ACL user. +The protocols are not `@runtime_checkable`, so `isinstance(settings, RedisSettingsProtocol)` +raises `TypeError`. ## Configuration Best Practices diff --git a/redis_client_kit/config.py b/redis_client_kit/config.py index 866a5fa..0edd627 100644 --- a/redis_client_kit/config.py +++ b/redis_client_kit/config.py @@ -4,7 +4,11 @@ class RedisConnectionProtocol(Protocol): - """Protocol for Redis connection settings.""" + """Protocol for Redis connection settings. + + A ``username: str | None`` attribute is read too when the object has one: the Redis ACL + user to log in as. Without it the client logs in as ``default``. + """ host: str port: int diff --git a/redis_client_kit/settings/redis.py b/redis_client_kit/settings/redis.py index cc997cc..c36c820 100644 --- a/redis_client_kit/settings/redis.py +++ b/redis_client_kit/settings/redis.py @@ -10,6 +10,7 @@ class RedisConnectionSettings(BaseModel): # type: ignore[misc] host: str = Field(default="localhost", description="Redis host") port: int = Field(default=6379, ge=1, le=65535, description="Redis port") + username: str | None = Field(default=None, description="Redis ACL user name (optional; `default` when unset)") password: SecretStr | None = Field(default=None, description="Redis password (optional)") db: int = Field(default=0, ge=0, le=15, description="Redis database number (ignored in cluster mode)") client_name: str | None = Field(default=None, description="Identifier for this client in Redis") diff --git a/redis_client_kit/utils.py b/redis_client_kit/utils.py index f87ef23..7c25fbd 100644 --- a/redis_client_kit/utils.py +++ b/redis_client_kit/utils.py @@ -53,6 +53,8 @@ def build_base_redis_kwargs(settings: RedisSettingsProtocol, *, asyncio: bool = _validate_pem_format(settings.ssl.keyfile, "PRIVATE KEY") kwargs: dict[str, object] = { + # Optional on the connection protocol, so settings objects written before it still work. + "username": getattr(settings.connection, "username", None), "password": settings.connection.get_password(), "max_connections": settings.pool.max_connections, "socket_timeout": settings.pool.socket_timeout, diff --git a/tests/integration/test_redis_acl.py b/tests/integration/test_redis_acl.py new file mode 100644 index 0000000..7c73b85 --- /dev/null +++ b/tests/integration/test_redis_acl.py @@ -0,0 +1,106 @@ +"""Integration tests for logging in as a Redis ACL user.""" + +from collections.abc import Iterator + +import pytest +from redis import Redis +from redis.exceptions import NoPermissionError +from testcontainers.redis import RedisContainer + +from redis_client_kit import ( + check_async_redis_health, + close_async_redis_client, + close_redis_client, + create_async_redis_client, + create_redis_client, +) +from redis_client_kit.settings import BaseRedisSettings, RedisConnectionSettings + +from .conftest import is_docker_available + +pytestmark = pytest.mark.skipif(not is_docker_available(), reason="Docker is not available") + +ACL_USER = "app" +ACL_PASSWORD = "app-secret" +# The rule docs/guide/configuration.md gives for an application user. +ACL_RULE = f"on >{ACL_PASSWORD} resetkeys ~app:* resetchannels &app:* -@all +@read +@write -@dangerous +ping" + + +@pytest.fixture +def admin(redis_container: RedisContainer) -> Iterator[Redis]: + """The container's ``default`` user, with the ACL user created and another app's key written.""" + client = Redis( + host=redis_container.get_container_host_ip(), + port=int(redis_container.get_exposed_port(redis_container.port)), + ) + client.execute_command("ACL", "SETUSER", ACL_USER, *ACL_RULE.split()) + client.set("other:x", "theirs") + try: + yield client + finally: + client.acl_deluser(ACL_USER) + client.delete("other:x", "app:x", "app:probe") + client.close() + + +def acl_settings(admin: Redis, *, protocol: int = 2) -> BaseRedisSettings: + kwargs = admin.connection_pool.connection_kwargs + return BaseRedisSettings( + key_prefix="app", + connection=RedisConnectionSettings( + host=kwargs["host"], + port=kwargs["port"], + username=ACL_USER, + password=ACL_PASSWORD, + protocol=protocol, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", [2, 3], ids=["resp2", "resp3"]) +async def test__async_client__acl_user__confined_to_its_keys(admin: Redis, protocol: int) -> None: + # Arrange + client = create_async_redis_client(acl_settings(admin, protocol=protocol)) + + try: + # Act & Assert + assert await client.set("app:x", "mine") is True + with pytest.raises(NoPermissionError): + await client.get("other:x") + with pytest.raises(NoPermissionError): + await client.flushdb() + finally: + await close_async_redis_client(client) + + assert admin.get("other:x") == b"theirs" + + +def test__sync_client__acl_user__confined_to_its_keys(admin: Redis) -> None: + # Arrange + client = create_redis_client(acl_settings(admin)) + + try: + # Act & Assert + assert client.set("app:x", "mine") is True + with pytest.raises(NoPermissionError): + client.get("other:x") + finally: + close_redis_client(client) + + +@pytest.mark.asyncio +async def test__check_async_redis_health__acl_user__ping_and_write_probe_pass(admin: Redis) -> None: + # Arrange + client = create_async_redis_client(acl_settings(admin)) + + try: + # Act + pinged = await check_async_redis_health(client) + probed = await check_async_redis_health(client, write_key="app:probe") + + # Assert + assert pinged is True + assert probed is True + finally: + await close_async_redis_client(client) diff --git a/tests/unit/aio/test_client.py b/tests/unit/aio/test_client.py index 577574a..7d3770b 100644 --- a/tests/unit/aio/test_client.py +++ b/tests/unit/aio/test_client.py @@ -128,6 +128,27 @@ def test__create_async_redis_client__retry_disabled__passes_zero_retries_to_clie assert isinstance(retry._backoff, NoBackoff) +@pytest.mark.parametrize( + "cluster_mode, expected_class", + [(False, "Redis"), (True, "RedisCluster")], + ids=["single-node", "cluster"], +) +def test__create_async_redis_client__username_set__passes_acl_user_to_client( + mock_redis_settings: MagicMock, cluster_mode: bool, expected_class: str +) -> None: + # Arrange + mock_redis_settings.cluster.enabled = cluster_mode + mock_redis_settings.connection.username = "app" + + # Act + with patch(f"redis_client_kit.aio.factory.{expected_class}") as mock_class: + create_async_redis_client(mock_redis_settings) + + # Assert + assert mock_class.call_args[1]["username"] == "app" + assert mock_class.call_args[1]["password"] == "password" + + @pytest.mark.asyncio async def test__close_async_redis_client__valid_client__calls_aclose() -> None: # Arrange diff --git a/tests/unit/settings/test_redis_settings.py b/tests/unit/settings/test_redis_settings.py index 83df45d..e9e27f2 100644 --- a/tests/unit/settings/test_redis_settings.py +++ b/tests/unit/settings/test_redis_settings.py @@ -2,6 +2,7 @@ import pytest from pydantic import SecretStr, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict from redis_client_kit.settings import BaseRedisSettings from redis_client_kit.settings.redis import ( @@ -161,6 +162,33 @@ def test__base_redis_settings__get_password_none__returns_none() -> None: assert password is None +def test__base_redis_settings__username_unset__defaults_to_none() -> None: + # Arrange & Act + settings = BaseRedisSettings(key_prefix="myapp") + + # Assert + assert settings.connection.username is None + + +def test__base_redis_settings__username_env_var__reads_acl_user(monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + class AppSettings(BaseSettings): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + redis: BaseRedisSettings + + monkeypatch.setenv("REDIS__KEY_PREFIX", "bertie") + monkeypatch.setenv("REDIS__CONNECTION__USERNAME", "bertie") + monkeypatch.setenv("REDIS__CONNECTION__PASSWORD", "bertie-secret") + + # Act + settings = AppSettings() + + # Assert + assert settings.redis.connection.username == "bertie" + assert settings.redis.connection.get_password() == "bertie-secret" + + def test__base_redis_settings__retry_configuration__includes_all_parameters() -> None: # Arrange & Act settings = BaseRedisSettings( diff --git a/tests/unit/sync/test_sync_client.py b/tests/unit/sync/test_sync_client.py index c530018..aa2ddf3 100644 --- a/tests/unit/sync/test_sync_client.py +++ b/tests/unit/sync/test_sync_client.py @@ -118,6 +118,27 @@ def test__create_redis_client__retry_disabled__passes_zero_retries_to_client( assert isinstance(retry._backoff, NoBackoff) +@pytest.mark.parametrize( + "cluster_mode, expected_class", + [(False, "Redis"), (True, "RedisCluster")], + ids=["single-node", "cluster"], +) +def test__create_redis_client__username_set__passes_acl_user_to_client( + mock_redis_settings: MagicMock, cluster_mode: bool, expected_class: str +) -> None: + # Arrange + mock_redis_settings.cluster.enabled = cluster_mode + mock_redis_settings.connection.username = "app" + + # Act + with patch(f"redis_client_kit.sync.factory.{expected_class}") as mock_class: + create_redis_client(mock_redis_settings) + + # Assert + assert mock_class.call_args[1]["username"] == "app" + assert mock_class.call_args[1]["password"] == "password" + + def test__close_redis_client__valid_client__calls_close() -> None: # Arrange mock_client = MagicMock() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 3d441d8..0f13032 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -132,6 +132,29 @@ def test__build_base_redis_kwargs__full_config__returns_all_parameters(mock_redi assert "retry" in kwargs +def test__build_base_redis_kwargs__username_set__passes_acl_user(mock_redis_settings: MagicMock) -> None: + # Arrange + mock_redis_settings.connection.username = "app" + + # Act + kwargs = build_base_redis_kwargs(mock_redis_settings) + + # Assert + assert kwargs["username"] == "app" + assert kwargs["password"] == "password" + + +def test__build_base_redis_kwargs__connection_without_username__passes_none(mock_redis_settings: MagicMock) -> None: + # Arrange: the spec'd connection mock has no ``username``, like a settings object written before it + assert not hasattr(mock_redis_settings.connection, "username") + + # Act + kwargs = build_base_redis_kwargs(mock_redis_settings) + + # Assert + assert kwargs["username"] is None + + def test__build_base_redis_kwargs__retry_disabled__passes_zero_retries( mock_redis_settings: MagicMock, ) -> None: @@ -228,3 +251,14 @@ def test__mask_redis_kwargs__sensitive_data__masks_password() -> None: assert masked["host"] == "localhost" assert kwargs["password"] == "secret_password" # Ensure original is not modified assert masked_no_pwd == kwargs_no_pwd + + +def test__mask_redis_kwargs__username__left_unmasked() -> None: + # Arrange + kwargs: dict[str, object] = {"username": "app", "password": "secret_password"} + + # Act + masked = mask_redis_kwargs(kwargs) + + # Assert + assert masked == {"username": "app", "password": "********"} From 4241e818a2fab01141da863b8696480e9b082633 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:28:49 +0300 Subject: [PATCH 2/2] feat!: make username a member of RedisConnectionProtocol BREAKING CHANGE: a hand-written connection object now needs a username attribute (None for the default user); without it the factories raise AttributeError. --- README.md | 3 +++ docs/agents.md | 4 ++-- docs/guide/configuration.md | 7 +++---- redis_client_kit/config.py | 7 ++----- redis_client_kit/utils.py | 3 +-- tests/conftest.py | 1 + tests/integration/conftest.py | 1 + tests/unit/test_utils.py | 5 +---- 8 files changed, 14 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b01efc5..39e57d1 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,10 @@ class MySettings: class connection: host = "localhost" port = 6379 + username = None db = 0 + client_name = None + protocol = 2 @staticmethod def get_password(): diff --git a/docs/agents.md b/docs/agents.md index a65dfe9..84a1a1e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -102,6 +102,7 @@ from dataclasses import dataclass, field class Connection: host: str = "localhost" port: int = 6379 + username: str | None = None db: int = 0 client_name: str | None = None protocol: int = 2 @@ -119,8 +120,7 @@ client = create_async_redis_client(Settings()) ``` Every attribute the protocols name has to be there: the factory reads all of them and -raises `AttributeError` on the first one missing. `connection.username` is optional and not -in the protocol: when the object has it, the client logs in as that ACL user. +raises `AttributeError` on the first one missing. ## Settings diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3ed4acd..09ab657 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -347,6 +347,7 @@ from redis_client_kit.config import ( class MyConnection: host: str = "localhost" port: int = 6379 + username: str | None = None db: int = 0 client_name: str | None = None protocol: int = 2 @@ -415,10 +416,8 @@ client = create_async_redis_client(settings) Every attribute the protocols name has to be there: the factory reads all of them but `metrics_enabled`, which only `PrometheusRedisMetricsProvider` reads, and raises -`AttributeError` on the first one missing. `connection.username` is the one optional -attribute: give the connection object a `username: str | None` to log in as an ACL user. -The protocols are not `@runtime_checkable`, so `isinstance(settings, RedisSettingsProtocol)` -raises `TypeError`. +`AttributeError` on the first one missing. The protocols are not +`@runtime_checkable`, so `isinstance(settings, RedisSettingsProtocol)` raises `TypeError`. ## Configuration Best Practices diff --git a/redis_client_kit/config.py b/redis_client_kit/config.py index 0edd627..1d4f915 100644 --- a/redis_client_kit/config.py +++ b/redis_client_kit/config.py @@ -4,14 +4,11 @@ class RedisConnectionProtocol(Protocol): - """Protocol for Redis connection settings. - - A ``username: str | None`` attribute is read too when the object has one: the Redis ACL - user to log in as. Without it the client logs in as ``default``. - """ + """Protocol for Redis connection settings.""" host: str port: int + username: str | None db: int client_name: str | None protocol: int diff --git a/redis_client_kit/utils.py b/redis_client_kit/utils.py index 7c25fbd..456496e 100644 --- a/redis_client_kit/utils.py +++ b/redis_client_kit/utils.py @@ -53,8 +53,7 @@ def build_base_redis_kwargs(settings: RedisSettingsProtocol, *, asyncio: bool = _validate_pem_format(settings.ssl.keyfile, "PRIVATE KEY") kwargs: dict[str, object] = { - # Optional on the connection protocol, so settings objects written before it still work. - "username": getattr(settings.connection, "username", None), + "username": settings.connection.username, "password": settings.connection.get_password(), "max_connections": settings.pool.max_connections, "socket_timeout": settings.pool.socket_timeout, diff --git a/tests/conftest.py b/tests/conftest.py index 7ec067c..f1948f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ def mock_redis_settings() -> MagicMock: connection = MagicMock(spec=RedisConnectionProtocol) connection.host = "localhost" connection.port = 6379 + connection.username = None connection.db = 0 connection.client_name = None connection.protocol = 2 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5111f5a..59603c4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -55,6 +55,7 @@ class FakeConnection: host = "localhost" port = 6379 + username = None db = 0 client_name = None protocol = 2 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 0f13032..b140ffd 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -144,10 +144,7 @@ def test__build_base_redis_kwargs__username_set__passes_acl_user(mock_redis_sett assert kwargs["password"] == "password" -def test__build_base_redis_kwargs__connection_without_username__passes_none(mock_redis_settings: MagicMock) -> None: - # Arrange: the spec'd connection mock has no ``username``, like a settings object written before it - assert not hasattr(mock_redis_settings.connection, "username") - +def test__build_base_redis_kwargs__username_unset__passes_none(mock_redis_settings: MagicMock) -> None: # Act kwargs = build_base_redis_kwargs(mock_redis_settings)