Coverage for src / lexigram / admin / config.py: 97%
278 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Admin configuration models and factory helpers."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Any, ClassVar, Literal, cast
8from lexigram.admin.constants import ENV_NESTED_DELIMITER, ENV_PREFIX
9from lexigram.admin.resources.config import ResourceConfig, TableConfiguration
10from lexigram.config import BaseConfig
11from lexigram.domain import DomainModel
12from lexigram.validation import ConfigDict, Field, model_validator
15@dataclass(init=False)
16class ResourceDefaults(DomainModel):
17 """Default configuration for all resources."""
19 per_page: int = Field(default=20, ge=1, le=1000)
20 enable_search: bool = Field(default=True)
21 enable_export: bool = Field(default=True)
22 enable_bulk_actions: bool = Field(default=True)
23 action_layout: Literal["horizontal", "vertical", "dropdown"] = Field(
24 default="horizontal",
25 )
26 soft_delete: bool = Field(default=True)
27 timestamp_fields: bool = Field(default=True)
29 model_config = {"extra": "forbid"}
32@dataclass(init=False)
33class TableDefaults(DomainModel):
34 """Default configuration for DataTables."""
36 reorderable_columns: bool = Field(default=False)
37 enable_column_visibility: bool = Field(default=True)
38 sticky_header: bool = Field(default=True)
39 virtualized: bool = Field(default=False)
40 row_height: int = Field(default=48, ge=24, le=120)
41 zebra_stripes: bool = Field(default=True)
42 hover_highlight: bool = Field(default=True)
44 model_config = {"extra": "forbid"}
47@dataclass(init=False)
48class FormDefaults(DomainModel):
49 """Default configuration for Forms."""
51 show_required_indicator: bool = Field(default=True)
52 autosave_enabled: bool = Field(default=False)
53 autosave_interval_ms: int = Field(default=30000, ge=1000)
54 confirm_unsaved_changes: bool = Field(default=True)
55 inline_validation: bool = Field(default=True)
57 model_config = {"extra": "forbid"}
60@dataclass(init=False)
61class ResourceYAMLConfig(DomainModel):
62 """Per-resource configuration from YAML."""
64 enabled: bool = Field(default=True)
65 icon: str | None = None
66 label: str | None = None
67 label_plural: str | None = None
68 per_page: int | None = Field(default=None, ge=1, le=1000)
69 searchable_fields: list[str] | None = None
70 default_sort: str | None = None
71 default_sort_order: Literal["asc", "desc"] = "desc"
72 permissions: dict[str, list[str]] | None = None
74 model_config = {"extra": "allow"}
77@dataclass(init=False)
78class AdminNavigationGroup(DomainModel):
79 """Navigation group configuration."""
81 label: str
82 icon: str | None = None
83 order: int = Field(default=100, ge=0)
84 resources: list[str] = Field(default_factory=list)
85 permission: str | None = None
86 collapsible: bool = Field(default=True)
87 collapsed_by_default: bool = Field(default=False)
89 model_config = {"extra": "forbid"}
92@dataclass(init=False)
93class AdminFeaturesConfig(DomainModel):
94 """Feature flags for admin functionality."""
96 # UI Features
97 command_palette: bool = Field(default=True)
98 keyboard_shortcuts: bool = Field(default=True)
99 theme_toggle: bool = Field(default=True)
100 search: bool = Field(default=True)
102 # UX Features
103 optimistic_updates: bool = Field(default=True)
104 undo_redo: bool = Field(default=True)
105 autosave: bool = Field(default=False)
107 # Advanced Features
108 audit_logging: bool = Field(default=True)
109 activity_feed: bool = Field(default=False)
110 notifications: bool = Field(default=True)
111 webhooks: bool = Field(default=False)
112 api_docs: bool = Field(default=True)
114 model_config = {"extra": "allow"}
117@dataclass(init=False)
118class AdminPasswordPolicyConfig(DomainModel):
119 """Password policy configuration for admin authentication.
121 Follows NIST SP 800-63B guidelines.
122 """
124 min_length: int = Field(default=12, ge=8, le=128)
125 max_length: int = Field(default=128, ge=32, le=1024)
126 require_uppercase: bool = Field(default=True)
127 require_lowercase: bool = Field(default=True)
128 require_digit: bool = Field(default=True)
129 require_special: bool = Field(default=True)
130 reject_common_passwords: bool = Field(default=True)
131 reject_containing_email: bool = Field(default=True)
133 model_config = {"extra": "forbid"}
136@dataclass(init=False)
137class AdminSecurityConfig(DomainModel):
138 """Security hardening configuration for admin authentication.
140 Controls rate limiting, progressive lockout, and setup token protection.
141 """
143 ip_rate_limit_enabled: bool = Field(default=True)
144 ip_rate_limit_per_minute: int = Field(default=10, ge=1)
145 ip_rate_limit_per_15_minutes: int = Field(default=30, ge=1)
146 ip_rate_limit_per_hour: int = Field(default=60, ge=1)
148 # Progressive lockout thresholds: list of (failure_count, lockout_minutes)
149 # e.g. [(5, 15), (10, 60), (15, 240), (20, 1440)] means:
150 # 5 failures → 15 min lockout, 10 → 1hr, 15 → 4hr, 20 → 24hr
151 lockout_thresholds: list[tuple[int, int]] = Field(
152 default_factory=lambda: [(5, 15), (10, 60), (15, 240), (20, 1440)],
153 )
154 permanent_lockout_threshold: int = Field(default=50, ge=10)
156 setup_token: str | None = Field(
157 default=None,
158 description="Optional ADMIN_SETUP_TOKEN — when set, must be provided during first-run setup.",
159 )
161 model_config = {"extra": "forbid"}
164@dataclass(init=False)
165class AdminAuthConfig(DomainModel):
166 """Authentication configuration."""
168 enabled: bool = Field(default=True, description="Enable authentication")
169 env: Literal["development", "staging", "production"] = Field(
170 default="development",
171 description="Deployment environment for cookie security defaults",
172 )
173 session_secret: str = Field(
174 default="change-me-in-production",
175 description="Session secret for signing",
176 )
177 login_url: str = Field(default="/admin/login")
178 logout_url: str = Field(default="/admin/logout")
179 session_lifetime: int = Field(default=86400, ge=300) # 5 min minimum
180 permission_cache_ttl: int = Field(default=300, ge=0) # 5 minutes
182 # Security settings
183 idle_timeout: int = Field(
184 default=3600, ge=60, description="Session idle timeout in seconds"
185 )
186 csrf_token_lifetime: int = Field(
187 default=3600, ge=60, description="CSRF token expiry in seconds"
188 )
189 password_policy: AdminPasswordPolicyConfig = Field(
190 default_factory=AdminPasswordPolicyConfig,
191 )
192 security: AdminSecurityConfig = Field(
193 default_factory=AdminSecurityConfig,
194 )
196 # Users and Roles (Sync)
197 users: list[Any] = Field(default_factory=list)
198 roles: dict[str, Any] = Field(default_factory=dict)
200 # OAuth/SSO (optional)
201 oauth_enabled: bool = Field(default=False)
202 oauth_providers: list[str] = Field(default_factory=list)
204 model_config = {"extra": "allow"}
206 @model_validator(mode="after")
207 def validate_security(self) -> AdminAuthConfig:
208 """Ensure secure settings in production."""
209 insecure_defaults = (
210 "change-me",
211 "your-secret-key",
212 "secret",
213 "password",
214 "change-me-in-production",
215 )
217 if (
218 self.env == "production"
219 and self.session_secret.lower() in insecure_defaults
220 ):
221 raise ValueError(
222 "CRITICAL SECURITY ERROR: Default admin session_secret detected in PRODUCTION.\n"
223 "You MUST set a secure session secret via LEX_ADMIN__AUTH__SESSION_SECRET.",
224 )
226 if (
227 self.env in {"production", "staging"}
228 and self.oauth_enabled
229 and not self.oauth_providers
230 ):
231 raise ValueError(
232 "oauth_providers must be configured when oauth_enabled=True"
233 )
235 if (
236 self.env in {"production", "staging"}
237 and self.csrf_token_lifetime > self.idle_timeout
238 ):
239 raise ValueError("csrf_token_lifetime must not exceed idle_timeout")
241 return self
244@dataclass(init=False)
245class AdminAuditConfig(DomainModel):
246 """Audit logging configuration."""
248 read_audit_enabled: bool = Field(
249 default=False,
250 description="Log read operations (off by default; compliance mode only).",
251 )
252 redaction_field_denylist: tuple[str, ...] = Field(
253 default_factory=lambda: ("email", "phone", "password_hash", "ssn"),
254 description="Field names whose values are always redacted in audit payloads.",
255 )
256 redaction_patterns: tuple[str, ...] = Field(
257 default_factory=lambda: ("email", "phone"),
258 description="Pattern-based redaction strategies to apply.",
259 )
261 model_config = {"extra": "forbid"}
264@dataclass(init=False)
265class AdminUIConfig(DomainModel):
266 """UI/Theme configuration."""
268 theme: Literal["light", "dark", "system"] = Field(default="system")
269 primary_color: str = Field(default="#6B7280")
270 sidebar_width: int = Field(default=256, ge=200, le=400)
271 sidebar_collapsed_width: int = Field(default=64, ge=48, le=100)
272 content_max_width: int | None = Field(default=None, ge=800)
273 logo_url: str | None = None
274 favicon_url: str | None = None
276 model_config = {"extra": "allow"}
279@dataclass(init=False)
280class AdminRateLimitConfig(DomainModel):
281 """Rate limiting configuration."""
283 enabled: bool = Field(default=True)
284 requests_per_minute: int = Field(default=60, ge=1)
285 requests_per_hour: int = Field(default=1000, ge=1)
286 burst_size: int = Field(default=10, ge=1)
288 # Per-action limits
289 create_per_minute: int = Field(default=30)
290 update_per_minute: int = Field(default=60)
291 delete_per_minute: int = Field(default=20)
292 bulk_per_minute: int = Field(default=5)
294 model_config = {"extra": "forbid"}
297@dataclass(init=False)
298class ContributorConfig(DomainModel):
299 """Per-contributor enable/disable and options."""
301 enabled: bool = Field(default=True)
302 options: dict[str, Any] = Field(default_factory=dict)
305@dataclass(init=False)
306class DashboardLayoutConfig(DomainModel):
307 """Dashboard layout configuration."""
309 widget_refresh_default: int = Field(default=30)
310 max_widgets: int = Field(default=20)
311 layout: Literal["grid", "masonry"] = Field(default="grid")
314@dataclass(init=False)
315class TenancyConfig(DomainModel):
316 """Multi-tenancy configuration for admin.
318 Controls tenant resolution, data isolation, and route scoping.
320 Attributes:
321 enabled: Enable multi-tenancy support.
322 tenant_field: Field name used for tenant data filtering (default ``"tenant_id"``).
323 header_name: HTTP header name for tenant ID resolution (default ``"x-tenant-id"``).
324 cookie_name: Cookie name for tenant ID resolution (default ``"admin_tenant"``).
325 default_tenant_id: Fallback tenant ID when none can be resolved.
326 route_prefix_template: If set (e.g. ``"{tenant}"``), routes are prefixed
327 with the resolved tenant ID. Empty means no route prefix.
328 """
330 enabled: bool = Field(default=False)
331 tenant_field: str = Field(default="tenant_id")
332 header_name: str = Field(default="x-tenant-id")
333 cookie_name: str = Field(default="admin_tenant")
334 default_tenant_id: str = Field(default="")
335 route_prefix_template: str = Field(default="")
337 model_config = {"extra": "forbid"}
340# ---------------------------------------------------------------------------
341# Data & Observability configs
342# ---------------------------------------------------------------------------
345@dataclass(init=False)
346class AdminDataConfig(DomainModel):
347 query_timeout_seconds: int = Field(default=5, ge=1, le=120)
350@dataclass(init=False)
351class AdminObservabilityConfig(DomainModel):
352 metrics_enabled: bool = Field(default=True)
353 high_cardinality_labels_enabled: bool = Field(default=False)
356# ---------------------------------------------------------------------------
357# Integration configs
358# ---------------------------------------------------------------------------
361@dataclass(init=False)
362class CacheIntegrationConfig(DomainModel):
363 """Configuration for the optional cache integration.
365 Controls ``lexigram-cache`` usage from admin resources.
366 """
368 enabled: bool = Field(default=True)
369 default_ttl_seconds: int = Field(default=60, ge=1)
370 key_prefix: str = Field(default="admin")
373@dataclass(init=False)
374class TasksIntegrationConfig(DomainModel):
375 """Configuration for the optional tasks integration.
377 Controls ``lexigram-tasks`` usage from admin bulk actions.
378 """
380 enabled: bool = Field(default=True)
381 bulk_threshold: int = Field(default=25, ge=1)
384@dataclass(init=False)
385class SearchIntegrationConfig(DomainModel):
386 """Configuration for the optional search integration."""
388 enabled: bool = Field(default=True)
389 fallback_to_like: bool = Field(default=True)
392@dataclass(init=False)
393class ResilienceIntegrationConfig(DomainModel):
394 """Configuration for the optional resilience integration."""
396 enabled: bool = Field(default=True)
397 retry_max_attempts: int = Field(default=3, ge=1)
398 circuit_failure_threshold: int = Field(default=5, ge=1)
401@dataclass(init=False)
402class StorageIntegrationConfig(DomainModel):
403 """Configuration for the optional storage integration."""
405 enabled: bool = Field(default=True)
406 presigned_url_expiry: int = Field(default=3600, ge=60)
409@dataclass(init=False)
410class FeaturesIntegrationConfig(DomainModel):
411 """Configuration for the optional feature-flags integration."""
413 enabled: bool = Field(default=True)
416@dataclass(init=False)
417class MonitorIntegrationConfig(DomainModel):
418 """Configuration for the optional monitoring integration."""
420 enabled: bool = Field(default=True)
423@dataclass(init=False)
424class AdminIntegrationsConfig(DomainModel):
425 """Aggregate configuration for all optional integrations."""
427 cache: CacheIntegrationConfig = Field(default_factory=CacheIntegrationConfig)
428 tasks: TasksIntegrationConfig = Field(default_factory=TasksIntegrationConfig)
429 search: SearchIntegrationConfig = Field(default_factory=SearchIntegrationConfig)
430 resilience: ResilienceIntegrationConfig = Field(
431 default_factory=ResilienceIntegrationConfig,
432 )
433 storage: StorageIntegrationConfig = Field(
434 default_factory=StorageIntegrationConfig,
435 )
436 features: FeaturesIntegrationConfig = Field(
437 default_factory=FeaturesIntegrationConfig,
438 )
439 monitor: MonitorIntegrationConfig = Field(default_factory=MonitorIntegrationConfig)
440 enabled: bool = Field(default=True)
442 model_config = {"extra": "forbid"}
445@dataclass(init=False)
446class FrameworkPagesConfig(DomainModel):
447 """Framework management pages configuration."""
449 enabled: bool = Field(default=True)
450 require_permission: str = Field(default="admin:framework:access")
453@dataclass(init=False)
454class AdminConfig(BaseConfig):
455 """Complete admin configuration - SINGLE SOURCE OF TRUTH.
457 This model represents the full configuration hierarchy for lexigram-admin.
458 Configuration is loaded from:
459 1. Pydantic defaults (this model)
460 2. application.yaml (admin: section)
461 3. Environment variables (LEX_ADMIN_*)
462 4. Runtime config (hot-reloadable)
464 Attributes:
465 name: Configuration name (default: "admin")
466 enabled: Whether admin module is enabled
467 title: Admin UI title
468 prefix: Admin URL prefix
469 htmx_prefix: HTMX endpoint prefix
470 api_prefix: API endpoint prefix
471 static_prefix: Static files prefix
472 require_auth: Require authentication
473 debug: Debug mode
474 templates_dir: Templates directory path
475 static_dir: Static files directory path
476 auth: Authentication settings
477 features: Feature toggles
478 """
480 model_config = cast(
481 "ConfigDict",
482 {
483 "env_prefix": ENV_PREFIX,
484 "env_nested_delimiter": ENV_NESTED_DELIMITER,
485 "extra": "ignore",
486 },
487 )
489 config_section: ClassVar[str] = "admin"
491 # Section identifier (used by config discovery)
492 name: str = "admin"
493 enabled: bool = True
495 # Core Settings
496 title: str = Field(default="Lexigram Admin")
497 prefix: str = Field(default="/admin")
498 htmx_prefix: str = Field(default="/admin/htmx")
499 api_prefix: str = Field(default="/admin/api")
500 static_prefix: str = Field(default="/admin/static")
502 # Security
503 require_auth: bool = Field(default=True)
504 debug: bool = Field(default=False)
506 # Paths
507 templates_dir: str | None = Field(default=None)
508 static_dir: str | None = Field(default=None)
510 # Sub-configs
511 auth: AdminAuthConfig = Field(default_factory=AdminAuthConfig)
512 features: AdminFeaturesConfig = Field(default_factory=AdminFeaturesConfig)
513 data: AdminDataConfig = Field(default_factory=AdminDataConfig)
514 observability: AdminObservabilityConfig = Field(
515 default_factory=AdminObservabilityConfig
516 )
518 ui: AdminUIConfig = Field(default_factory=AdminUIConfig)
519 rate_limit: AdminRateLimitConfig = Field(default_factory=AdminRateLimitConfig)
520 resource_defaults: ResourceDefaults = Field(default_factory=ResourceDefaults)
521 table_defaults: TableDefaults = Field(default_factory=TableDefaults)
522 form_defaults: FormDefaults = Field(default_factory=FormDefaults)
524 # Resource & Navigation
525 resources: dict[str, ResourceYAMLConfig] = Field(default_factory=dict)
526 navigation_groups: dict[str, AdminNavigationGroup] = Field(default_factory=dict)
527 commands: list[dict[str, Any]] = Field(default_factory=list)
528 extensions: dict[str, Any] = Field(default_factory=dict)
530 # Audit
531 audit: AdminAuditConfig = Field(default_factory=AdminAuditConfig)
533 # Multi-tenancy
534 tenancy: TenancyConfig = Field(default_factory=TenancyConfig)
536 # Optional integrations
537 integrations: AdminIntegrationsConfig = Field(
538 default_factory=AdminIntegrationsConfig,
539 )
541 # Contributor system
542 contributors: dict[str, ContributorConfig] = Field(default_factory=dict)
543 dashboard_layout: DashboardLayoutConfig = Field(
544 default_factory=DashboardLayoutConfig
545 )
546 framework_pages: FrameworkPagesConfig = Field(default_factory=FrameworkPagesConfig)
548 contributor_collision_mode: Literal["warn", "error"] = Field(
549 default="warn",
550 description=(
551 "How to handle name collisions when multiple contributors register "
552 "widgets, pages, or routes with the same name. 'warn' (default) "
553 "logs a warning and keeps the first registration; 'error' raises "
554 "at boot time."
555 ),
556 )
558 strict_resource_resolution: bool = Field(
559 default=True,
560 description=(
561 "When True (production default), resource/controller resolution "
562 "failures during AdminProvider.boot() raise immediately. "
563 "When False, failures are logged and resolution continues with "
564 "the remaining resources/controllers. Set to False in dev only."
565 ),
566 )
568 def get_resource_config(self, name: str) -> ResourceYAMLConfig:
569 """Get resource config with fallback to defaults."""
570 return self.resources.get(name, ResourceYAMLConfig())
572 def get_navigation_group(self, name: str) -> AdminNavigationGroup | None:
573 """Get navigation group by name."""
574 return self.navigation_groups.get(name)
576 def is_feature_enabled(self, feature: str) -> bool:
577 """Check if a feature is enabled."""
578 return getattr(self.features, feature, False)
581@dataclass(init=False)
582class AdminStorageConfig(DomainModel):
583 """Configuration for admin file storage service."""
585 base_path: str = Field(
586 default="uploads", description="Base path for uploaded files"
587 )
588 max_file_size: int = Field(
589 default=10 * 1024 * 1024,
590 description="Maximum allowed file size in bytes (default 10 MB)",
591 )
592 allowed_content_types: list[str] = Field(
593 default_factory=lambda: [
594 "image/jpeg",
595 "image/png",
596 "image/gif",
597 "image/webp",
598 "application/pdf",
599 "text/plain",
600 "text/csv",
601 ],
602 description="Allowed MIME content types for uploads",
603 )
604 presigned_url_expiry: int = Field(
605 default=3600,
606 description="Default expiry for presigned URLs in seconds",
607 )
610@dataclass(init=False)
611class AdminNotificationConfig(DomainModel):
612 """Configuration for admin notification service.
614 Controls email notification behaviour, delivery channels, and
615 retry settings.
616 """
618 email_from: str = Field(
619 default="admin@localhost", description="Sender email address"
620 )
621 email_from_name: str = Field(
622 default="Admin Panel", description="Sender display name"
623 )
624 default_channel: str = Field(
625 default="email", description="Default delivery channel"
626 )
627 max_retries: int = Field(default=3, description="Maximum delivery retry attempts")
628 retry_delay_seconds: int = Field(default=60, description="Seconds between retries")
629 enabled: bool = Field(default=True, description="Enable notification sending")
632def make_admin_config(**kwargs: Any) -> AdminConfig:
633 """Helper to create admin config from kwargs."""
634 return AdminConfig(**kwargs)
637def default_admin_config() -> AdminConfig:
638 """Factory for default admin configuration."""
639 return AdminConfig()
642__all__ = [
643 "AdminAuditConfig",
644 "AdminAuthConfig",
645 "AdminConfig",
646 "AdminDataConfig",
647 "AdminFeaturesConfig",
648 "AdminIntegrationsConfig",
649 "AdminNavigationGroup",
650 "AdminNotificationConfig",
651 "AdminObservabilityConfig",
652 "AdminPasswordPolicyConfig",
653 "AdminRateLimitConfig",
654 "AdminSecurityConfig",
655 "AdminStorageConfig",
656 "AdminUIConfig",
657 "CacheIntegrationConfig",
658 "ContributorConfig",
659 "DashboardLayoutConfig",
660 "FeaturesIntegrationConfig",
661 "FormDefaults",
662 "FrameworkPagesConfig",
663 "MonitorIntegrationConfig",
664 "ResilienceIntegrationConfig",
665 "ResourceConfig",
666 "ResourceDefaults",
667 "ResourceYAMLConfig",
668 "SearchIntegrationConfig",
669 "StorageIntegrationConfig",
670 "TableConfiguration",
671 "TableDefaults",
672 "TasksIntegrationConfig",
673 "TenancyConfig",
674 "default_admin_config",
675 "make_admin_config",
676]