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

1"""Top-level web configuration.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6import os 

7from typing import ClassVar, cast 

8 

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) 

25 

26 

27@dataclass(init=False) 

28class WebConfig(BaseConfig): 

29 """Hierarchical root configuration for Lexigram Web. 

30 

31 This is the single source of truth for web configuration, 

32 loaded from application.yaml's `web` section. 

33 

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 """ 

47 

48 config_section: ClassVar[str] = "web" 

49 

50 model_config = cast( 

51 "ConfigDict", 

52 { 

53 "env_prefix": "LEX_WEB__", 

54 "env_nested_delimiter": "__", 

55 "extra": "ignore", 

56 }, 

57 ) 

58 

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) 

83 

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 ) 

97 

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 ) 

103 

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 ) 

114 

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 ) 

120 

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 ) 

126 

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 ) 

139 

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 ) 

151 

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 ) 

158 

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 

205 

206 

207__all__ = [ 

208 "WebConfig", 

209]