mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
fix: address production readiness review
This commit is contained in:
+2
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])}
|
||||
Reference in New Issue
Block a user