Files
Ten31-Portal/backend/ten31portal/auth.py
T
Jonathan Kirkwood ae967494bd 0.2.42: external Administrator role with entity-scoped management
The external fund_administrator role (relabeled Administrator) now signs
into the full admin interface, fenced to the funds and SPVs granted to
it via EntityAccess:

- Partners, capital accounts, documents (upload and delete), entity
  edits, and eNAV imports for its own funds only; no fund creation,
  valuation sign-off, audit log, or investor view.
- Scoped user management: sees and manages only investors tied to its
  funds; creates investor accounts only; updates preserve grants on
  funds outside its scope.
- New DELETE /api/users/{id} (in-app Delete user button) with the
  cascade cleanup factored out of the CLI; Service Admin and self are
  protected, and an Administrator can only delete an investor who
  belongs solely to its funds.
- Internal fund_admin relabeled 'Staff (all funds)' and dropped from
  the create picker to end the two-similar-names confusion.
- Version badge removed from the UI (sidebar and portal header); the
  build version now logs to the browser console instead.
- deploy/.startos (signing key) added to .gitignore.
2026-08-10 15:38:39 -05:00

126 lines
4.9 KiB
Python

"""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)
# Internal admins plus the external Administrator (fund_administrator). Every endpoint
# using this gate must also call check_administrator_scope for the entity it touches —
# the role alone says nothing about WHICH entities an Administrator may manage.
require_admin = require_role(
UserRole.approver, UserRole.cfo, UserRole.operations, UserRole.fund_administrator
)
# Entity-record writers: internal writers plus the external Administrator (scope-checked).
require_entity_writer = require_role(
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations,
UserRole.fund_administrator,
)
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
"""Read gate for admin screens: any internal role, or the external Administrator.
Investors are blocked; Administrator calls must still be scope-checked per entity.
"""
if user.role == UserRole.investor:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
"""403 when an external Administrator touches an entity outside their grants.
Internal roles pass through untouched — their reach is decided by the route's gate.
"""
if user.role == UserRole.fund_administrator and not can_access_entity(user, entity_id, session):
raise HTTPException(status_code=403, detail="No access to this entity")