Coverage for src/lexigram/web/config/top_level.py: 74%
61 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Top-level web configuration."""
3from __future__ import annotations
5from dataclasses import dataclass
6import os
7from typing import ClassVar, cast
9from lexigram.config import BaseConfig
10from lexigram.validation import (
11 ConfigDict,
12 Field,
13 SecretStr,
14 model_validator,
15)
16from lexigram.web import constants as const
17from lexigram.web.config.api_docs import APIDocsConfig, StaticFileConfig
18from lexigram.web.config.rate_limit import RateLimitConfig, RoleGuardConfig
19from lexigram.web.config.server import ServerConfig
20from lexigram.web.security.config import (
21 CORSConfig,
22 CSRFConfig,
23 SecurityConfig,
24)
27@dataclass(init=False)
28class WebConfig(BaseConfig):
29 """Hierarchical root configuration for Lexigram Web.
31 This is the single source of truth for web configuration,
32 loaded from application.yaml's `web` section.
34 Attributes:
35 name: Configuration name (default: "web")
36 enabled: Whether the web module is enabled
37 server: Server binding and worker settings
38 app: Application-level settings (OpenAPI, CORS origins, etc.)
39 security: Security headers and policies
40 cors: CORS configuration
41 rate_limit: Rate limiting rules
42 debug_routes: Enable /debug/* endpoints
43 enable_identity_resolution: Resolve OAuth external IDs to internal UUIDs
44 enable_auth: Enable built-in authentication middleware
45 auth_exclude_paths: Paths excluded from authentication
46 """
48 config_section: ClassVar[str] = "web"
50 model_config = cast(
51 "ConfigDict",
52 {
53 "env_prefix": "LEX_WEB__",
54 "env_nested_delimiter": "__",
55 "extra": "ignore",
56 },
57 )
59 name: str = "web"
60 enabled: bool = True
61 env: str | None = Field(
62 default=None,
63 description="Environment (development/staging/production)",
64 )
65 server: ServerConfig = Field(default_factory=ServerConfig)
66 security: SecurityConfig = Field(
67 default_factory=lambda: SecurityConfig(
68 csrf=CSRFConfig(
69 enabled=True,
70 excluded_paths=["/health", "/metrics", "/admin"],
71 ),
72 ),
73 description="Security configuration (HSTS, CSP, cross-origin, CSRF, headers)",
74 )
75 cors: CORSConfig = Field(
76 default_factory=lambda: CORSConfig(
77 allowed_origins=["http://localhost:3000", "http://localhost:8001"],
78 allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
79 ),
80 )
81 static: StaticFileConfig = Field(default_factory=StaticFileConfig)
82 rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
84 # OpenAPI settings (formerly in WebAppConfig)
85 openapi_title: str = Field(default="API", description="OpenAPI Title")
86 openapi_version: str = Field(default="1.0.0", description="OpenAPI Version")
87 openapi_url: str | None = Field(default=const.DEFAULT_OPENAPI_PATH)
88 swagger_ui_url: str | None = Field(default=const.DEFAULT_DOCS_PATH)
89 redoc_url: str | None = Field(default="/redoc")
90 swagger_js_url: str | None = Field(default=None)
91 swagger_css_url: str | None = Field(default=None)
92 redoc_js_url: str | None = Field(default=None)
93 compression_enabled: bool = Field(default=True)
94 template_directory: str = Field(
95 default="templates", description="Directory for Jinja2 templates"
96 )
98 # API Documentation - auto-configures CSP for /docs and /redoc
99 api_docs: APIDocsConfig = Field(
100 default_factory=APIDocsConfig,
101 description="API documentation configuration (auto-configures CSP)",
102 )
104 # Debug routes - enable /debug/* endpoints
105 debug_routes: bool = Field(default=False, description="Enable debug routes")
106 enable_debug_routes_env_gate: bool = Field(
107 default=False,
108 description="Require explicit opt-in for debug route registration.",
109 )
110 debug_routes_token: SecretStr | None = Field(
111 default=None,
112 description="Token required to access debug routes (sent as X-Debug-Token header).",
113 )
115 # OAuth Identity Resolution - resolve external OAuth IDs to internal UUIDs
116 enable_identity_resolution: bool = Field(
117 default=False,
118 description="Automatically resolve OAuth external IDs to internal UUIDs in authenticated requests",
119 )
121 # Authentication - enable built-in authentication middleware
122 enable_auth: bool = Field(
123 default=False,
124 description="Enable built-in authentication middleware. Requires authenticators to be registered in the container.",
125 )
127 # Request body size limit — protects against OOM DoS via oversized payloads.
128 # Set to None to disable the middleware entirely.
129 max_body_size: int | None = Field(
130 default=10 * 1024 * 1024, # 10 MiB
131 ge=1,
132 description=(
133 "Maximum allowed request body size in bytes. "
134 "Requests with a Content-Length header exceeding this limit receive "
135 "a 413 response before the body is read. "
136 "Set to None to disable the body size limit (not recommended in production)."
137 ),
138 )
140 # Paths to exclude from authentication (e.g., health checks, docs)
141 auth_exclude_paths: list[str] = Field(
142 default_factory=lambda: [
143 const.DEFAULT_HEALTH_PATH,
144 const.DEFAULT_HEALTH_PATH + "/",
145 const.DEFAULT_DOCS_PATH,
146 "/redoc",
147 const.DEFAULT_OPENAPI_PATH,
148 ],
149 description="Paths to exclude from authentication",
150 )
152 # Role guard — declarative path-to-role enforcement (requires a bound
153 # RoleResolverProtocol in the container when rules are declared).
154 role_guard: RoleGuardConfig = Field(
155 default_factory=RoleGuardConfig,
156 description="Role guard rules (path -> allowed roles)",
157 )
159 @model_validator(mode="after")
160 def validate_production_security(self) -> WebConfig:
161 """Block insecure configurations in production."""
162 # Use explicit env field if set, otherwise fall back to os.getenv
163 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development"
164 env = str(env_raw).lower()
165 if (
166 self.cors.allowed_origins
167 and "*" in self.cors.allowed_origins
168 and self.cors.allow_credentials
169 ):
170 raise ValueError(
171 "CRITICAL SECURITY ERROR: wildcard CORS origin '*' combined with "
172 "allow_credentials=True is not permitted in any environment — "
173 "set specific origins via LEX_WEB__CORS__ALLOWED_ORIGINS.",
174 )
175 if env == "production":
176 if self.cors.allowed_origins and "*" in self.cors.allowed_origins:
177 raise ValueError(
178 "CRITICAL SECURITY ERROR: Wildcard CORS origin '*' not allowed in PRODUCTION.\n"
179 "You MUST set specific origins via LEX_WEB__CORS__ALLOWED_ORIGINS.",
180 )
181 if not self.security.csrf.enabled:
182 raise ValueError(
183 "CRITICAL SECURITY ERROR: CSRF protection is disabled in PRODUCTION.\n"
184 "You MUST enable it via LEX_WEB__SECURITY__CSRF__ENABLED.",
185 )
186 csrf_key: str | None = (
187 self.security.csrf.secret_key.get_secret_value()
188 if isinstance(self.security.csrf.secret_key, SecretStr)
189 else self.security.csrf.secret_key
190 )
191 if not csrf_key or csrf_key.strip() == "":
192 raise ValueError(
193 "CRITICAL SECURITY ERROR: CSRF is enabled but no secret_key is set in PRODUCTION.\n"
194 "You MUST set one via LEX_WEB__SECURITY__CSRF__SECRET_KEY.",
195 )
196 if not self.security.allowed_hosts:
197 raise ValueError(
198 "CRITICAL SECURITY ERROR: no allowed_hosts configured for PRODUCTION.\n"
199 "You MUST configure LEX_WEB__SECURITY__ALLOWED_HOSTS "
200 "(host validation fails closed).",
201 )
202 # HSTS must be on in production (mirrors create_production_config).
203 self.security.hsts.enabled = True
204 return self
207__all__ = [
208 "WebConfig",
209]