Coverage for src/lexigram/admin/auth/session_manager.py: 99%
68 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Admin Session Manager — pure session-lifecycle business logic.
3All persistence is delegated to an injected ``SessionRepositoryProtocol``. This
4module contains no SQL, no DDL, and no driver-specific code.
5"""
7from __future__ import annotations
9from datetime import UTC, datetime, timedelta
10from typing import Any
11import uuid
13from lexigram.contracts.auth.fingerprint import generate_device_id
14from lexigram.contracts.auth.models import UserSession
15from lexigram.contracts.auth.repositories import SessionRepositoryProtocol
16from lexigram.di.decorators import inject
17from lexigram.logging import get_logger
19logger = get_logger(__name__)
22@inject
23class AdminSessionManager:
24 """Lifecycle manager for admin user sessions.
26 Enforces concurrency limits, expiry, and activity tracking. All I/O is
27 performed through the injected ``SessionRepositoryProtocol`` so the class remains
28 fully testable without a real database.
29 """
31 DEFAULT_EXPIRY_DAYS = 30
32 MAX_CONCURRENT_SESSIONS = 5
34 def __init__(self, repo: SessionRepositoryProtocol) -> None:
35 """Initialise with the session repository.
37 Args:
38 repo: Persistence abstraction for session records. Injected by
39 the DI container; typically backed by
40 ``AdminSessionSqlRepository``.
41 """
42 self._repo = repo
44 async def create_session(
45 self,
46 user_id: str,
47 fingerprint_data: dict[str, Any],
48 ip_address: str | None = None,
49 user_agent: str | None = None,
50 expires_days: int = DEFAULT_EXPIRY_DAYS,
51 ) -> UserSession:
52 """Create and persist a new admin session.
54 Enforces ``MAX_CONCURRENT_SESSIONS`` by revoking the oldest session
55 when the limit is reached before creating a new one.
57 Args:
58 user_id: Admin user identifier.
59 fingerprint_data: Device/browser fingerprint dict used to derive
60 a stable ``device_id``.
61 ip_address: Originating IP (optional, for audit metadata).
62 user_agent: Request User-Agent header (optional).
63 expires_days: Session lifetime in days.
65 Returns:
66 Hydrated ``UserSession`` for the newly created session.
67 """
68 device_id = generate_device_id(fingerprint_data)
69 now = datetime.now(UTC)
70 expires_at = now + timedelta(days=expires_days)
72 # Enforce concurrent-session cap — evict oldest first.
73 active = await self.get_active_sessions(user_id)
74 if len(active) >= self.MAX_CONCURRENT_SESSIONS:
75 oldest = min(active, key=lambda s: s.last_active_at or s.created_at) # type: ignore[arg-type, return-value]
76 await self.revoke_session(oldest.session_id)
77 logger.info("Evicted oldest admin session for user %s", user_id)
79 session_id = str(uuid.uuid4())
80 payload: dict[str, Any] = {
81 "session_id": session_id,
82 "admin_id": user_id,
83 "device_id": device_id,
84 "ip_address": ip_address,
85 "user_agent": user_agent,
86 "fingerprint": fingerprint_data,
87 "expires_at": expires_at,
88 }
90 await self._repo.insert(payload)
92 session = UserSession(
93 session_id=session_id,
94 user_id=user_id,
95 device_id=device_id,
96 ip_address=ip_address or "",
97 user_agent=user_agent or "",
98 fingerprint=fingerprint_data,
99 expires_at=expires_at,
100 created_at=now,
101 last_active_at=now,
102 is_active=True,
103 )
105 logger.info("Created admin session %s for user %s", session_id, user_id)
106 return session
108 async def validate_session(self, session_id: str) -> UserSession | None:
109 """Validate a session and refresh its activity timestamp.
111 Args:
112 session_id: Opaque session identifier to validate.
114 Returns:
115 Hydrated ``UserSession`` if active and unexpired, else ``None``.
116 """
117 row = await self._repo.find_active(session_id)
118 if not row:
119 return None
121 expires_at = row.get("expires_at")
122 if expires_at and datetime.now(UTC) > expires_at:
123 await self.revoke_session(session_id)
124 logger.warning("Admin session %s expired", session_id)
125 return None
127 await self.update_activity(session_id)
128 return self._row_to_session(row)
130 async def get_session(self, session_id: str) -> UserSession | None:
131 """Retrieve an admin session by ID without refreshing activity.
133 Args:
134 session_id: Opaque session identifier.
136 Returns:
137 Hydrated ``UserSession``, or ``None`` when absent or inactive.
138 """
139 row = await self._repo.find_active(session_id)
140 if not row:
141 return None
143 expires_at = row.get("expires_at")
144 if expires_at and datetime.now(UTC) > expires_at:
145 await self.revoke_session(session_id)
146 return None
148 return self._row_to_session(row)
150 async def get_active_sessions(self, user_id: str) -> list[UserSession]:
151 """Return all active, non-expired sessions for an admin user.
153 Args:
154 user_id: Admin owner identifier.
156 Returns:
157 List of ``UserSession`` objects, newest-activity first.
158 """
159 rows = await self._repo.find_active_by_user(user_id, datetime.now(UTC))
160 return [self._row_to_session(r) for r in rows]
162 async def revoke_session(self, session_id: str) -> bool:
163 """Revoke a single admin session.
165 Args:
166 session_id: Session to deactivate.
168 Returns:
169 ``True`` after successful revocation.
170 """
171 await self._repo.revoke(session_id)
172 logger.info("Revoked admin session %s", session_id)
173 return True
175 async def revoke_all_sessions(self, user_id: str) -> int:
176 """Revoke every active session for an admin user.
178 Args:
179 user_id: Owner whose sessions are to be revoked.
181 Returns:
182 ``1`` to indicate the operation ran (count of affected sessions
183 is not surfaced by all backends).
184 """
185 await self._repo.revoke_all(user_id)
186 logger.info("Revoked all admin sessions for user %s", user_id)
187 return 1
189 async def update_activity(self, session_id: str) -> bool:
190 """Refresh the last-active timestamp for a session.
192 Args:
193 session_id: Session to touch.
195 Returns:
196 ``True`` after the update is sent to the repository.
197 """
198 await self._repo.update_activity(session_id, datetime.now(UTC))
199 return True
201 # ------------------------------------------------------------------
202 # Internal helpers
203 # ------------------------------------------------------------------
205 @staticmethod
206 def _row_to_session(row: dict[str, Any]) -> UserSession:
207 """Map a raw repository row dict to a ``UserSession`` value object."""
208 return UserSession(
209 session_id=row["session_id"],
210 user_id=row["admin_id"],
211 device_id=row.get("device_id", ""),
212 ip_address=row.get("ip_address", ""),
213 user_agent=row.get("user_agent", ""),
214 fingerprint=row.get("fingerprint", {}),
215 expires_at=row.get("expires_at"),
216 created_at=row.get("created_at"),
217 last_active_at=row.get("last_active_at"),
218 is_active=row.get("is_active", True),
219 )