"""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 {} for r in cap_rows: d = CapitalAccountResponse.model_validate(r, from_attributes=True) d.investor_name = names.get(r.investor_user_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) 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"}