Coverage for src / lexigram / admin / auth / protocols.py: 100%
51 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 authentication service protocols.
3All protocols remain in lexigram-admin (not lexigram-contracts) because they
4are admin-specific and not consumed by other extension packages.
6``AdminAuditLogServiceProtocol`` extends the framework-wide
7``AuditLoggerProtocol`` from ``lexigram.contracts.audit`` so that admin audit
8implementations satisfy the cross-package contract.
9"""
11from __future__ import annotations
13from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
15from lexigram.contracts.audit import AuditLoggerProtocol
17if TYPE_CHECKING:
18 from lexigram.admin.auth.errors import AdminAuthError
19 from lexigram.admin.auth.types import (
20 AdminAuthResult,
21 AdminLockoutInfo,
22 AdminLoginAttempt,
23 AdminPasswordValidationResult,
24 AdminSecurityEvent,
25 AdminSecurityEventType,
26 )
27 from lexigram.result import Result
30@runtime_checkable
31class AdminAuthServiceProtocol(Protocol):
32 """Main authentication orchestration service protocol.
34 Coordinates credential verification, rate limiting, lockout checks,
35 session issuance, and audit logging.
36 """
38 async def authenticate(
39 self,
40 email: str,
41 password: str,
42 ip_address: str,
43 user_agent: str,
44 ) -> Result[AdminAuthResult, AdminAuthError]:
45 """Authenticate an admin user with full security pipeline.
47 Args:
48 email: Admin user email.
49 password: Plain-text password.
50 ip_address: Client IP for rate limiting.
51 user_agent: Client user agent for audit.
53 Returns:
54 Ok(AdminAuthResult) with session details on success.
55 Err with specific AdminAuthError subclass on failure.
56 """
57 ...
59 async def invalidate_session(self, session_id: str) -> None:
60 """Invalidate a session (logout).
62 Args:
63 session_id: Session identifier to revoke.
64 """
65 ...
67 async def invalidate_all_user_sessions(self, user_id: str) -> None:
68 """Revoke all active sessions for a user (e.g., after password change).
70 Args:
71 user_id: Admin user UUID whose sessions to revoke.
72 """
73 ...
76@runtime_checkable
77class AdminLoginAttemptStoreProtocol(Protocol):
78 """Persistence protocol for login attempt records."""
80 async def ensure_schema(self) -> None:
81 """Create the admin_login_attempts table if it does not exist."""
82 ...
84 async def insert(self, attempt: AdminLoginAttempt) -> None:
85 """Persist a login attempt record.
87 Args:
88 attempt: The attempt to store.
89 """
90 ...
92 async def count_recent_failures(self, email: str, since_seconds: int) -> int:
93 """Count failed attempts for email within the given window.
95 Args:
96 email: Email address to query.
97 since_seconds: Look-back window in seconds.
99 Returns:
100 Number of failed attempts.
101 """
102 ...
104 async def count_recent_failures_by_ip(
105 self, ip_address: str, since_seconds: int
106 ) -> int:
107 """Count failed attempts from an IP within the given window.
109 Args:
110 ip_address: IP address to query.
111 since_seconds: Look-back window in seconds.
113 Returns:
114 Number of failed attempts.
115 """
116 ...
118 async def clear_failures(self, email: str) -> None:
119 """Clear failure records for email (called on successful login).
121 Args:
122 email: Email to clear.
123 """
124 ...
127@runtime_checkable
128class AdminAccountLockoutStoreProtocol(Protocol):
129 """Persistence protocol for account lockout records."""
131 async def ensure_schema(self) -> None:
132 """Create the admin_account_lockouts table if it does not exist."""
133 ...
135 async def get_active_lockout(self, email: str) -> AdminLockoutInfo | None:
136 """Get active lockout for email, or None if not locked.
138 Args:
139 email: Email to check.
141 Returns:
142 AdminLockoutInfo if active lockout exists, None otherwise.
143 """
144 ...
146 async def create_lockout(
147 self,
148 email: str,
149 consecutive_failures: int,
150 unlock_at: Any | None,
151 is_permanent: bool,
152 ) -> None:
153 """Create or update a lockout record for email.
155 Args:
156 email: Email to lock.
157 consecutive_failures: Total consecutive failures.
158 unlock_at: UTC datetime when lock expires (None if permanent).
159 is_permanent: Whether this requires manual admin unlock.
160 """
161 ...
163 async def clear_lockout(self, email: str) -> None:
164 """Remove active lockout for email (on successful login or admin unlock).
166 Args:
167 email: Email to unlock.
168 """
169 ...
172@runtime_checkable
173class AdminLoginAttemptServiceProtocol(Protocol):
174 """Service for IP rate limiting and account lockout enforcement."""
176 async def check_ip_rate_limit(self, ip_address: str) -> None:
177 """Check IP rate limit. Raises RateLimitExceededError if exceeded.
179 Args:
180 ip_address: Client IP to check.
182 Raises:
183 RateLimitExceededError: When the IP is rate-limited.
184 """
185 ...
187 async def check_account_lockout(self, email: str) -> None:
188 """Check account lockout status. Raises AccountLockedError if locked.
190 Args:
191 email: Email address to check.
193 Raises:
194 AccountLockedError: When the account is locked.
195 """
196 ...
198 async def record_attempt(
199 self,
200 email: str,
201 ip_address: str,
202 user_agent: str,
203 success: bool,
204 failure_reason: str | None = None,
205 ) -> None:
206 """Record a login attempt and update lockout state on failure.
208 Args:
209 email: Email that attempted login.
210 ip_address: Client IP.
211 user_agent: Client user agent.
212 success: Whether the attempt succeeded.
213 failure_reason: Short failure code when success=False.
214 """
215 ...
217 async def clear_lockout(self, email: str) -> None:
218 """Clear lockout and failure records on successful login.
220 Args:
221 email: Email to clear.
222 """
223 ...
226@runtime_checkable
227class AdminAuditLogStoreProtocol(Protocol):
228 """Persistence protocol for security audit log entries."""
230 async def ensure_schema(self) -> None:
231 """Create the admin_security_audit_log table if it does not exist."""
232 ...
234 async def insert(self, event: AdminSecurityEvent) -> None:
235 """Persist a security event.
237 Args:
238 event: Security event to store.
239 """
240 ...
242 async def query_recent(
243 self,
244 admin_user_id: str | None = None,
245 event_type: AdminSecurityEventType | None = None,
246 since_seconds: int = 3600,
247 limit: int = 100,
248 ) -> list[AdminSecurityEvent]:
249 """Query recent security events with optional filters.
251 Args:
252 admin_user_id: Filter to specific user (None = all users).
253 event_type: Filter to specific event type (None = all types).
254 since_seconds: Look-back window in seconds.
255 limit: Maximum records to return.
257 Returns:
258 List of matching security events, newest first.
259 """
260 ...
263@runtime_checkable
264class AdminAuditLogServiceProtocol(AuditLoggerProtocol, Protocol):
265 """Service for recording admin security events.
267 Extends the framework-wide ``AuditLoggerProtocol`` so that admin audit
268 implementations satisfy the cross-package contract. Adds admin-specific
269 methods (``log_event``, ``get_recent_events``) on top of the base
270 ``log()`` and ``query()`` methods from ``AuditLoggerProtocol``.
272 Implementations must never raise — audit failures are swallowed so that
273 an audit store outage cannot interrupt authentication flows.
274 """
276 async def log_event(
277 self,
278 event_type: AdminSecurityEventType,
279 ip_address: str,
280 user_agent: str,
281 success: bool,
282 admin_user_id: str | None = None,
283 metadata: dict[str, Any] | None = None,
284 ) -> None:
285 """Record a security event. Implementation must never raise.
287 Args:
288 event_type: Type of security event.
289 ip_address: Client IP.
290 user_agent: Client user agent.
291 success: Whether the operation succeeded.
292 admin_user_id: Associated admin user (None for pre-auth events).
293 metadata: Optional structured context.
294 """
295 ...
297 async def get_recent_events(
298 self,
299 admin_user_id: str | None = None,
300 since_seconds: int = 3600,
301 limit: int = 50,
302 ) -> list[AdminSecurityEvent]:
303 """Retrieve recent security events for display.
305 Args:
306 admin_user_id: Filter to specific user.
307 since_seconds: Look-back window.
308 limit: Maximum results.
310 Returns:
311 List of security events, newest first.
312 """
313 ...
316@runtime_checkable
317class AdminPasswordPolicyServiceProtocol(Protocol):
318 """Password policy validation service."""
320 def validate(
321 self,
322 password: str,
323 email: str | None = None,
324 ) -> AdminPasswordValidationResult:
325 """Validate a password against all configured policy rules.
327 Returns ALL violations, not just the first one.
329 Args:
330 password: Plain-text password to validate.
331 email: Optional email — used to check if password contains it.
333 Returns:
334 AdminPasswordValidationResult with is_valid and full violations list.
335 """
336 ...
339@runtime_checkable
340class AdminCsrfServiceProtocol(Protocol):
341 """CSRF token generation and validation service."""
343 def generate_token(self, session_id: str) -> str:
344 """Generate a CSRF token scoped to the given session.
346 Token format: base64url(timestamp:nonce:hmac_signature)
348 Args:
349 session_id: Session ID to scope the token to.
351 Returns:
352 CSRF token string.
353 """
354 ...
356 def validate_token(self, session_id: str, token: str) -> bool:
357 """Validate a CSRF token against the session.
359 Uses hmac.compare_digest for timing-safe comparison.
361 Args:
362 session_id: Session ID the token was generated for.
363 token: Token to validate.
365 Returns:
366 True if valid and not expired, False otherwise.
367 """
368 ...
371@runtime_checkable
372class AdminSessionServiceProtocol(Protocol):
373 """Admin session lifecycle management service."""
375 async def create_session(
376 self,
377 user_id: str,
378 email: str,
379 roles: list[str],
380 ip_address: str,
381 user_agent: str,
382 ) -> str:
383 """Create a new session and return the session ID.
385 Args:
386 user_id: Admin user UUID.
387 email: Admin user email.
388 roles: User's roles.
389 ip_address: Client IP.
390 user_agent: Client user agent.
392 Returns:
393 New session identifier (secrets.token_urlsafe(32)).
394 """
395 ...
397 async def get_session(self, session_id: str) -> dict[str, Any] | None:
398 """Retrieve session data if valid (not expired, not revoked).
400 Checks both idle timeout and absolute expiry.
402 Args:
403 session_id: Session to retrieve.
405 Returns:
406 Session data dict or None if not found/expired.
407 """
408 ...
410 async def touch_session(self, session_id: str) -> None:
411 """Update session last-active timestamp.
413 Args:
414 session_id: Session to touch.
415 """
416 ...
418 async def revoke_session(self, session_id: str) -> None:
419 """Revoke a single session.
421 Args:
422 session_id: Session to revoke.
423 """
424 ...
426 async def revoke_all_user_sessions(self, user_id: str) -> None:
427 """Revoke all sessions for a user.
429 Args:
430 user_id: Admin user UUID.
431 """
432 ...
435__all__ = [
436 "AdminAccountLockoutStoreProtocol",
437 "AdminAuditLogServiceProtocol",
438 "AdminAuditLogStoreProtocol",
439 "AdminAuthServiceProtocol",
440 "AdminCsrfServiceProtocol",
441 "AdminLoginAttemptServiceProtocol",
442 "AdminLoginAttemptStoreProtocol",
443 "AdminPasswordPolicyServiceProtocol",
444 "AdminSessionServiceProtocol",
445]