Coverage for src/lexigram/admin/services/impersonation.py: 92%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""User impersonation service for admin panels. 

2 

3Allows super-admin users to temporarily assume the identity of another user 

4for debugging and support purposes. Every impersonation session is audit- 

5logged. The original admin identity is preserved so it can be restored. 

6 

7Usage:: 

8 

9 service = ImpersonationService( 

10 audit_logger=audit_logger, 

11 policy=my_policy, 

12 ) 

13 

14 result = await service.start( 

15 actor=admin_user, 

16 target_user_id="user-123", 

17 request=request, 

18 reason="Support ticket #456", 

19 ) 

20 

21 if result.is_ok(): 

22 impersonation = result.unwrap() 

23 # Swap session identity to impersonation.target_user_id ... 

24 

25 # Later, to restore the original admin identity: 

26 await service.stop(admin_user, request) 

27 

28.. note:: 

29 Wired via ``ImpersonateAction`` (``lexigram.admin.actions.standard``) 

30 and ``ImpersonationController`` (``lexigram.admin.controllers. 

31 impersonation``), which register ``POST /admin/impersonate/{user_id}`` 

32 and ``POST /admin/impersonate/stop``. See 

33 ``docs/superpowers/specs/2026-08-16-security-impersonation-design.md`` 

34 for the security gaps that were closed before wiring (nested 

35 sessions, target-role restriction, multi-worker session visibility). 

36""" 

37 

38from __future__ import annotations 

39 

40from dataclasses import dataclass, field 

41from datetime import UTC, datetime 

42from typing import Any 

43import uuid 

44 

45from lexigram.admin.config import AdminRbacConfig 

46from lexigram.admin.exceptions import NotFoundError, PermissionDeniedError 

47from lexigram.admin.rbac.super_admin import is_super_admin 

48from lexigram.contracts.audit import AuditEntry, AuditEventSeverity, AuditLoggerProtocol 

49from lexigram.di.decorators import inject 

50from lexigram.logging import get_logger 

51from lexigram.result import Err, Ok, Result 

52 

53logger = get_logger(__name__) 

54 

55# Session-state key used to store impersonation context 

56_SESSION_KEY = "_admin_impersonation" 

57# Attribute on request.state that holds original admin identity 

58_ORIGINAL_USER_KEY = "_impersonation_original_user_id" 

59 

60 

61@dataclass(frozen=True) 

62class ImpersonationSession: 

63 """Represents an active impersonation session. 

64 

65 Attributes: 

66 id: Unique impersonation session identifier. 

67 actor_id: Admin user who initiated the impersonation. 

68 target_user_id: User being impersonated. 

69 started_at: UTC timestamp when impersonation began. 

70 reason: Optional free-text reason for audit trail. 

71 """ 

72 

73 id: str = field(default_factory=lambda: str(uuid.uuid4())) 

74 actor_id: str = "" 

75 target_user_id: str = "" 

76 started_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

77 reason: str = "" 

78 

79 

80class ImpersonationPolicy: 

81 """Policy: only holders of the configured super-admin role may impersonate. 

82 

83 Override ``can_impersonate`` to customise authorisation logic. 

84 """ 

85 

86 def __init__(self, super_admin_role: str = "superadmin") -> None: 

87 """Store the role name that grants impersonation rights. 

88 

89 Args: 

90 super_admin_role: Role name (default ``"superadmin"``) that 

91 grants super-admin rights. 

92 """ 

93 self._super_admin_role = super_admin_role 

94 

95 def can_impersonate(self, actor: Any) -> bool: 

96 """Return True if *actor* is allowed to impersonate another user. 

97 

98 Args: 

99 actor: The admin user attempting to impersonate. 

100 

101 Returns: 

102 True if the actor holds the configured super-admin role. 

103 """ 

104 return is_super_admin(actor, self._super_admin_role) 

105 

106 def can_impersonate_target(self, target_roles: list[str]) -> bool: 

107 """Return True unless *target_roles* includes the super-admin role. 

108 

109 Args: 

110 target_roles: Role names held by the user being impersonated. 

111 

112 Returns: 

113 False if the target holds the configured super-admin role 

114 (impersonating another super-admin is never allowed); True 

115 otherwise, including when *target_roles* is empty. 

116 """ 

117 return self._super_admin_role not in target_roles 

118 

119 

120@inject 

121class ImpersonationService: 

122 """Service that manages user impersonation sessions. 

123 

124 Args: 

125 audit_logger: Optional audit logger (AuditLoggerProtocol). 

126 If ``None``, impersonation events are only logged via the standard 

127 logger; no structured audit record is persisted. 

128 policy: Policy object that determines who may impersonate. 

129 Defaults to ``ImpersonationPolicy`` (superadmin-only). 

130 active_sessions: Optional pre-populated in-memory store used in tests. 

131 """ 

132 

133 def __init__( 

134 self, 

135 audit_logger: AuditLoggerProtocol | None = None, 

136 policy: ImpersonationPolicy | None = None, 

137 active_sessions: dict[str, ImpersonationSession] | None = None, 

138 rbac_config: AdminRbacConfig | None = None, 

139 ) -> None: 

140 self._audit = audit_logger 

141 self._policy = policy or ImpersonationPolicy( 

142 super_admin_role=(rbac_config or AdminRbacConfig()).super_admin_role 

143 ) 

144 # actor_id → active ImpersonationSession 

145 self._sessions: dict[str, ImpersonationSession] = ( 

146 active_sessions if active_sessions is not None else {} 

147 ) 

148 

149 # ------------------------------------------------------------------ 

150 # Public API 

151 # ------------------------------------------------------------------ 

152 

153 async def start( 

154 self, 

155 actor: Any, 

156 target_user_id: str, 

157 reason: str = "", 

158 request: Any | None = None, 

159 target_roles: list[str] | None = None, 

160 ) -> Result[ImpersonationSession, PermissionDeniedError]: 

161 """Begin impersonating *target_user_id* on behalf of *actor*. 

162 

163 Args: 

164 actor: The authenticated admin user requesting impersonation. 

165 target_user_id: ID of the user to impersonate. 

166 reason: Free-text reason recorded in the audit trail. 

167 request: Optional Starlette request — if provided, the 

168 impersonation token is stored in ``request.session``. 

169 target_roles: Optional list of role names held by the target 

170 user. When provided and it includes the configured 

171 super-admin role, the attempt is denied. 

172 

173 Returns: 

174 ``Ok(ImpersonationSession)`` on success, or 

175 ``Err(PermissionDeniedError)`` if the actor is not authorised 

176 or already has an active impersonation session. 

177 """ 

178 actor_id: str = getattr(actor, "id", str(actor)) 

179 

180 if not self._policy.can_impersonate(actor): 

181 logger.warning( 

182 "impersonation.denied", 

183 actor_id=actor_id, 

184 target_user_id=target_user_id, 

185 ) 

186 return Err( 

187 PermissionDeniedError( 

188 f"User {actor_id!r} is not authorised to impersonate other users" 

189 ) 

190 ) 

191 

192 # D1 — nested-session guard: an actor with a live impersonation 

193 # session must stop it before starting another. Silently replacing 

194 # the session would orphan the original target identity and break 

195 # audit attribution. 

196 existing = self._sessions.get(actor_id) 

197 if existing is not None: 

198 logger.warning( 

199 "impersonation.nested_denied", 

200 actor_id=actor_id, 

201 active_target_user_id=existing.target_user_id, 

202 requested_target_user_id=target_user_id, 

203 ) 

204 return Err( 

205 PermissionDeniedError( 

206 f"User {actor_id!r} is already impersonating " 

207 f"{existing.target_user_id!r}; stop the active " 

208 "impersonation session first" 

209 ) 

210 ) 

211 

212 # D2 — target-role restriction: a super-admin must never be 

213 # impersonated, even by another super-admin. The caller supplies 

214 # the target's roles as resolved from the user store. 

215 if target_roles and not self._policy.can_impersonate_target(target_roles): 

216 logger.warning( 

217 "impersonation.target_denied", 

218 actor_id=actor_id, 

219 target_user_id=target_user_id, 

220 ) 

221 return Err( 

222 PermissionDeniedError( 

223 f"Target user {target_user_id!r} holds the super-admin role " 

224 "and cannot be impersonated." 

225 ) 

226 ) 

227 

228 session = ImpersonationSession( 

229 actor_id=actor_id, 

230 target_user_id=target_user_id, 

231 reason=reason, 

232 ) 

233 self._sessions[actor_id] = session 

234 

235 if request is not None: 

236 req_session = getattr(request, "session", None) 

237 if req_session is not None: 

238 req_session[_SESSION_KEY] = { 

239 "id": session.id, 

240 "actor_id": actor_id, 

241 "target_user_id": target_user_id, 

242 "started_at": session.started_at.isoformat(), 

243 "reason": reason, 

244 } 

245 req_session[_ORIGINAL_USER_KEY] = actor_id 

246 

247 await self._emit_audit( 

248 action="impersonation.start", 

249 actor_id=actor_id, 

250 target_user_id=target_user_id, 

251 session_id=session.id, 

252 reason=reason, 

253 request=request, 

254 ) 

255 logger.info( 

256 "impersonation.started", 

257 actor_id=actor_id, 

258 target_user_id=target_user_id, 

259 session_id=session.id, 

260 ) 

261 return Ok(session) 

262 

263 async def stop( 

264 self, 

265 actor: Any, 

266 request: Any | None = None, 

267 ) -> Result[str, NotFoundError]: 

268 """End the active impersonation session for *actor*. 

269 

270 Args: 

271 actor: The authenticated admin user (original identity). 

272 request: Optional Starlette request — session cookie is cleared 

273 when provided. 

274 

275 Returns: 

276 ``Ok(original_user_id)`` or ``Err(NotFoundError)`` when no 

277 active session exists. 

278 """ 

279 actor_id: str = getattr(actor, "id", str(actor)) 

280 

281 session = self._sessions.pop(actor_id, None) 

282 if session is None and request is not None: 

283 req_session = getattr(request, "session", None) 

284 if req_session: 

285 raw = req_session.get(_SESSION_KEY) 

286 if isinstance(raw, dict): 

287 actor_id = raw.get("actor_id", actor_id) 

288 session = ImpersonationSession( 

289 id=raw.get("id", ""), 

290 actor_id=actor_id, 

291 target_user_id=raw.get("target_user_id", ""), 

292 reason=raw.get("reason", ""), 

293 ) 

294 

295 if session is None: 

296 return Err(NotFoundError("No active impersonation session")) 

297 

298 if request is not None: 

299 req_session = getattr(request, "session", None) 

300 if req_session is not None: 

301 req_session.pop(_SESSION_KEY, None) 

302 req_session.pop(_ORIGINAL_USER_KEY, None) 

303 

304 await self._emit_audit( 

305 action="impersonation.stop", 

306 actor_id=session.actor_id, 

307 target_user_id=session.target_user_id, 

308 session_id=session.id, 

309 reason="", 

310 request=request, 

311 ) 

312 logger.info( 

313 "impersonation.stopped", 

314 actor_id=session.actor_id, 

315 target_user_id=session.target_user_id, 

316 session_id=session.id, 

317 ) 

318 return Ok(session.actor_id) 

319 

320 def get_active_session( 

321 self, actor_id: str, request: Any | None = None 

322 ) -> ImpersonationSession | None: 

323 """Return the active ``ImpersonationSession`` for *actor_id*, or ``None``. 

324 

325 Args: 

326 actor_id: Admin user ID to look up. 

327 request: Optional Starlette request — when the in-process 

328 session store has no entry (e.g. a different worker 

329 handled the original ``start()`` call), the session is 

330 reconstructed from ``request.session`` if present. 

331 

332 Returns: 

333 Active session, or ``None`` if the user is not impersonating. 

334 """ 

335 session = self._sessions.get(actor_id) 

336 if session is not None: 

337 return session 

338 if request is not None: 

339 req_session = getattr(request, "session", None) 

340 raw = req_session.get(_SESSION_KEY) if req_session else None 

341 if isinstance(raw, dict) and raw.get("actor_id") == actor_id: 

342 return ImpersonationSession( 

343 id=raw.get("id", ""), 

344 actor_id=actor_id, 

345 target_user_id=raw.get("target_user_id", ""), 

346 reason=raw.get("reason", ""), 

347 ) 

348 return None 

349 

350 def is_impersonating(self, actor_id: str, request: Any | None = None) -> bool: 

351 """Return ``True`` if *actor_id* currently has an active impersonation. 

352 

353 Args: 

354 actor_id: Admin user ID to check. 

355 request: Optional Starlette request, passed through to 

356 ``get_active_session`` for cross-worker fallback. 

357 

358 Returns: 

359 True if an active session exists for this actor. 

360 """ 

361 return self.get_active_session(actor_id, request) is not None 

362 

363 def list_active(self) -> list[ImpersonationSession]: 

364 """Return all currently active impersonation sessions. 

365 

366 Returns: 

367 List of active ``ImpersonationSession`` objects. 

368 """ 

369 return list(self._sessions.values()) 

370 

371 # ------------------------------------------------------------------ 

372 # Internal helpers 

373 # ------------------------------------------------------------------ 

374 

375 async def _emit_audit( 

376 self, 

377 action: str, 

378 actor_id: str, 

379 target_user_id: str, 

380 session_id: str, 

381 reason: str, 

382 request: Any | None, 

383 ) -> None: 

384 """Emit an audit log entry if an audit logger is configured.""" 

385 if self._audit is None: 

386 return 

387 

388 ip = None 

389 user_agent = None 

390 if request is not None: 

391 client = getattr(request, "client", None) 

392 ip = getattr(client, "host", None) if client else None 

393 headers = getattr(request, "headers", {}) 

394 user_agent = headers.get("user-agent") 

395 

396 await self._audit.log( 

397 AuditEntry( 

398 action=action, 

399 actor_id=actor_id, 

400 resource_type="admin_user", 

401 resource_id=target_user_id, 

402 outcome="success", 

403 severity=AuditEventSeverity.CRITICAL, 

404 source="admin", 

405 metadata={ 

406 "impersonation_session_id": str(session_id), 

407 "reason": str(reason) if reason else "", 

408 "ip_address": str(ip) if ip else "", 

409 "user_agent": str(user_agent) if user_agent else "", 

410 }, 

411 ) 

412 )