fix: address production readiness review

This commit is contained in:
beubax
2026-06-10 16:55:14 +05:30
parent 69b263b071
commit b374fb49a0
9 changed files with 179 additions and 19 deletions
+2 -1
View File
@@ -51,12 +51,13 @@ services:
AUTHSOME_HOME: /data/authsome
AUTHSOME_BASE_URL: ${AUTHSOME_BASE_URL:-http://localhost:7998}
AUTHSOME_DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome}
DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome}
AUTHSOME_REDIS_URL: redis://redis:6379/0
AUTHSOME_DO_NOT_TRACK: "1"
# Set AUTHSOME_MASTER_KEY from your platform secret store before production use.
# AUTHSOME_MASTER_KEY_FILE remains available for platforms that mount a secret file into the container.
AUTHSOME_MASTER_KEY: ${AUTHSOME_MASTER_KEY:?set AUTHSOME_MASTER_KEY}
# Must be identical on every replica because browser sessions are stateless signed JWTs.
AUTHSOME_UI_SESSION_KEY: ${AUTHSOME_UI_SESSION_KEY:?set AUTHSOME_UI_SESSION_KEY}
# Uncomment to use a pre-built image from a registry instead of building locally:
# image: ghcr.io/agentrhq/authsome:latest
+14 -10
View File
@@ -9,28 +9,29 @@ The repository ships a compose file that wires the daemon to Postgres and Redis.
```bash
export AUTHSOME_POSTGRES_PASSWORD='change-me-to-a-long-random-password'
export AUTHSOME_MASTER_KEY='base64-encoded-32-byte-key'
export AUTHSOME_UI_SESSION_KEY='base64-encoded-32-byte-key'
docker compose up -d
curl http://localhost:7998/health
```
The daemon should answer on `http://localhost:7998`. The root `/health` endpoint is the container health target used by the image and by `docker compose`.
The included compose file reads `AUTHSOME_MASTER_KEY` from the host environment. `AUTHSOME_MASTER_KEY_FILE` is supported by Authsome itself, but if you want to use a file-mounted secret you must add that mount and pass the file path yourself in a custom compose file.
The included compose file reads `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` from the host environment. The `_FILE` variants are supported by Authsome itself, but if you want to use file-mounted secrets you must add those mounts and pass the file paths yourself in a custom compose file.
## What this deployment does
- Postgres stores the relational server registries: identities, principals, vaults, claims, and bindings.
- Redis stores shared runtime state and, when configured, backs the raw KV layer that holds encrypted vault blobs.
- The Authsome container keeps only a small home directory for logs and optional fallback key material. Primary production state lives in Postgres and Redis.
- Browser sessions remain stateless signed cookies for now. Any future stateful browser session store is tracked separately.
- Browser sessions remain stateless signed cookies for now, so every replica must use the same `AUTHSOME_UI_SESSION_KEY`. Any future stateful browser session store is tracked separately.
## Prerequisites
- Docker and Docker Compose v2.
- Postgres 16.
- Redis 7.
- A stable `AUTHSOME_MASTER_KEY` for the included compose file.
- Stable `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` values for the included compose file.
Do not commit production master keys. Use your platform secret store or a Docker secret for the included compose file. If you prefer `AUTHSOME_MASTER_KEY_FILE`, wire up your own secret mount and file path in a custom compose file.
Do not commit production secrets. Use your platform secret store or Docker secrets for the included compose file. If you prefer `_FILE` variables, wire up your own secret mounts and file paths in a custom compose file.
## Required environment variables
@@ -43,6 +44,8 @@ Do not commit production master keys. Use your platform secret store or a Docker
| `AUTHSOME_POSTGRES_DB` | `authsome` | Postgres database name used by the bundled compose file. |
| `AUTHSOME_MASTER_KEY` | none | Base64-encoded 32-byte master key. Highest priority when set. |
| `AUTHSOME_MASTER_KEY_FILE` | none | Advanced alternative for custom compose or platform-secret setups where you mount a file into the container and point Authsome at that path yourself. |
| `AUTHSOME_UI_SESSION_KEY` | none | Shared signing secret for stateless browser session JWTs. Must be identical on every replica. |
| `AUTHSOME_UI_SESSION_KEY_FILE` | none | Advanced alternative for custom compose or platform-secret setups where you mount the UI session key into the container. |
| `AUTHSOME_BASE_URL` | `http://localhost:7998` | Public daemon URL used to build OAuth callback URLs. Set this to the reverse-proxy URL in production. |
| `AUTHSOME_HOME` | `/data/authsome` | Home directory for logs, generated fallback secrets, and other daemon-local files. |
| `AUTHSOME_HOST` | `0.0.0.0` | Host interface the daemon binds to inside the container. |
@@ -51,19 +54,19 @@ Do not commit production master keys. Use your platform secret store or a Docker
| `AUTHSOME_POSTHOG_API_KEY` | none | Enables PostHog analytics when present and telemetry is not opted out. |
| `AUTHSOME_POSTHOG_HOST` | `https://us.i.posthog.com` | Override the PostHog ingestion host if needed. |
The current daemon settings still read the legacy `DATABASE_URL` alias internally. The compose file sets `AUTHSOME_DATABASE_URL` and mirrors it into `DATABASE_URL` so the deployment contract stays explicit while the current runtime keeps working.
The included compose file hard-requires `AUTHSOME_MASTER_KEY` from the host environment; it does not mount a secret file or pass a `_FILE` path for you.
The daemon still accepts the legacy `DATABASE_URL` alias, but production deployments should set `AUTHSOME_DATABASE_URL`.
The included compose file hard-requires `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` from the host environment; it does not mount secret files or pass `_FILE` paths for you.
## Master key resolution
## Secret resolution
On startup, Authsome resolves the master key in this order:
On startup, Authsome resolves the master key and UI session signing key in this order:
1. `AUTHSOME_MASTER_KEY`
2. `AUTHSOME_MASTER_KEY_FILE`, or the default server key file at `AUTHSOME_HOME/server/master.key`
3. The OS keyring entry
4. A generated fallback, stored in the keyring when possible, otherwise written to the default server key file
`AUTHSOME_MASTER_KEY` is the strongest and cleanest production option for the included compose file because it avoids writing secret material to disk. If you use `AUTHSOME_MASTER_KEY_FILE`, mount it read-only, point Authsome at the mounted path, and treat that as a custom compose setup rather than the out-of-the-box quick start.
`AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` are the strongest and cleanest production options for the included compose file because they avoid writing secret material to disk. If you use `_FILE` variables, mount them read-only, point Authsome at the mounted paths, and treat that as a custom compose setup rather than the out-of-the-box quick start.
## Compose layout
@@ -88,6 +91,7 @@ Back up these pieces together:
- Postgres data, because it stores the server registries.
- Redis persistence, if you enable or rely on it for encrypted vault blobs or shared runtime state.
- The master key or key file, because encrypted vault data cannot be decrypted without it.
- The UI session key or key file, because stateless browser session JWTs cannot be verified consistently across replicas without it.
- The `authsome-data` volume only if you want daemon logs or a fallback key file to survive container replacement.
Browser sessions remain stateless signed cookies for now, so there is no separate session database to back up yet.
@@ -108,7 +112,7 @@ Because schema migrations run at startup, keep the Postgres and Redis services h
## Example production notes
- Use your platform secret store for `AUTHSOME_MASTER_KEY`. Only switch to `AUTHSOME_MASTER_KEY_FILE` if you have added a real secret mount and file path to your own compose file.
- Use your platform secret store for `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY`. Only switch to `_FILE` variables if you have added real secret mounts and file paths to your own compose file.
- Set `AUTHSOME_BASE_URL` to the public URL behind your reverse proxy.
- Keep `AUTHSOME_HOME` mounted only if you want local logs or fallback key material to persist.
- Consider pointing `AUTHSOME_POSTHOG_API_KEY` at a real analytics key only if you have opted in to telemetry.
+4 -1
View File
@@ -16,7 +16,10 @@ class ServerConfig(AuthsomeConfig):
port: int = 7998
# Store
database_url: str | None = Field(default=None, validation_alias="DATABASE_URL")
database_url: str | None = Field(
default=None,
validation_alias=AliasChoices("AUTHSOME_DATABASE_URL", "DATABASE_URL"),
)
redis_url: str | None = None
postgres_pool_min_size: int = Field(default=1, ge=1)
postgres_pool_max_size: int = Field(default=10, ge=1)
+9 -3
View File
@@ -1,5 +1,8 @@
"""Health and readiness routes."""
from contextlib import suppress
from uuid import uuid4
from fastapi import APIRouter, Depends, Request
from authsome import __version__
@@ -96,10 +99,10 @@ async def _check_providers_and_connections(
async def _check_vault(vault, checks: dict[str, str], issues: list[str]) -> None:
probe_key = f"__ready_test__:{uuid4()}"
try:
await vault.put("__ready_test__", "ok", collection="vault:__ready__")
value = await vault.get("__ready_test__", collection="vault:__ready__")
await vault.delete("__ready_test__", collection="vault:__ready__")
await vault.put(probe_key, "ok", collection="vault:__ready__")
value = await vault.get(probe_key, collection="vault:__ready__")
if value != "ok":
issues.append("vault: readiness roundtrip failed")
checks["vault"] = "failed"
@@ -115,6 +118,9 @@ async def _check_vault(vault, checks: dict[str, str], issues: list[str]) -> None
checks["vault"] = "failed"
checks["integrity"] = "failed"
issues.append(f"vault: {exc}")
finally:
with suppress(Exception):
await vault.delete(probe_key, collection="vault:__ready__")
@router.get("/ready", response_model=ReadyResponse)
+10
View File
@@ -13,6 +13,7 @@ import aiosqlite
from authsome.server.config import get_server_config
StoreBackend = Literal["sqlite", "postgres"]
_POSTGRES_SCHEMA_LOCK_ID = 715_504_817_119_338_103
@dataclass(frozen=True)
@@ -269,6 +270,15 @@ def build_schema(backend: StoreBackend) -> list[str]:
async def initialize_schema(database: StoreDatabase) -> None:
if database.backend == "postgres":
async with database.transaction():
await database.execute("SELECT pg_advisory_xact_lock(?)", [_POSTGRES_SCHEMA_LOCK_ID])
await _apply_schema_migrations(database)
return
await _apply_schema_migrations(database)
async def _apply_schema_migrations(database: StoreDatabase) -> None:
await database.execute("CREATE TABLE IF NOT EXISTS store_schema_version (version INTEGER PRIMARY KEY)")
applied_rows = await database.fetch_all("SELECT version FROM store_schema_version")
applied = {int(row["version"]) for row in applied_rows}
+18 -4
View File
@@ -3,7 +3,7 @@
import builtins
import json
from key_value.aio.protocols.key_value import AsyncKeyValue
from key_value.aio.protocols.key_value import AsyncEnumerateKeysProtocol, AsyncKeyValue
class Vault:
@@ -39,6 +39,20 @@ class Vault:
async def _save_index(self, collection: str, keys: builtins.list[str]) -> None:
await self._kv.put("__index__", {"data": json.dumps(sorted(keys))}, collection=collection)
def _enumerable_kv(self) -> AsyncEnumerateKeysProtocol | None:
if isinstance(self._kv, AsyncEnumerateKeysProtocol):
return self._kv
wrapped = getattr(self._kv, "key_value", None)
if isinstance(wrapped, AsyncEnumerateKeysProtocol):
return wrapped
return None
async def _list_indexed_keys(self, collection: str) -> builtins.list[str]:
enumerable_kv = self._enumerable_kv()
if enumerable_kv is not None:
return sorted(key for key in await enumerable_kv.keys(collection=collection) if key != "__index__")
return await self._get_index(collection)
# ── Encrypted KV interface ────────────────────────────────────────────
async def get(self, key: str, *, collection: str) -> str | None:
@@ -51,7 +65,7 @@ class Vault:
async def put(self, key: str, value: str, *, collection: str) -> None:
"""Encrypt and store a value."""
await self._kv.put(key, {"data": value}, collection=collection)
if key != "__index__":
if key != "__index__" and self._enumerable_kv() is None:
idx = set(await self._get_index(collection))
if key not in idx:
idx.add(key)
@@ -60,7 +74,7 @@ class Vault:
async def delete(self, key: str, *, collection: str) -> bool:
"""Delete a key. Returns True if the key existed."""
existed = await self._kv.delete(key, collection=collection)
if existed and key != "__index__":
if existed and key != "__index__" and self._enumerable_kv() is None:
idx = set(await self._get_index(collection))
idx.discard(key)
await self._save_index(collection, builtins.list(idx))
@@ -68,7 +82,7 @@ class Vault:
async def list(self, prefix: str = "", *, collection: str) -> builtins.list[str]:
"""List all keys matching a prefix within a collection."""
idx = await self._get_index(collection)
idx = await self._list_indexed_keys(collection)
if prefix:
return [k for k in idx if k.startswith(prefix)]
return builtins.list(idx)
@@ -8,6 +8,7 @@ from authsome.server.store.database import (
StoreDatabase,
StoreDatabaseConfig,
build_migrations,
initialize_schema,
open_store_database,
resolve_store_database_config,
)
@@ -150,3 +151,19 @@ async def test_postgres_transaction_uses_single_pooled_connection(tmp_path: Path
assert pool.acquire_count == 1
assert connection.execute_calls == [("INSERT INTO audit_events (event_id) VALUES ($1)", ("evt_1",))]
@pytest.mark.asyncio
async def test_postgres_migrations_take_advisory_lock(tmp_path: Path) -> None:
config = StoreDatabaseConfig(backend="postgres", dsn="postgresql://localhost:5432/authsome", home=tmp_path)
connection = _FakeConnection()
db = StoreDatabase(config=config, connection=connection)
try:
await initialize_schema(db)
finally:
await db.close()
assert connection.transaction_enters == 1
assert connection.transaction_exits == 1
assert connection.execute_calls[0][0] == "SELECT pg_advisory_xact_lock($1)"
assert isinstance(connection.execute_calls[0][1][0], int)
+19
View File
@@ -13,6 +13,25 @@ def test_server_config_reads_redis_url(monkeypatch) -> None:
assert config.redis_url == "redis://localhost:6379/0"
def test_server_config_reads_authsome_database_url(monkeypatch) -> None:
monkeypatch.setenv("AUTHSOME_DATABASE_URL", "postgresql://authsome:secret@localhost/authsome")
monkeypatch.delenv("DATABASE_URL", raising=False)
config = ServerConfig()
assert config.database_url == "postgresql://authsome:secret@localhost/authsome"
assert config.database == "postgresql://authsome:secret@localhost/authsome"
def test_server_config_keeps_legacy_database_url_alias(monkeypatch) -> None:
monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False)
monkeypatch.setenv("DATABASE_URL", "postgresql://legacy:secret@localhost/authsome")
config = ServerConfig()
assert config.database_url == "postgresql://legacy:secret@localhost/authsome"
def test_server_config_exposes_postgres_pool_settings(monkeypatch) -> None:
monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MIN_SIZE", "2")
monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MAX_SIZE", "9")
+86
View File
@@ -0,0 +1,86 @@
import json
from collections.abc import Mapping, Sequence
from typing import Any, SupportsFloat
import pytest
from authsome.vault import Vault
class EnumerableKv:
def __init__(self) -> None:
self.data: dict[str, dict[str, dict[str, Any]]] = {}
async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None:
return self.data.get(collection or "default_collection", {}).get(key)
async def put(
self,
key: str,
value: Mapping[str, Any],
*,
collection: str | None = None,
ttl: SupportsFloat | None = None,
) -> None:
_ = ttl
self.data.setdefault(collection or "default_collection", {})[key] = dict(value)
async def delete(self, key: str, *, collection: str | None = None) -> bool:
values = self.data.setdefault(collection or "default_collection", {})
existed = key in values
values.pop(key, None)
return existed
async def get_many(self, keys: Sequence[str], *, collection: str | None = None) -> list[dict[str, Any] | None]:
return [await self.get(key, collection=collection) for key in keys]
async def put_many(
self,
keys: Sequence[str],
values: Sequence[Mapping[str, Any]],
*,
collection: str | None = None,
ttl: SupportsFloat | None = None,
) -> None:
for key, value in zip(keys, values, strict=True):
await self.put(key, value, collection=collection, ttl=ttl)
async def delete_many(self, keys: Sequence[str], *, collection: str | None = None) -> int:
deleted = 0
for key in keys:
if await self.delete(key, collection=collection):
deleted += 1
return deleted
async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]:
return await self.get(key, collection=collection), None
async def ttl_many(
self,
keys: Sequence[str],
*,
collection: str | None = None,
) -> list[tuple[dict[str, Any] | None, float | None]]:
return [await self.ttl(key, collection=collection) for key in keys]
async def keys(self, collection: str | None = None, *, limit: int | None = None) -> list[str]:
keys = sorted(self.data.get(collection or "default_collection", {}))
return keys[:limit] if limit is not None else keys
@pytest.mark.asyncio
async def test_vault_lists_from_enumerable_backend_instead_of_manual_index() -> None:
kv = EnumerableKv()
vault = Vault(kv)
await vault.put("beta", "2", collection="vault:vault_1")
await vault.put("alpha", "1", collection="vault:vault_1")
kv.data["vault:vault_1"]["__index__"] = {"data": json.dumps(["stale"])}
assert await vault.list(collection="vault:vault_1") == ["alpha", "beta"]
assert await vault.list("alp", collection="vault:vault_1") == ["alpha"]
await vault.delete("alpha", collection="vault:vault_1")
assert await vault.list(collection="vault:vault_1") == ["beta"]
assert kv.data["vault:vault_1"]["__index__"] == {"data": json.dumps(["stale"])}