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

1"""Security, RBAC, password policy, rate limiting, and audit configurations.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any 

7 

8from lexigram.domain import DomainModel 

9from lexigram.validation import ( 

10 Field, 

11 SecretStr, 

12 field_validator, 

13 model_validator, 

14) 

15 

16 

17@dataclass(init=False) 

18class AdminPasswordPolicyConfig(DomainModel): 

19 """Password policy configuration for admin authentication. 

20 

21 Follows NIST SP 800-63B guidelines. 

22 """ 

23 

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) 

32 

33 model_config = {"extra": "forbid"} 

34 

35 

36@dataclass(init=False) 

37class AdminSecurityConfig(DomainModel): 

38 """Security hardening configuration for admin authentication. 

39 

40 Controls rate limiting, progressive lockout, and setup token protection. 

41 """ 

42 

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) 

47 

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) 

55 

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 ) 

60 

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

68 

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 ) 

77 

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``. 

82 

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. 

87 

88 Args: 

89 data: Raw input dict (or already-built instance) before field 

90 assignment. 

91 

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 

102 

103 legacy = os.getenv("ADMIN_SETUP_TOKEN") 

104 if legacy: 

105 return {**data, "setup_token": legacy} 

106 return data 

107 

108 model_config = {"extra": "forbid"} 

109 

110 

111@dataclass(init=False) 

112class AdminRbacConfig(DomainModel): 

113 """RBAC editing-page configuration.""" 

114 

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

118 

119 

120@dataclass(init=False) 

121class AdminRateLimitConfig(DomainModel): 

122 """Rate limiting configuration.""" 

123 

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) 

128 

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) 

134 

135 model_config = {"extra": "forbid"} 

136 

137 

138@dataclass(init=False) 

139class AdminAuditConfig(DomainModel): 

140 """Audit logging configuration.""" 

141 

142 read_audit_enabled: bool = Field( 

143 default=False, 

144 description="Log read operations (off by default; compliance mode only).", 

145 ) 

146 

147 model_config = {"extra": "forbid"}