diff --git a/CLAUDE.md b/CLAUDE.md index 00e567d606..e614fa64b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,8 +128,8 @@ src/synthorg/ persistence/ # Operational data persistence — pluggable PersistenceBackend protocol, SQLite initial, SettingsRepository (namespaced settings CRUD) (see Memory & Persistence design page) observability/ # Structured logging, correlation tracking, log sinks providers/ # LLM provider abstraction (LiteLLM adapter) - settings/ # Runtime-editable settings persistence (DB > env > YAML > code defaults), typed definitions (8 namespaces), Fernet encryption for sensitive values, config bridge, ConfigResolver (typed composed reads for controllers), validation, registry, change notifications via message bus - definitions/ # Per-namespace setting definitions (company, providers, memory, budget, security, coordination, observability, backup) + settings/ # Runtime-editable settings persistence (DB > env > YAML > code defaults), typed definitions (9 namespaces), Fernet encryption for sensitive values, config bridge, ConfigResolver (typed composed reads for controllers), validation, registry, change notifications via message bus + definitions/ # Per-namespace setting definitions (api, company, providers, memory, budget, security, coordination, observability, backup) security/ # SecOps agent, rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, output scan response policies (redact/withhold/log-only/autonomy-tiered), risk classifier, risk tier classifier, action type registry, ToolInvoker security integration, progressive trust (4 strategies: disabled/weighted/per-category/milestone), autonomy levels (presets, resolver, change strategy), timeout policies (park/resume) templates/ # Pre-built company templates, personality presets, and builder tools/ # Tool registry, built-in tools (file_system/, git, sandbox/, code_runner), git clone SSRF prevention (git_url_validator), MCP bridge (mcp/), role-based access, approval tool (request_human_approval) diff --git a/docs/design/operations.md b/docs/design/operations.md index ae9b8e7bcd..1875434ebf 100644 --- a/docs/design/operations.md +++ b/docs/design/operations.md @@ -1041,7 +1041,7 @@ and retry guidance. - **Budget Panel**: Spending charts, per-agent breakdown (projections/alerts planned) - **Meeting Logs**: Placeholder — coming soon - **Artifact Browser**: Placeholder — coming soon -- **Settings**: Runtime-editable configuration via DB-backed settings persistence (8 namespaces: company, providers, memory, budget, security, coordination, observability, backup). 4-layer resolution (DB > env > YAML > code defaults), Fernet encryption for sensitive values, REST API (GET/PUT/DELETE + schema endpoints for dynamic UI generation), change notifications via message bus. `ConfigResolver` provides typed composed reads for API controllers (assembles full Pydantic config models from individually resolved settings, using `asyncio.TaskGroup` for parallel resolution) +- **Settings**: Runtime-editable configuration via DB-backed settings persistence (9 namespaces: api, company, providers, memory, budget, security, coordination, observability, backup). 4-layer resolution (DB > env > YAML > code defaults), Fernet encryption for sensitive values, REST API (GET/PUT/DELETE + schema endpoints for dynamic UI generation), change notifications via message bus. `ConfigResolver` provides typed composed reads for API controllers (assembles full Pydantic config models from individually resolved settings, using `asyncio.TaskGroup` for parallel resolution) ### Human Roles diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index 40af765d4e..a8b713d6e0 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -472,6 +472,27 @@ async def _safe_shutdown( ) +# ── 2-Phase Initialisation ──────────────────────────────────────── +# +# Phase 1 (construct): Litestar bakes middleware, CORS, and routes +# into the app at construction time — these read directly from +# RootConfig and are immutable after construction. Bootstrap-only +# settings (server_host, server_port, api_prefix, cors_allowed_origins, +# rate_limit_exclude_paths, auth_exclude_paths) are therefore NOT +# resolved through SettingsService. +# +# Phase 2 (on_startup): After persistence connects and migrations +# run, SettingsService + ConfigResolver become available. Runtime- +# editable settings (rate_limit_max_requests, rate_limit_time_unit, +# jwt_expiry_minutes, min_password_length) are resolved through +# ConfigResolver.get_api_config() by consumers that need current +# values post-startup. +# +# Note: Litestar's rate-limit middleware reads max_requests and +# time_unit at construction; runtime DB changes are visible only +# to code calling get_api_config(), not to the middleware itself. + + def create_app( # noqa: PLR0913 *, config: RootConfig | None = None, diff --git a/src/synthorg/settings/definitions/__init__.py b/src/synthorg/settings/definitions/__init__.py index b28aab078e..068808f3ee 100644 --- a/src/synthorg/settings/definitions/__init__.py +++ b/src/synthorg/settings/definitions/__init__.py @@ -5,6 +5,7 @@ """ from synthorg.settings.definitions import ( + api, backup, budget, company, @@ -16,6 +17,7 @@ ) __all__ = [ + "api", "backup", "budget", "company", diff --git a/src/synthorg/settings/definitions/api.py b/src/synthorg/settings/definitions/api.py new file mode 100644 index 0000000000..3d223ad435 --- /dev/null +++ b/src/synthorg/settings/definitions/api.py @@ -0,0 +1,159 @@ +"""API namespace setting definitions. + +Registers 10 settings covering server, CORS, rate limiting, and +authentication. Four are runtime-editable; six are bootstrap-only +(``restart_required=True``) because Litestar bakes middleware and +CORS into the application at construction time. +""" + +from synthorg.settings.enums import SettingLevel, SettingNamespace, SettingType +from synthorg.settings.models import SettingDefinition +from synthorg.settings.registry import get_registry + +_r = get_registry() + +# ── Server (bootstrap-only) ────────────────────────────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="server_host", + type=SettingType.STRING, + default="127.0.0.1", + description="Server bind address", + group="Server", + restart_required=True, + yaml_path="api.server.host", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="server_port", + type=SettingType.INTEGER, + default="8000", + description="Server bind port", + group="Server", + restart_required=True, + min_value=1, + max_value=65535, + yaml_path="api.server.port", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="api_prefix", + type=SettingType.STRING, + default="/api/v1", + description="URL prefix for all API routes", + group="Server", + level=SettingLevel.ADVANCED, + restart_required=True, + yaml_path="api.api_prefix", + ) +) + +# ── CORS (bootstrap-only) ──────────────────────────────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="cors_allowed_origins", + type=SettingType.JSON, + default='["http://localhost:5173"]', + description="Origins permitted to make cross-origin requests", + group="CORS", + restart_required=True, + yaml_path="api.cors.allowed_origins", + ) +) + +# ── Rate Limiting (exclude_paths: bootstrap-only) ──────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="rate_limit_max_requests", + type=SettingType.INTEGER, + default="100", + description="Maximum requests per time window", + group="Rate Limiting", + min_value=1, + max_value=10000, + yaml_path="api.rate_limit.max_requests", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="rate_limit_time_unit", + type=SettingType.ENUM, + default="minute", + description="Rate limit time window", + group="Rate Limiting", + enum_values=("second", "minute", "hour", "day"), + yaml_path="api.rate_limit.time_unit", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="rate_limit_exclude_paths", + type=SettingType.JSON, + default='["/api/v1/health"]', + description="Paths excluded from rate limiting", + group="Rate Limiting", + level=SettingLevel.ADVANCED, + restart_required=True, + yaml_path="api.rate_limit.exclude_paths", + ) +) + +# ── Authentication (exclude_paths: bootstrap-only) ─────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="jwt_expiry_minutes", + type=SettingType.INTEGER, + default="1440", + description="JWT token lifetime in minutes", + group="Authentication", + min_value=1, + max_value=10080, + yaml_path="api.auth.jwt_expiry_minutes", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="min_password_length", + type=SettingType.INTEGER, + default="12", + description="Minimum password length for setup and password change", + group="Authentication", + min_value=12, + max_value=128, + yaml_path="api.auth.min_password_length", + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="auth_exclude_paths", + type=SettingType.JSON, + default="[]", + description="Paths excluded from authentication middleware", + group="Authentication", + level=SettingLevel.ADVANCED, + restart_required=True, + yaml_path="api.auth.exclude_paths", + ) +) diff --git a/src/synthorg/settings/enums.py b/src/synthorg/settings/enums.py index 5dbe9dcb33..f342385990 100644 --- a/src/synthorg/settings/enums.py +++ b/src/synthorg/settings/enums.py @@ -10,6 +10,7 @@ class SettingNamespace(StrEnum): can be edited at runtime via the settings API. """ + API = "api" COMPANY = "company" PROVIDERS = "providers" MEMORY = "memory" diff --git a/src/synthorg/settings/resolver.py b/src/synthorg/settings/resolver.py index 8e9743f130..94efc31ac4 100644 --- a/src/synthorg/settings/resolver.py +++ b/src/synthorg/settings/resolver.py @@ -20,6 +20,7 @@ from synthorg.settings.errors import SettingNotFoundError if TYPE_CHECKING: + from synthorg.api.config import ApiConfig from synthorg.budget.config import BudgetAlertConfig, BudgetConfig from synthorg.config.schema import RootConfig from synthorg.core.enums import AutonomyLevel @@ -328,6 +329,71 @@ async def get_budget_config(self) -> BudgetConfig: }, ) + async def get_api_config(self) -> ApiConfig: + """Assemble an ``ApiConfig`` with runtime-editable overrides. + + Resolves the four runtime-editable API settings (rate-limit + max requests, rate-limit time unit, JWT expiry, min password + length) and merges them onto the YAML-loaded base config. + + Bootstrap-only settings (``server_host``, ``server_port``, + ``api_prefix``, ``cors_allowed_origins``, + ``rate_limit_exclude_paths``, ``auth_exclude_paths``) are + **not** resolved — they are baked into the Litestar app at + construction and require a restart to take effect. + + Uses ``asyncio.TaskGroup`` to resolve all settings in parallel. + + Returns: + An ``ApiConfig`` with DB/env overrides applied to the + runtime-editable fields. + + Raises: + SettingNotFoundError: If a required API setting is + missing from the registry. + ValueError: If a resolved value cannot be parsed. + """ + from synthorg.api.config import RateLimitTimeUnit # noqa: PLC0415 + + base = self._config.api + + try: + async with asyncio.TaskGroup() as tg: + t_max_req = tg.create_task( + self.get_int("api", "rate_limit_max_requests") + ) + t_time_unit = tg.create_task( + self.get_enum("api", "rate_limit_time_unit", RateLimitTimeUnit) + ) + t_jwt_exp = tg.create_task(self.get_int("api", "jwt_expiry_minutes")) + t_min_pw = tg.create_task(self.get_int("api", "min_password_length")) + except ExceptionGroup as eg: + logger.warning( + SETTINGS_FETCH_FAILED, + namespace="api", + key="_composed", + error_count=len(eg.exceptions), + exc_info=True, + ) + raise eg.exceptions[0] from eg + + return base.model_copy( + update={ + "rate_limit": base.rate_limit.model_copy( + update={ + "max_requests": t_max_req.result(), + "time_unit": t_time_unit.result(), + }, + ), + "auth": base.auth.model_copy( + update={ + "jwt_expiry_minutes": t_jwt_exp.result(), + "min_password_length": t_min_pw.result(), + }, + ), + }, + ) + async def get_coordination_config( self, *, diff --git a/tests/integration/settings/__init__.py b/tests/integration/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/settings/test_settings_integration.py b/tests/integration/settings/test_settings_integration.py new file mode 100644 index 0000000000..c950e9fc3f --- /dev/null +++ b/tests/integration/settings/test_settings_integration.py @@ -0,0 +1,114 @@ +"""Integration test: API settings round-trip through real persistence. + +Uses a real SQLite backend + SettingsService + ConfigResolver to +verify that DB overrides flow through the full resolution chain. +""" + +from typing import TYPE_CHECKING + +import pytest + +import synthorg.settings.definitions # noqa: F401 — trigger registration +from synthorg.config.schema import RootConfig +from synthorg.persistence.config import SQLiteConfig +from synthorg.persistence.sqlite.backend import SQLitePersistenceBackend +from synthorg.settings.registry import get_registry +from synthorg.settings.resolver import ConfigResolver +from synthorg.settings.service import SettingsService + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from pathlib import Path + + +@pytest.fixture +def db_path(tmp_path: Path) -> str: + """Return a temporary on-disk database path.""" + return str(tmp_path / "settings-test.db") + + +@pytest.fixture +async def backend(db_path: str) -> AsyncGenerator[SQLitePersistenceBackend]: + """Create a connected and migrated on-disk SQLite backend. + + Yields: + A ``SQLitePersistenceBackend`` ready for settings operations. + """ + be = SQLitePersistenceBackend(SQLiteConfig(path=db_path)) + await be.connect() + await be.migrate() + yield be + await be.disconnect() + + +@pytest.fixture +def config() -> RootConfig: + """Minimal root config with defaults.""" + return RootConfig(company_name="test-co") + + +@pytest.fixture +def settings_service( + backend: SQLitePersistenceBackend, + config: RootConfig, +) -> SettingsService: + """Real SettingsService wired to on-disk SQLite.""" + return SettingsService( + repository=backend.settings, + registry=get_registry(), + config=config, + ) + + +@pytest.fixture +def resolver( + settings_service: SettingsService, + config: RootConfig, +) -> ConfigResolver: + """Real ConfigResolver wired to real SettingsService.""" + return ConfigResolver( + settings_service=settings_service, + config=config, + ) + + +@pytest.mark.integration +@pytest.mark.timeout(30) +class TestApiSettingsIntegration: + """End-to-end test: DB override → SettingsService → ConfigResolver.""" + + async def test_db_override_flows_through_resolver( + self, + settings_service: SettingsService, + resolver: ConfigResolver, + ) -> None: + """Verify DB overrides flow through the full resolution chain. + + Writes a rate-limit override via ``SettingsService`` and reads + it back through ``ConfigResolver.get_api_config()``. + """ + await settings_service.set("api", "rate_limit_max_requests", "500") + + result = await resolver.get_api_config() + + assert result.rate_limit.max_requests == 500 + # Non-overridden fields keep code defaults + assert result.rate_limit.time_unit.value == "minute" + assert result.auth.jwt_expiry_minutes == 1440 + assert result.auth.min_password_length == 12 + + async def test_defaults_without_db_overrides( + self, + resolver: ConfigResolver, + ) -> None: + """Verify code defaults are used when no DB overrides exist. + + All four runtime-editable API settings should resolve to their + ``SettingDefinition`` defaults. + """ + result = await resolver.get_api_config() + + assert result.rate_limit.max_requests == 100 + assert result.rate_limit.time_unit.value == "minute" + assert result.auth.jwt_expiry_minutes == 1440 + assert result.auth.min_password_length == 12 diff --git a/tests/unit/settings/test_resolver.py b/tests/unit/settings/test_resolver.py index 27ab306845..f915d7b3d6 100644 --- a/tests/unit/settings/test_resolver.py +++ b/tests/unit/settings/test_resolver.py @@ -1,6 +1,7 @@ """Unit tests for ConfigResolver.""" from enum import StrEnum +from typing import Literal from unittest.mock import AsyncMock import pytest @@ -8,7 +9,9 @@ from hypothesis import strategies as st from pydantic import BaseModel, ConfigDict +from synthorg.api.config import RateLimitTimeUnit from synthorg.core.enums import AutonomyLevel +from synthorg.core.types import NotBlankStr from synthorg.settings.enums import SettingNamespace, SettingSource from synthorg.settings.errors import SettingNotFoundError from synthorg.settings.models import SettingValue @@ -70,12 +73,49 @@ class _CoordinationSection(BaseModel): base_branch: str = "main" +class _FakeAuthConfig(BaseModel): + model_config = ConfigDict(frozen=True) + jwt_secret: str = "" + jwt_algorithm: Literal["HS256", "HS384", "HS512"] = "HS256" + jwt_expiry_minutes: int = 1440 + min_password_length: int = 12 + exclude_paths: tuple[str, ...] | None = None + + +class _FakeRateLimitConfig(BaseModel): + model_config = ConfigDict(frozen=True) + max_requests: int = 100 + time_unit: RateLimitTimeUnit = RateLimitTimeUnit.MINUTE + exclude_paths: tuple[str, ...] = ("/api/v1/health",) + + +class _FakeCorsConfig(BaseModel): + model_config = ConfigDict(frozen=True) + allowed_origins: tuple[str, ...] = ("http://localhost:5173",) + + +class _FakeServerConfig(BaseModel): + model_config = ConfigDict(frozen=True) + host: str = "127.0.0.1" + port: int = 8000 + + +class _FakeApiConfig(BaseModel): + model_config = ConfigDict(frozen=True) + cors: _FakeCorsConfig = _FakeCorsConfig() + rate_limit: _FakeRateLimitConfig = _FakeRateLimitConfig() + server: _FakeServerConfig = _FakeServerConfig() + auth: _FakeAuthConfig = _FakeAuthConfig() + api_prefix: NotBlankStr = "/api/v1" + + class _CompanyConfig(BaseModel): model_config = ConfigDict(frozen=True) class _FakeRootConfig(BaseModel): model_config = ConfigDict(frozen=True) + api: _FakeApiConfig = _FakeApiConfig() budget: _BudgetConfig = _BudgetConfig() coordination: _CoordinationSection = _CoordinationSection() config: _CompanyConfig = _CompanyConfig() @@ -592,3 +632,174 @@ async def test_bool_roundtrip(self, b: bool) -> None: resolver, mock = _make_resolver() mock.get = AsyncMock(return_value=_make_value(str(b))) assert await resolver.get_bool("budget", "total_monthly") is b + + +# ── Composed Read: API Config ──────────────────────────────────── + + +def _api_get_side_effect( + overrides: dict[tuple[str, str], str] | None = None, +) -> AsyncMock: + """Create a mock .get() that returns API defaults with optional overrides.""" + defaults = { + ("api", "rate_limit_max_requests"): "100", + ("api", "rate_limit_time_unit"): "minute", + ("api", "jwt_expiry_minutes"): "1440", + ("api", "min_password_length"): "12", + } + merged = {**defaults, **(overrides or {})} + + async def _get(ns: str, key: str) -> SettingValue: + value = merged.get((ns, key)) + if value is None: + msg = f"Unknown: {ns}/{key}" + raise SettingNotFoundError(msg) + return _make_value(value, namespace=SettingNamespace(ns), key=key) + + return AsyncMock(side_effect=_get) + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestGetApiConfig: + """Tests for get_api_config() composed read.""" + + async def test_returns_api_config_from_defaults( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + mock_settings.get = _api_get_side_effect() + result = await resolver.get_api_config() + + assert result.rate_limit.max_requests == 100 + assert result.rate_limit.time_unit == RateLimitTimeUnit.MINUTE + assert result.auth.jwt_expiry_minutes == 1440 + assert result.auth.min_password_length == 12 + + async def test_db_overrides_take_precedence( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + mock_settings.get = _api_get_side_effect( + { + ("api", "rate_limit_max_requests"): "500", + ("api", "min_password_length"): "16", + } + ) + result = await resolver.get_api_config() + + assert result.rate_limit.max_requests == 500 + assert result.auth.min_password_length == 16 + # Non-overridden fields keep defaults + assert result.rate_limit.time_unit == RateLimitTimeUnit.MINUTE + assert result.auth.jwt_expiry_minutes == 1440 + + async def test_preserves_unregistered_fields( + self, + mock_settings: AsyncMock, + ) -> None: + """Bootstrap-only fields keep YAML values. + + Covers CORS, server, api_prefix, and exclude_paths. + """ + custom_config = _FakeRootConfig( + api=_FakeApiConfig( + cors=_FakeCorsConfig(allowed_origins=("https://example.com",)), + server=_FakeServerConfig(host="10.0.0.1", port=9000), + rate_limit=_FakeRateLimitConfig( + exclude_paths=("/health", "/custom"), + ), + auth=_FakeAuthConfig(exclude_paths=("/public",)), + api_prefix="/api/v2", + ), + ) + resolver = ConfigResolver( + settings_service=mock_settings, + config=custom_config, # type: ignore[arg-type] + ) + mock_settings.get = _api_get_side_effect() + result = await resolver.get_api_config() + + assert result.cors.allowed_origins == ("https://example.com",) + assert result.server.host == "10.0.0.1" + assert result.server.port == 9000 + assert result.rate_limit.exclude_paths == ("/health", "/custom") + assert result.auth.exclude_paths == ("/public",) + assert result.api_prefix == "/api/v2" + + async def test_not_found_propagates( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + """SettingNotFoundError unwrapped from ExceptionGroup.""" + mock_settings.get.side_effect = SettingNotFoundError("missing") + with pytest.raises(SettingNotFoundError): + await resolver.get_api_config() + + async def test_value_error_propagates( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + """ValueError from a corrupted DB value propagates directly.""" + mock_settings.get = _api_get_side_effect( + {("api", "rate_limit_max_requests"): "not-a-number"} + ) + with pytest.raises(ValueError, match="invalid"): + await resolver.get_api_config() + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("second", RateLimitTimeUnit.SECOND), + ("minute", RateLimitTimeUnit.MINUTE), + ("hour", RateLimitTimeUnit.HOUR), + ("day", RateLimitTimeUnit.DAY), + ], + ) + async def test_enum_resolution( + self, + resolver: ConfigResolver, + mock_settings: AsyncMock, + value: str, + expected: RateLimitTimeUnit, + ) -> None: + """rate_limit_time_unit resolves to the correct enum member.""" + mock_settings.get = _api_get_side_effect( + {("api", "rate_limit_time_unit"): value} + ) + result = await resolver.get_api_config() + + assert result.rate_limit.time_unit == expected + + async def test_partial_failure_unwraps_first_exception( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + """When one setting fails but others succeed, first exception propagates.""" + call_count = 0 + + async def _mixed_get(ns: str, key: str) -> SettingValue: + nonlocal call_count + call_count += 1 + if key == "jwt_expiry_minutes": + msg = "jwt_expiry_minutes" + raise SettingNotFoundError(msg) + defaults = { + ("api", "rate_limit_max_requests"): "100", + ("api", "rate_limit_time_unit"): "minute", + ("api", "min_password_length"): "12", + } + value = defaults.get((ns, key)) + if value is None: + msg = f"Unknown: {ns}/{key}" + raise SettingNotFoundError(msg) + return _make_value(value, namespace=SettingNamespace(ns), key=key) + + mock_settings.get = AsyncMock(side_effect=_mixed_get) + with pytest.raises(SettingNotFoundError, match="jwt_expiry_minutes"): + await resolver.get_api_config() + + async def test_invalid_enum_value_propagates( + self, resolver: ConfigResolver, mock_settings: AsyncMock + ) -> None: + """Invalid enum value for rate_limit_time_unit raises ValueError.""" + mock_settings.get = _api_get_side_effect( + {("api", "rate_limit_time_unit"): "weekly"} + ) + with pytest.raises(ValueError, match=r"invalid.*RateLimitTimeUnit"): + await resolver.get_api_config()