Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
39 changes: 39 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -309,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
Expand Down
1 change: 1 addition & 0 deletions redis_client_kit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class RedisConnectionProtocol(Protocol):

host: str
port: int
username: str | None
db: int
client_name: str | None
protocol: int
Expand Down
1 change: 1 addition & 0 deletions redis_client_kit/settings/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions redis_client_kit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def build_base_redis_kwargs(settings: RedisSettingsProtocol, *, asyncio: bool =
_validate_pem_format(settings.ssl.keyfile, "PRIVATE KEY")

kwargs: dict[str, object] = {
"username": settings.connection.username,
"password": settings.connection.get_password(),
"max_connections": settings.pool.max_connections,
"socket_timeout": settings.pool.socket_timeout,
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class FakeConnection:

host = "localhost"
port = 6379
username = None
db = 0
client_name = None
protocol = 2
Expand Down
106 changes: 106 additions & 0 deletions tests/integration/test_redis_acl.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions tests/unit/aio/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/settings/test_redis_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/sync/test_sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ 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__username_unset__passes_none(mock_redis_settings: MagicMock) -> None:
# 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:
Expand Down Expand Up @@ -228,3 +248,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": "********"}
Loading