# Copyright (c) 2026 Juan Luna. All rights reserved. # SPDX-License-Identifier: LicenseRef-Aegis-Commercial-1.0 # Licensed under the Aegis Commercial License 1.0; see LICENSE and NOTICE. """ aegis_server.config — Enterprise configuration via environment variables. All settings are read from the environment (or a .env file). No secrets are stored in code; defaults are safe for local development only. Environment prefix: ``AEGIS_`` Dependency: pydantic-settings>=2.3.0 """ from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class EnterpriseSettings(BaseSettings): """ Enterprise-layer configuration. Storage provider, crypto signer, and upstream proxy settings are all controlled here. The existing ``aegis.config.AegisSettings`` handles WAF, rate-limiter, and per-request forensics thresholds; this class extends it with persistence and signing backends. """ model_config = SettingsConfigDict( env_prefix="AEGIS_", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ------------------------------------------------------------------ # Shared runtime enforcement # ------------------------------------------------------------------ security_enforcement_mode: Literal["strict", "development"] = Field(default="strict") require_durable_evidence: bool = Field(default=True) # ------------------------------------------------------------------ # Server # ------------------------------------------------------------------ host: str = Field(default="0.0.0.0", description="Bind address.") port: int = Field(default=8080, ge=1, le=65535, description="Bind port.") workers: int = Field(default=1, ge=1, le=64, description="Uvicorn worker count.") log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO") cors_origins: str = Field( default="", description="Comma-separated allowed CORS origins. Empty = no CORS.", ) trusted_proxy_cidrs: str = Field( default="127.0.0.1,::1", description=( "Comma-separated IPs/CIDRs uvicorn trusts to set X-Forwarded-For / " "X-Forwarded-Proto (passed through as forwarded_allow_ips). Name the " "reverse proxy's real address here rather than accepting a wildcard: " "trusting '*' lets any direct client spoof its own source IP and " "scheme, which downstream IP allowlisting and HTTPS-required checks " "rely on. '*' is rejected at startup in strict mode." ), ) # ------------------------------------------------------------------ # Body limits (AUD-09) # ------------------------------------------------------------------ # The enterprise surface fronts the gateway, so it must not be the weaker # of the two: both limits are enforced on this surface as well. Bounds and # defaults mirror aegis.config.AegisSettings so one environment variable # (AEGIS_MAX_REQUEST_BODY_BYTES) governs both surfaces. max_request_body_bytes: int = Field( default=1_048_576, ge=1_024, le=16_777_216, description=( "Streaming HTTP request body limit, enforced before JSON parsing. " "Request bodies over this size are refused with 413." ), ) max_response_body_bytes: int = Field( default=16_777_216, ge=1_024, le=268_435_456, description=( "Upstream response size limit for the proxied chat-completions path, " "counted while the response is streamed. Larger upstream responses are " "refused with 502 and the refusal is durably evidenced." ), ) # ------------------------------------------------------------------ # Authentication # ------------------------------------------------------------------ api_keys: str = Field( default="", description=( "Comma-separated proxy API keys. At least one is required in " "production. Empty disables authentication (development only)." ), ) audit_api_keys: str = Field( default="", description=( "Comma-separated read-only keys for /v1/audit/*. Inherits api_keys when empty." ), ) auth_disabled: bool = Field( default=False, description="Disable all authentication. Never set True in production.", ) # ------------------------------------------------------------------ # Upstream backend # ------------------------------------------------------------------ backend_url: str = Field( default="http://localhost:11434", description="OpenAI-compatible upstream LLM endpoint.", ) backend_api_key: SecretStr = Field( default=SecretStr(""), description="API key forwarded to the upstream backend.", ) backend_timeout_seconds: float = Field(default=120.0, gt=0) backend_connect_timeout_seconds: float = Field(default=10.0, gt=0) # ------------------------------------------------------------------ # Forensics # ------------------------------------------------------------------ force_logprobs: bool = Field( default=True, description=( "Inject logprobs=true, top_logprobs=N into every request. " "Increases upstream payload size by ~3–8× for long completions." ), ) top_logprobs: int = Field(default=20, ge=1, le=20) kl_alert_threshold: float = Field(default=2.0, gt=0) js_alert_threshold: float = Field(default=0.5, gt=0, le=1) entropy_alert_threshold_bits: float = Field(default=1.0, gt=0) request_entropy_guard: bool = Field( default=False, description="Block requests whose payload entropy is below the minimum band.", ) webhook_url: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-066).]** " "POST alert payloads here (Slack, PagerDuty, SIEM ingest). " "Not read by any code path; see AUD-35 in ROADMAP.md." ), ) # ------------------------------------------------------------------ # Storage provider # ------------------------------------------------------------------ storage_provider: Literal["sqlite", "postgres", "dynamodb"] = Field( default="sqlite", description=( "Persistence backend for the audit chain. " "sqlite = local WAL-mode file; " "postgres = asyncpg connection pool; " "dynamodb = AWS DynamoDB via aioboto3." ), ) # SQLite sqlite_path: str = Field( default="./aegis_audit.db", description="Path to the SQLite audit database file.", ) # PostgreSQL postgres_dsn: str = Field( default="", description=( "asyncpg DSN, e.g. " "postgresql://user:pass@host:5432/aegis_audit. " "Required when storage_provider=postgres." ), ) postgres_min_pool_size: int = Field(default=2, ge=1, le=64) postgres_max_pool_size: int = Field(default=10, ge=1, le=256) # DynamoDB dynamodb_table: str = Field( default="aegis-audit-nodes", description="DynamoDB table name. Table + GSI must be pre-created.", ) dynamodb_region: str = Field( default="us-east-1", description="AWS region for DynamoDB.", ) dynamodb_endpoint_url: str = Field( default="", description=( "Override DynamoDB endpoint (e.g. http://localhost:8000 for " "DynamoDB Local). Empty = use AWS default." ), ) # ------------------------------------------------------------------ # Cryptographic signing provider # ------------------------------------------------------------------ signer_provider: Literal["hmac", "vault"] = Field( default="hmac", description=( "Signing backend. hmac = local HMAC-SHA256 (development / " "self-hosted); vault = HashiCorp Vault Transit Engine (enterprise)." ), ) # HMAC signer hmac_signing_key: SecretStr = Field( default=SecretStr(""), description=( "HMAC signing key. Required when signer_provider=hmac and no keyring " "path is configured. Minimum 32 bytes of entropy. Never use the default." ), ) hmac_keyring_path: str = Field( default="", description=( "Optional owner-readable JSON keyring for zero-restart HMAC rotation. " "When set, it takes precedence over hmac_signing_key." ), ) hmac_keyring_reload_interval_s: float = Field( default=1.0, ge=0.0, le=300.0, description="Minimum interval between keyring metadata reload attempts.", ) # HashiCorp Vault Transit vault_url: str = Field( default="", description=( "Vault server URL, e.g. https://vault.corp.example.com. " "Required when signer_provider=vault." ), ) vault_token: SecretStr = Field( default=SecretStr(""), description="Vault token (short-lived; prefer AppRole in production).", ) vault_role_id: str = Field( default="", description="Vault AppRole RoleID (takes precedence over token).", ) vault_secret_id: SecretStr = Field( default=SecretStr(""), description="Vault AppRole SecretID.", ) vault_transit_key: str = Field( default="aegis-signing-key", description="Name of the Transit secrets engine key used for signing.", ) vault_transit_mount: str = Field( default="transit", description="Mount path for the Transit secrets engine.", ) vault_max_retries: int = Field( default=3, ge=0, le=10, description="Maximum retry attempts on transient Vault failures.", ) vault_retry_base_delay_s: float = Field( default=0.25, gt=0, description="Base delay (seconds) for exponential retry backoff.", ) vault_namespace: str = Field( default="", description="Vault Enterprise namespace (empty = root namespace).", ) # ------------------------------------------------------------------ # Compliance exporter # ------------------------------------------------------------------ compliance_export_dir: str = Field( default="./aegis_exports", description="Directory where compliance export bundles are written.", ) # ------------------------------------------------------------------ # Validators # ------------------------------------------------------------------ @field_validator("api_keys", "audit_api_keys", mode="before") @classmethod def _strip_keys(cls, v: str) -> str: return v.strip() if isinstance(v, str) else v @model_validator(mode="after") def _check_required_settings(self) -> EnterpriseSettings: if self.storage_provider == "postgres" and not self.postgres_dsn: raise ValueError("AEGIS_POSTGRES_DSN is required when AEGIS_STORAGE_PROVIDER=postgres") if self.signer_provider == "vault" and not self.vault_url: raise ValueError("AEGIS_VAULT_URL is required when AEGIS_SIGNER_PROVIDER=vault") if self.signer_provider == "vault": has_token = bool(self.vault_token.get_secret_value()) has_approle = bool(self.vault_role_id and self.vault_secret_id.get_secret_value()) if not has_token and not has_approle: raise ValueError( "When AEGIS_SIGNER_PROVIDER=vault, provide either " "AEGIS_VAULT_TOKEN or both AEGIS_VAULT_ROLE_ID + " "AEGIS_VAULT_SECRET_ID" ) return self # ------------------------------------------------------------------ # Convenience # ------------------------------------------------------------------ def validate_runtime_invariants(self) -> None: """Fail before serving when enterprise evidence controls are incomplete.""" if self.security_enforcement_mode != "strict": return if "*" in self.get_trusted_proxy_cidrs(): raise ValueError( "strict runtime cannot trust '*' in AEGIS_TRUSTED_PROXY_CIDRS: " "that lets any direct client spoof X-Forwarded-For/-Proto and " "impersonate a trusted reverse proxy. Name the proxy's real " "address or CIDR instead." ) if self.auth_disabled: raise ValueError("strict runtime cannot disable authentication") if not self.require_durable_evidence: raise ValueError("strict runtime requires durable evidence") if not self.get_api_keys(): raise ValueError("strict runtime requires at least one API key") if self.signer_provider == "hmac": if self.hmac_keyring_path: return key = self.hmac_signing_key.get_secret_value() if len(key.encode("utf-8")) < 32: raise ValueError("strict runtime requires an HMAC key with at least 32 bytes") if self.signer_provider == "vault" and not self.vault_url: raise ValueError("strict runtime requires Vault signer URL") def get_trusted_proxy_cidrs(self) -> tuple[str, ...]: """Parsed AEGIS_TRUSTED_PROXY_CIDRS, passed through to uvicorn as ``forwarded_allow_ips``. May legitimately contain a literal ``"*"`` if an operator set one; ``validate_runtime_invariants`` is what refuses that in strict mode, not this parse.""" return tuple( value.strip() for value in self.trusted_proxy_cidrs.split(",") if value.strip() ) def get_api_keys(self) -> set[str]: """Return the set of valid proxy API keys (stripped, non-empty).""" if not self.api_keys: return set() return {k.strip() for k in self.api_keys.split(",") if k.strip()} def get_audit_api_keys(self) -> set[str]: """Return the set of valid audit-only API keys.""" raw = self.audit_api_keys or self.api_keys if not raw: return set() return {k.strip() for k in raw.split(",") if k.strip()} @lru_cache(maxsize=1) def get_settings() -> EnterpriseSettings: """Return a process-level singleton of EnterpriseSettings.""" return EnterpriseSettings()