rmi-backend/app/config.py
opencode 9c62549b50
Some checks failed
CI / build (push) Failing after 2s
fix(rmi-backend,audit): jwt_secret is required, fail-fast in prod (P1.5)
Previously: jwt_secret defaulted to dev-secret-CHANGE-ME so a missing
env var in production would silently boot with a known-public dev HMAC
key — an instant auth bypass for any service verifying JWTs.

Now:
- Field(...) with no default → missing env raises ValidationError at boot
- min_length=32 enforces minimum entropy (rejects dev placeholder too)
- field_validator rejects the literal dev-secret-CHANGE-ME in ENVIRONMENT=prod
  as belt-and-suspenders in case min_length is loosened later

Verified behavior:
- no env, no .env                 -> ValidationError: Field required
- ENVIRONMENT=prod, no JWT_SECRET -> ValidationError: Field required
- prod, JWT_SECRET=dev-secret-... -> ValidationError: string_too_short
- prod, JWT_SECRET=short          -> ValidationError: string_too_short
- prod, JWT_SECRET=<64 hex>       -> OK, len=64

.env.example updated to show the placeholder + generation hint.

Refs AUDIT-2026-Q3.md P1.5.
2026-07-06 17:53:49 +02:00

102 lines
4.1 KiB
Python

"""Application configuration.
Pydantic-settings reads from environment variables (and .env in dev).
Import: `from app.config import settings`
"""
from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""All runtime configuration. Add new env vars here as needed."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── Runtime ──────────────────────────────────────────────────────
environment: Literal["dev", "staging", "prod"] = "prod"
log_level: str = "INFO"
port: int = 8000
# ── Database / Cache ────────────────────────────────────────────
database_url: str = "postgresql+asyncpg://rmi:rmi@localhost/rmi"
redis_url: str = "redis://localhost:6379/0"
# ── Auth ────────────────────────────────────────────────────────
jwt_secret: str = Field( # required, no default, fail-fast in prod
min_length=32,
description=(
"HMAC secret for JWT. Required. Must be >= 32 chars. "
"Generate with: openssl rand -hex 32. "
"Set via gopass (rmi/prod/jwt_secret) or JWT_SECRET env var."
),
)
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
# ── CORS ────────────────────────────────────────────────────────
cors_origins: list[str] = ["*"]
# ── Rate limiting ───────────────────────────────────────────────
rate_limit_per_minute: int = 100
# ── AI providers ────────────────────────────────────────────────
ollama_url: str = "http://localhost:11434"
openrouter_api_key: str = ""
huggingface_token: str = ""
# ── Langfuse v4 (observability) ─────────────────────────────────
langfuse_public_key: str = ""
langfuse_secret_key: str = ""
langfuse_host: str = "http://localhost:3002"
# ── External APIs ───────────────────────────────────────────────
coingecko_api_key: str = ""
etherscan_api_key: str = ""
birdeye_api_key: str = ""
goplus_api_key: str = ""
# ── RMI-specific ────────────────────────────────────────────────
rag_collections: list[str] = [
"scam_intel",
"deployer_history",
"wallet_labels",
"contract_audit",
"phishing_db",
]
@field_validator("jwt_secret")
@classmethod
def _jwt_secret_must_be_set_in_prod(cls, v: str) -> str:
"""Reject the dev default in production.
Pydantic-settings binds v from env (or rejects via min_length above).
Here we only enforce the dev placeholder cannot leak into a prod boot.
"""
if v == "dev-secret-CHANGE-ME" and os.getenv("ENVIRONMENT", "dev") == "prod":
raise ValueError(
"jwt_secret must be set to a non-default value in production. "
"Set via gopass (rmi/prod/jwt_secret) or JWT_SECRET env var."
)
return v
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Cached settings instance."""
return Settings()
# Module-level singleton for convenience.
settings = get_settings()