"""Authenticated session state for the Studio. A ``Session`` is the runtime context of a logged-in user. It carries the :class:`User`, exposes capability checks, and writes audit events for every gated decision (granted or denied). Window code reaches for the current Session via the :class:`SessionHolder` singleton; tests can swap it out trivially. """ from __future__ import annotations from pathlib import Path from typing import Optional from arautopilot.core.audit import AuditEvent, AuditLog, AuditOutcome from arautopilot.core.rbac import Capability, Role, has, requires_dual_auth from arautopilot.core.user import User from arautopilot.core.user_store import UserStore class Session: """The current user + their audit log + capability gate.""" def __init__( self, user: User, store: UserStore, audit: AuditLog, ) -> None: self.user = user self.store = store self.audit = audit @property def role(self) -> Role: return self.user.role def can(self, capability: Capability) -> bool: return has(self.user.role, capability) def check(self, capability: Capability, *, target: str | None = None, extra: dict[str, object] | None = None) -> bool: """Capability check with audit side effect. Returns True on grant, False on denial. Records an audit event either way so the trail is complete. """ granted = self.can(capability) self.audit.append( AuditEvent( user_id=self.user.user_id, role=self.user.role.value, action=f"check:{capability.value}", target=target, outcome=AuditOutcome.SUCCESS if granted else AuditOutcome.DENIED, reason="" if granted else f"role {self.user.role.value!r} lacks {capability.value!r}", extra=extra or {}, ) ) return granted def needs_dual_auth(self, capability: Capability) -> bool: return requires_dual_auth(self.user.role, capability) def verify_super_admin_pin(self, pin: str) -> Optional[User]: """Look up the (first) Super Admin in the store and verify the PIN. Returns the SA :class:`User` on success, ``None`` on failure. """ for sa in self.store.by_role(Role.SUPER_ADMIN): if sa.verify_pin(pin): return sa return None def log_dual_auth_grant( self, capability: Capability, sa_user: User, *, target: str | None = None, extra: dict[str, object] | None = None, ) -> None: """Record an audit event for a successful dual-auth approval.""" self.audit.append( AuditEvent( user_id=self.user.user_id, role=self.user.role.value, action=f"dual_auth_approved:{capability.value}", target=target, outcome=AuditOutcome.SUCCESS, secondary_user_id=sa_user.user_id, reason=f"approved by Super Admin {sa_user.display_name!r}", extra=extra or {}, ) ) def log_action( self, action: str, *, outcome: AuditOutcome = AuditOutcome.SUCCESS, target: str | None = None, reason: str = "", extra: dict[str, object] | None = None, ) -> None: """Free-form audit event for downstream business actions.""" self.audit.append( AuditEvent( user_id=self.user.user_id, role=self.user.role.value, action=action, target=target, outcome=outcome, reason=reason, extra=extra or {}, ) ) class SessionHolder: """Process-global current session (set after login).""" _current: Session | None = None @classmethod def set(cls, session: Session | None) -> None: cls._current = session @classmethod def current(cls) -> Session | None: return cls._current @classmethod def require(cls) -> Session: if cls._current is None: raise RuntimeError("no active Studio session -- log in first") return cls._current def studio_data_dir() -> Path: """Per-user directory under ``~/.ar-autopilot/studio/`` for the local user store and audit log.""" base = Path.home() / ".ar-autopilot" / "studio" base.mkdir(parents=True, exist_ok=True) return base