Files
Ten31-Portal/backend/ten31portal/routers/user_router.py
T
Jonathan KirkwoodandClaude Fable 5 eac3262f29 0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center
- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:01:50 +02:00

386 lines
15 KiB
Python

"""User administration: create and manage accounts and their entity access."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, can_access_entity, get_current_user, hash_password,
household_user_ids, require_internal_admin,
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole,
)
from ten31portal.schemas import (
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset,
UserCreate, UserDetailResponse, UserResponse, UserUpdate,
)
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/access-matrix")
def access_matrix(
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> AccessMatrixResponse:
"""External accounts, all entities, and the grants linking them."""
users = session.exec(
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
).all()
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
grants = session.exec(select(EntityAccess)).all()
return AccessMatrixResponse(
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants],
)
@router.put("/{user_id}/access/{entity_id}", status_code=200)
def grant_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
existing = session.exec(
select(EntityAccess).where(
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
)
).first()
if existing is None:
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id})
session.commit()
return {"status": "ok"}
@router.delete("/{user_id}/access/{entity_id}")
def revoke_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
existing = session.exec(
select(EntityAccess).where(
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
)
).first()
if existing is not None:
session.delete(existing)
record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id})
session.commit()
return {"status": "ok"}
@router.get("/investors-for-entity/{entity_id}")
def investors_for_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[UserResponse]:
"""Investor accounts with access to an entity. For internal staff and the entity's fund admins."""
if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not (
user.role == UserRole.fund_administrator
and can_access_entity(user, entity_id, session)
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
rows = session.exec(
select(User)
.join(EntityAccess, EntityAccess.user_id == User.id)
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
.order_by(User.name) # type: ignore[arg-type]
).all()
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{user_id}/investor-view")
def investor_view(
user_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> InvestorViewResponse:
"""Reconstruct exactly what an investor sees in their portal — read-only, for admins.
No session impersonation: this returns the same data the investor's own portal would load
(their accessible entities, their capital statements, and the documents visible to them),
scoped with the same access helpers.
"""
target = session.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
if target.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Investor View is for investor accounts.")
allowed = accessible_entity_ids(target, session) or set()
household = household_user_ids(target, session)
entities = session.exec(
select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type]
).all() if allowed else []
caps: list[CapitalAccountResponse] = []
docs: list[DocumentResponse] = []
if allowed:
cap_rows = session.exec(
select(CapitalAccountStatement)
.where(
col(CapitalAccountStatement.investor_user_id).in_(household),
col(CapitalAccountStatement.entity_id).in_(allowed),
)
.order_by(col(CapitalAccountStatement.as_of_date).desc())
).all()
names = dict(session.exec(
select(User.id, User.name).where(
col(User.id).in_({r.investor_user_id for r in cap_rows})
)
).all()) if cap_rows else {}
# Mirror the investor's own portal exactly — including exit status and BTC marks,
# so this view never drifts from what the LP actually sees.
from ten31portal.routers.capital_account_router import btc_marks, exit_dates
exits = exit_dates(session, cap_rows)
btc_asof, btc_close = btc_marks(session, cap_rows)
for r in cap_rows:
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
d.investor_name = names.get(r.investor_user_id)
d.exited_on = exits.get((r.investor_user_id, r.entity_id))
d.btc_price_cents = btc_asof.get(r.id)
d.btc_close_price_cents = btc_close.get(r.entity_id)
caps.append(d)
doc_rows = session.exec(
select(Document)
.where(col(Document.entity_id).in_(allowed))
.order_by(col(Document.created_at).desc())
).all()
# Investor sees shared docs and those addressed to any of their linked names.
docs = [
DocumentResponse.model_validate(d, from_attributes=True)
for d in doc_rows
if d.investor_user_id is None or d.investor_user_id in household
]
return InvestorViewResponse(
user=UserResponse.model_validate(target, from_attributes=True),
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
capital_accounts=caps,
documents=docs,
)
def _entity_ids_for(user_id: int, session: Session) -> list[int]:
return list(session.exec(
select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id)
).all())
def _user_detail(user: User, session: Session) -> UserDetailResponse:
"""Build a full user detail, including the linked-account relationships."""
data = UserResponse.model_validate(user, from_attributes=True).model_dump()
primary_name = None
if user.primary_account_id:
primary = session.get(User, user.primary_account_id)
primary_name = primary.name if primary else None
linked = session.exec(
select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type]
).all()
return UserDetailResponse(
**data,
primary_account_name=primary_name,
linked_accounts=[
LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked
],
entity_ids=_entity_ids_for(user.id, session),
)
def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None:
"""Replace a user's entity grants with the given set, ignoring unknown ids."""
valid = set(session.exec(
select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr]
).all()) if entity_ids else set()
existing = session.exec(
select(EntityAccess).where(EntityAccess.user_id == user_id)
).all()
current = {a.entity_id: a for a in existing}
# Remove grants no longer wanted.
for eid, access in current.items():
if eid not in valid:
session.delete(access)
# Add new grants.
for eid in valid:
if eid not in current:
session.add(EntityAccess(user_id=user_id, entity_id=eid))
@router.get("")
def list_users(
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> list[UserResponse]:
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{user_id}")
def get_user(
user_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return _user_detail(user, session)
@router.post("", status_code=201)
def create_user(
body: UserCreate,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
if session.exec(select(User).where(User.username == body.username)).first():
raise HTTPException(status_code=409, detail="Username already taken")
if body.email and session.exec(select(User).where(User.email == body.email)).first():
raise HTTPException(status_code=409, detail="Email already in use")
user = User(
name=body.name,
username=body.username,
email=body.email or None,
password_hash=hash_password(body.password),
role=body.role,
)
session.add(user)
session.flush()
_set_entity_access(user.id, body.entity_ids, session)
record_audit(session, admin.id, "create", "user", user.id,
{"username": body.username, "role": body.role.value,
"entity_ids": body.entity_ids})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.patch("/{user_id}")
def update_user(
user_id: int,
body: UserUpdate,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
changes = body.model_dump(exclude_unset=True)
entity_ids = changes.pop("entity_ids", None)
if "username" in changes:
new_username = (changes["username"] or "").strip()
if not new_username:
raise HTTPException(status_code=400, detail="Username cannot be blank")
clash = session.exec(select(User).where(User.username == new_username)).first()
if clash and clash.id != user_id:
raise HTTPException(status_code=409, detail="Username already taken")
changes["username"] = new_username
if "email" in changes and changes["email"]:
clash = session.exec(select(User).where(User.email == changes["email"])).first()
if clash and clash.id != user_id:
raise HTTPException(status_code=409, detail="Email already in use")
for key, val in changes.items():
setattr(user, key, val)
session.add(user)
session.flush()
if entity_ids is not None:
_set_entity_access(user_id, entity_ids, session)
record_audit(session, admin.id, "update", "user", user_id,
{**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.put("/{user_id}/primary-account")
def link_account(
user_id: int,
body: AccountLink,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
"""Link an investor account to a primary login (or detach it when null).
The primary becomes the single sign-on that sees every linked name's investments. The
linked account's own login is disabled so there is one set of credentials per person.
"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
primary_id = body.primary_account_id
if primary_id is not None:
if primary_id == user_id:
raise HTTPException(status_code=400, detail="An account cannot link to itself.")
primary = session.get(User, primary_id)
if primary is None or primary.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
if primary.primary_account_id is not None:
raise HTTPException(
status_code=400,
detail="That account is itself linked to another login. Link to a primary instead.",
)
# Prevent chains: an account that other names log in under can't become a secondary.
if session.exec(select(User).where(User.primary_account_id == user_id)).first():
raise HTTPException(
status_code=400,
detail="This account is a primary for other names. Detach those first.",
)
# Login is blocked while primary_account_id is set (see auth_router.login), so we leave
# login_enabled untouched — unlinking then restores the account's own sign-in cleanly.
user.primary_account_id = primary_id
else:
user.primary_account_id = None
session.add(user)
record_audit(session, admin.id, "link_account", "user", user_id,
{"primary_account_id": primary_id})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.post("/{user_id}/reset-password")
def reset_password(
user_id: int,
body: PasswordReset,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user.password_hash = hash_password(body.password)
# Admin handed them a real password — no forced change on next login.
user.must_change_password = False
user.login_enabled = True # setting a password enables login
session.add(user)
record_audit(session, admin.id, "reset_password", "user", user_id, None)
session.commit()
return {"status": "ok"}