"""Authentication, session management, and role enforcement.""" from typing import Annotated from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, HTTPException, Request from sqlmodel import Session, select, col from ten31portal.database import get_session from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES ph = PasswordHasher() def hash_password(password: str) -> str: return ph.hash(password) def verify_password(password: str, password_hash: str) -> bool: try: return ph.verify(password_hash, password) except VerifyMismatchError: return False def get_current_user(request: Request, session: Session = Depends(get_session)) -> User: """FastAPI dependency: extract user from session cookie.""" user_id = request.session.get("user_id") if user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") user = session.get(User, user_id) if user is None or not user.is_active: raise HTTPException(status_code=401, detail="Not authenticated") return user def require_role(*roles: UserRole): """Return a dependency that enforces one of the given roles.""" def checker(user: User = Depends(get_current_user)) -> User: if user.role not in roles: raise HTTPException(status_code=403, detail="Insufficient permissions") return user return checker def require_internal(user: User = Depends(get_current_user)) -> User: """Block external (entity-scoped) accounts from internal staff endpoints.""" if user.role in EXTERNAL_ROLES: raise HTTPException(status_code=403, detail="Insufficient permissions") return user def household_user_ids(user: User, session: Session) -> list[int]: """All account ids that share this user's login. An investor who invests under several legal names has one "primary" account (the login) and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as the primary should surface every linked name's entities, statements, and documents. For a standalone account this is just ``[user.id]``. """ root_id = user.primary_account_id or user.id linked = session.exec( select(User.id).where(User.primary_account_id == root_id) ).all() return list({root_id, user.id, *linked}) def accessible_entity_ids(user: User, session: Session) -> set[int] | None: """Entity ids an external account may view. None means unrestricted (internal staff).""" if user.role not in EXTERNAL_ROLES: return None rows = session.exec( select(EntityAccess.entity_id).where( col(EntityAccess.user_id).in_(household_user_ids(user, session)) ) ).all() return set(rows) def can_access_entity(user: User, entity_id: int, session: Session) -> bool: allowed = accessible_entity_ids(user, session) return allowed is None or entity_id in allowed # Convenience aliases require_user = get_current_user require_writer = require_role( UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations ) require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) # Account administration (create/manage users, documents, capital accounts) require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)