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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Admin password policy validation service.
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"""
11from __future__ import annotations
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
24logger = get_logger(__name__)
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 )
40@inject
41class AdminPasswordPolicyService:
42 """Password policy validation service for admin accounts.
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.
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 """
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
65 # ------------------------------------------------------------------
66 # AdminPasswordPolicyServiceProtocol
67 # ------------------------------------------------------------------
69 def validate(
70 self,
71 password: str,
72 email: str | None = None,
73 ) -> AdminPasswordValidationResult:
74 """Validate a password against all configured rules.
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.
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.
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)
97 if self._reject_email and email:
98 violation = self._email_violation(password, email)
99 if violation is not None:
100 violations.append(violation)
102 logger.debug(
103 "password_policy.validated",
104 violation_count=len(violations),
105 is_valid=len(violations) == 0,
106 )
108 return AdminPasswordValidationResult(
109 is_valid=len(violations) == 0,
110 violations=violations,
111 )
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)
117 # ------------------------------------------------------------------
118 # Failure-report mapping
119 # ------------------------------------------------------------------
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 )
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
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."
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
184# Verify that the concrete class satisfies the protocol at import time.
185_: AdminPasswordPolicyServiceProtocol = AdminPasswordPolicyService.__new__(
186 AdminPasswordPolicyService
187)
189__all__ = ["AdminPasswordPolicyService"]