"""Who the signed-in user is and what they may do: identity, roles and account state (AD-5). The directory is the source of truth. Roles and account state are cached in the session for a short time; a directory outage denies elevated roles, and a disabled or locked account ends all of its sessions at its next request. """ from __future__ import annotations import logging import time from dataclasses import dataclass from django.conf import settings from django.db import transaction from portal.adapters.directory import DirectoryUnavailable, DirectoryUser from portal.adapters.registry import get_directory from portal.audit import services as audit from .session_store import DISPLAY_NAME, ROLES from .sessions import SessionUser, current_user, end_sessions logger = logging.getLogger(__name__) class SignInRefused(Exception): """The directory does not allow this account to start a session; `reason` is for the audit.""" def __init__(self, reason: str) -> None: self.reason = reason super().__init__(f"Sign-in refused: {reason}") @dataclass(frozen=True) class PortalUser: user: SessionUser display_name: str is_publisher: bool is_admin: bool def _display_name(user: DirectoryUser) -> str: full_name = " ".join(p for p in (user.given_name, user.surname) if p) return user.display_name or full_name or user.sam def admit(username: str) -> DirectoryUser: """The directory user for a verified sign-in name, or SignInRefused. Raises DirectoryUnavailable when the directory cannot answer; the caller denies the sign-in. """ user = get_directory().find_user(username) if user is None: raise SignInRefused("unknown") if not user.enabled: raise SignInRefused("disabled") if user.locked: raise SignInRefused("locked") return user def remember(request, user: DirectoryUser) -> None: """Store what the portal shows and decides with, with the time it was read.""" request.session[DISPLAY_NAME] = _display_name(user) request.session[ROLES] = { "publisher": user.is_publisher, "admin": user.is_admin, "checked": time.time(), } def current(request) -> PortalUser | None: """The signed-in user for this request, refreshing roles and state when the cache is old.""" user = current_user(request) if user is None: return None session = request.session cached = session.get(ROLES) name = session.get(DISPLAY_NAME) or user.sam age = time.time() - cached.get("checked", 0) if isinstance(cached, dict) else None if age is not None and age < settings.ROLE_CACHE_SECONDS: return PortalUser(user, name, bool(cached.get("publisher")), bool(cached.get("admin"))) try: fresh = get_directory().find_user(user.sam) except DirectoryUnavailable: logger.warning("Roles could not be refreshed; elevated roles are denied") return PortalUser(user, name, False, False) # the old cache stays expired if fresh is None or not fresh.enabled or fresh.locked: with transaction.atomic(): end_sessions(user) audit.record( user.key, "session.ended", ip=getattr(request, "client_ip", ""), detail={"reason": "account_unavailable"}, ) session.flush() return None remember(request, fresh) return PortalUser(user, _display_name(fresh), fresh.is_publisher, fresh.is_admin)