Coverage for src/lexigram/admin/auth/services/password_policy_service.py: 0%

65 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1"""Admin password policy validation service. 

2 

3All rule evaluation is delegated to the single lexigram-auth 

4``PasswordPolicy`` implementation (NIST rule set + common-password 

5list); this adapter keeps the admin result contract — per-rule 

6``AdminPasswordViolation`` entries with UI-safe messages plus the 

7admin-specific email-containment rule — and maps the auth engine's 

8failure report onto it. 

9""" 

10 

11from __future__ import annotations 

12 

13from lexigram.admin.auth.protocols import AdminPasswordPolicyServiceProtocol 

14from lexigram.admin.auth.types import ( 

15 AdminPasswordRule, 

16 AdminPasswordValidationResult, 

17 AdminPasswordViolation, 

18) 

19from lexigram.auth import PasswordPolicy 

20from lexigram.contracts.auth import PasswordPolicyProtocol 

21from lexigram.di.decorators import inject 

22from lexigram.logging import get_logger 

23 

24logger = get_logger(__name__) 

25 

26 

27def _default_policy() -> PasswordPolicy: 

28 """Build the admin-default policy (min 12, all classes required).""" 

29 return PasswordPolicy( 

30 min_length=12, 

31 max_length=128, 

32 require_uppercase=True, 

33 require_lowercase=True, 

34 require_digits=True, 

35 require_special=True, 

36 prevent_common=True, 

37 ) 

38 

39 

40@inject 

41class AdminPasswordPolicyService: 

42 """Password policy validation service for admin accounts. 

43 

44 Delegates rule evaluation (length, character classes, common 

45 passwords) to the injected ``PasswordPolicyProtocol`` implementation 

46 from lexigram-auth. Keeps the admin-only email-containment rule and 

47 translates the auth engine's failure report into per-rule 

48 ``AdminPasswordViolation`` entries. 

49 

50 Args: 

51 policy: lexigram-auth policy implementation carrying the 

52 configured rule set; defaults to the admin rule set. 

53 reject_containing_email: Reject passwords that contain the 

54 admin user's email local-part. 

55 """ 

56 

57 def __init__( 

58 self, 

59 policy: PasswordPolicyProtocol | None = None, 

60 reject_containing_email: bool = True, 

61 ) -> None: 

62 self._policy = policy if policy is not None else _default_policy() 

63 self._reject_email = reject_containing_email 

64 

65 # ------------------------------------------------------------------ 

66 # AdminPasswordPolicyServiceProtocol 

67 # ------------------------------------------------------------------ 

68 

69 def validate( 

70 self, 

71 password: str, 

72 email: str | None = None, 

73 ) -> AdminPasswordValidationResult: 

74 """Validate a password against all configured rules. 

75 

76 Delegates the shared rule evaluation to lexigram-auth's policy 

77 implementation and maps its failure report onto the admin 

78 violation contract. The admin-specific email-containment rule 

79 is checked here. 

80 

81 Args: 

82 password: Plain-text password to validate. 

83 email: Optional email — if provided and reject_containing_email 

84 is True, checks whether the password contains the email 

85 local-part. 

86 

87 Returns: 

88 AdminPasswordValidationResult with is_valid and the full 

89 violations list. 

90 """ 

91 violations: list[AdminPasswordViolation] = [] 

92 try: 

93 self._policy.validate(password) 

94 except ValueError as exc: 

95 violations = self._map_failures(str(exc), password) 

96 

97 if self._reject_email and email: 

98 violation = self._email_violation(password, email) 

99 if violation is not None: 

100 violations.append(violation) 

101 

102 logger.debug( 

103 "password_policy.validated", 

104 violation_count=len(violations), 

105 is_valid=len(violations) == 0, 

106 ) 

107 

108 return AdminPasswordValidationResult( 

109 is_valid=len(violations) == 0, 

110 violations=violations, 

111 ) 

112 

113 def is_valid(self, password: str) -> bool: 

114 """Return True when the password satisfies the delegated policy.""" 

115 return self._policy.is_valid(password) 

116 

117 # ------------------------------------------------------------------ 

118 # Failure-report mapping 

119 # ------------------------------------------------------------------ 

120 

121 _RULE_MAP: tuple[tuple[str, AdminPasswordRule], ...] = ( 

122 ("uppercase", AdminPasswordRule.MISSING_UPPERCASE), 

123 ("lowercase", AdminPasswordRule.MISSING_LOWERCASE), 

124 ("digit", AdminPasswordRule.MISSING_DIGIT), 

125 ("special character", AdminPasswordRule.MISSING_SPECIAL), 

126 ("too common", AdminPasswordRule.COMMON_PASSWORD), 

127 ("at most", AdminPasswordRule.TOO_LONG), 

128 ("at least", AdminPasswordRule.TOO_SHORT), 

129 ) 

130 

131 def _map_failures(self, report: str, password: str) -> list[AdminPasswordViolation]: 

132 violations: list[AdminPasswordViolation] = [] 

133 for part in report.split("; "): 

134 rule = next( 

135 (rule for needle, rule in self._RULE_MAP if needle in part), 

136 None, 

137 ) 

138 if rule is None: 

139 logger.warning("password_policy.unmapped_rule", rule_text=part) 

140 continue 

141 violations.append( 

142 AdminPasswordViolation( 

143 rule=rule, message=self._message_for(rule, password) 

144 ) 

145 ) 

146 return violations 

147 

148 def _message_for(self, rule: AdminPasswordRule, password: str) -> str: 

149 """Return the stable admin UI message for a violated rule.""" 

150 if rule is AdminPasswordRule.TOO_SHORT: 

151 minimum = int(getattr(self._policy, "min_length", 12)) 

152 return f"Password must be at least {minimum} characters." 

153 if rule is AdminPasswordRule.TOO_LONG: 

154 maximum = int(getattr(self._policy, "max_length", 128)) 

155 return f"Password must not exceed {maximum} characters." 

156 if rule is AdminPasswordRule.MISSING_UPPERCASE: 

157 return "Password must contain at least one uppercase letter." 

158 if rule is AdminPasswordRule.MISSING_LOWERCASE: 

159 return "Password must contain at least one lowercase letter." 

160 if rule is AdminPasswordRule.MISSING_DIGIT: 

161 return "Password must contain at least one digit." 

162 if rule is AdminPasswordRule.MISSING_SPECIAL: 

163 return ( 

164 "Password must contain at least one special character (!@#$%^&* etc.)." 

165 ) 

166 return "Password is too common. Please choose a more unique password." 

167 

168 def _email_violation( 

169 self, password: str, email: str | None 

170 ) -> AdminPasswordViolation | None: 

171 """Return a CONTAINS_EMAIL violation when the password embeds the email.""" 

172 if not email: 

173 return None 

174 email_lower = email.lower() 

175 local_part = email_lower.split("@")[0] if "@" in email_lower else email_lower 

176 if len(local_part) >= 4 and local_part in password.lower(): 

177 return AdminPasswordViolation( 

178 rule=AdminPasswordRule.CONTAINS_EMAIL, 

179 message="Password must not contain your email address.", 

180 ) 

181 return None 

182 

183 

184# Verify that the concrete class satisfies the protocol at import time. 

185_: AdminPasswordPolicyServiceProtocol = AdminPasswordPolicyService.__new__( 

186 AdminPasswordPolicyService 

187) 

188 

189__all__ = ["AdminPasswordPolicyService"]