Coverage for src/lexigram/admin/config/security.py: 36%
64 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1"""Security, RBAC, password policy, rate limiting, and audit configurations."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Any
8from lexigram.domain import DomainModel
9from lexigram.validation import (
10 Field,
11 SecretStr,
12 field_validator,
13 model_validator,
14)
17@dataclass(init=False)
18class AdminPasswordPolicyConfig(DomainModel):
19 """Password policy configuration for admin authentication.
21 Follows NIST SP 800-63B guidelines.
22 """
24 min_length: int = Field(default=12, ge=8, le=128)
25 max_length: int = Field(default=128, ge=32, le=1024)
26 require_uppercase: bool = Field(default=True)
27 require_lowercase: bool = Field(default=True)
28 require_digit: bool = Field(default=True)
29 require_special: bool = Field(default=True)
30 reject_common_passwords: bool = Field(default=True)
31 reject_containing_email: bool = Field(default=True)
33 model_config = {"extra": "forbid"}
36@dataclass(init=False)
37class AdminSecurityConfig(DomainModel):
38 """Security hardening configuration for admin authentication.
40 Controls rate limiting, progressive lockout, and setup token protection.
41 """
43 ip_rate_limit_enabled: bool = Field(default=True)
44 ip_rate_limit_per_minute: int = Field(default=10, ge=1)
45 ip_rate_limit_per_15_minutes: int = Field(default=30, ge=1)
46 ip_rate_limit_per_hour: int = Field(default=60, ge=1)
48 # Progressive lockout thresholds: list of (failure_count, lockout_minutes)
49 # e.g. [(5, 15), (10, 60), (15, 240), (20, 1440)] means:
50 # 5 failures → 15 min lockout, 10 → 1hr, 15 → 4hr, 20 → 24hr
51 lockout_thresholds: list[tuple[int, int]] = Field(
52 default_factory=lambda: [(5, 15), (10, 60), (15, 240), (20, 1440)],
53 )
54 permanent_lockout_threshold: int = Field(default=50, ge=10)
56 setup_token: SecretStr | None = Field(
57 default=None,
58 description="Optional ADMIN_SETUP_TOKEN — when set, must be provided during first-run setup.",
59 )
61 @field_validator("setup_token")
62 @classmethod
63 def _coerce_setup_token(cls, value: Any) -> Any:
64 """Accept plain strings from env/YAML; store as SecretStr."""
65 if value is None or isinstance(value, SecretStr):
66 return value
67 return SecretStr(str(value))
69 setup_token_optin_unsafe: bool = Field(
70 default=False,
71 description=(
72 "Explicit escape hatch: boot without a setup token. Only for "
73 "local/ephemeral environments — leaves the first-run wizard open "
74 "to any visitor until an admin account is created."
75 ),
76 )
78 @model_validator(mode="before")
79 @classmethod
80 def _map_legacy_setup_token(cls, data: Any) -> Any:
81 """Map the legacy ``ADMIN_SETUP_TOKEN`` input key onto ``setup_token``.
83 Keeps existing deployments working unchanged: the token can be
84 provided as a config key (``admin.security.ADMIN_SETUP_TOKEN`` in
85 YAML/``from_dict`` input) or as the bare ``ADMIN_SETUP_TOKEN``
86 environment variable. Explicit ``setup_token`` always wins.
88 Args:
89 data: Raw input dict (or already-built instance) before field
90 assignment.
92 Returns:
93 The input dict with the legacy key mapped onto ``setup_token``
94 when the latter is absent.
95 """
96 if not isinstance(data, dict):
97 return data
98 if "ADMIN_SETUP_TOKEN" in data and "setup_token" not in data:
99 return {**data, "setup_token": data["ADMIN_SETUP_TOKEN"]}
100 if "setup_token" not in data:
101 import os
103 legacy = os.getenv("ADMIN_SETUP_TOKEN")
104 if legacy:
105 return {**data, "setup_token": legacy}
106 return data
108 model_config = {"extra": "forbid"}
111@dataclass(init=False)
112class AdminRbacConfig(DomainModel):
113 """RBAC editing-page configuration."""
115 #: Role name granted wildcard admin rights. Matches the role string
116 #: already special-cased by settings/widgets/impersonation.
117 super_admin_role: str = Field(default="superadmin")
120@dataclass(init=False)
121class AdminRateLimitConfig(DomainModel):
122 """Rate limiting configuration."""
124 enabled: bool = Field(default=True)
125 requests_per_minute: int = Field(default=60, ge=1)
126 requests_per_hour: int = Field(default=1000, ge=1)
127 burst_size: int = Field(default=10, ge=1)
129 # Per-action limits
130 create_per_minute: int = Field(default=30)
131 update_per_minute: int = Field(default=60)
132 delete_per_minute: int = Field(default=20)
133 bulk_per_minute: int = Field(default=5)
135 model_config = {"extra": "forbid"}
138@dataclass(init=False)
139class AdminAuditConfig(DomainModel):
140 """Audit logging configuration."""
142 read_audit_enabled: bool = Field(
143 default=False,
144 description="Log read operations (off by default; compliance mode only).",
145 )
147 model_config = {"extra": "forbid"}