Files
Ten31-Portal/backend/ten31portal/routers/entity_router.py
T
Jonathan Kirkwood 3a4b7dfd8b 0.2.46: security hardening (external-fund scope, role guards, forced password rotation, headers)
Pre-release audit fixes:
- External Administrators can only act on investors already in their own funds
  (grant_access, create_statement, set_partner_exited, import match), closing a
  cross-fund account-takeover chain.
- Role and is_active changes are gated: no self-escalation, non-approvers cannot
  mint or alter approver accounts, and the Service Admin is protected.
- must_change_password is enforced server-side, not just in the client.
- Session cookie is Secure with a 12h max-age and SameSite; CSP and standard
  security headers on every response; the app refuses to boot on a known or short
  session secret; uvicorn runs with proxy-headers so login throttling keys on the
  real client IP behind the platform proxy.
- Import preview no longer confirms cross-fund investors; no-store on document
  downloads.
- Adds backend/tests/test_security_hardening.py (15 tests); full suite 76 passing.
- SECURITY-AUDIT.md records the swarm audit, findings, and remediation status.
2026-08-28 09:53:16 -05:00

449 lines
18 KiB
Python

"""Entity CRUD and rollup endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, literal
from sqlmodel import Session, col, select
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
get_current_user, household_user_ids, require_entity_writer, require_internal,
require_internal_or_administrator, require_writer,
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
)
from ten31portal.schemas import (
AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse,
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerExitUpdate,
PartnerResponse,
)
router = APIRouter(prefix="/api/entities", tags=["entities"])
class EntityRollupItem(BaseModel):
id: int
name: str
type: str
vintage_year: int | None
fund_size_cents: int | None
status: str
committed_cents: int # total LP commitments (latest per investor)
invested_cents: int
last_signed_value_cents: int
@router.get("/rollup")
def entity_rollup(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityRollupItem]:
"""Per-entity invested and last-signed-value in a single pass."""
allowed = accessible_entity_ids(user, session)
entities = session.exec(select(Entity)).all()
if allowed is not None:
entities = [e for e in entities if e.id in allowed]
result: list[EntityRollupItem] = []
for ent in entities:
# Sum cost_cents across all positions in this entity's holdings
invested_row = session.exec(
select(func.coalesce(func.sum(Position.cost_cents), 0))
.join(Holding, Position.holding_id == Holding.id)
.where(Holding.entity_id == ent.id)
).one()
invested_cents = int(invested_row)
# Find the latest approved round for this entity
latest_round = session.exec(
select(ValuationRound)
.where(
ValuationRound.entity_id == ent.id,
ValuationRound.status == RoundStatus.approved,
)
.order_by(ValuationRound.quarter_end.desc()) # type: ignore[union-attr]
).first()
last_signed_value_cents = 0
if latest_round:
val_sum = session.exec(
select(func.coalesce(func.sum(Valuation.value_cents), 0))
.where(Valuation.round_id == latest_round.id)
).one()
last_signed_value_cents = int(val_sum)
# Total committed capital = each investor's most recent commitment for this entity.
# Exited members (stake sold/transferred) are skipped — their buyer's commitment now
# appears on the roster, so counting both would double the fund's committed total.
stmts = session.exec(
select(CapitalAccountStatement)
.where(CapitalAccountStatement.entity_id == ent.id)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).all()
exited_ids = set(session.exec(
select(EntityAccess.user_id).where(
EntityAccess.entity_id == ent.id,
col(EntityAccess.exited_on).is_not(None),
)
).all())
committed_cents = 0
seen_investors: set[int] = set(exited_ids)
for st in stmts:
if st.investor_user_id in seen_investors:
continue
seen_investors.add(st.investor_user_id)
committed_cents += st.commitment_cents
result.append(EntityRollupItem(
id=ent.id,
name=ent.name,
type=ent.type.value,
vintage_year=ent.vintage_year,
fund_size_cents=ent.fund_size_cents,
status=ent.status.value,
committed_cents=committed_cents,
invested_cents=invested_cents,
last_signed_value_cents=last_signed_value_cents,
))
return result
@router.get("/{entity_id}/partners")
def list_partners(
entity_id: int,
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> list[PartnerResponse]:
"""Members (investors) granted access to this entity, with their latest capital value."""
check_administrator_scope(user, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
members = session.exec(
select(User, EntityAccess)
.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()
result: list[PartnerResponse] = []
for m, access in members:
stmts = session.exec(
select(CapitalAccountStatement)
.where(
CapitalAccountStatement.entity_id == entity_id,
CapitalAccountStatement.investor_user_id == m.id,
)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).all()
latest = stmts[0] if stmts else None
result.append(PartnerResponse(
user_id=m.id,
name=m.name,
username=m.username,
external_investor_id=m.external_investor_id,
is_active=m.is_active,
login_enabled=m.login_enabled,
latest_commitment_cents=latest.commitment_cents if latest else None,
latest_contributions_cents=latest.contributions_cents if latest else None,
latest_distributions_cents=latest.distributions_cents if latest else None,
latest_value_cents=latest.ending_balance_cents if latest else None,
latest_as_of=latest.as_of_date if latest else None,
statements_count=len(stmts),
exited_on=access.exited_on,
))
return result
@router.put("/{entity_id}/partners/{user_id}/exited")
def set_partner_exited(
entity_id: int,
user_id: int,
body: PartnerExitUpdate,
admin: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> PartnerResponse:
"""Mark a member as exited from this fund (stake sold/transferred), or clear it.
Their statements and documents stay; the portal shows an Exited badge instead of a
phantom -100% and drops the position from portfolio and fund committed totals.
"""
check_administrator_scope(admin, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
member = session.get(User, user_id)
if member is None or member.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor members can be marked exited")
# Marking an exit can mint an access grant (below); an external Administrator may only do
# that for an investor already inside their scope, never to pull in an out-of-fund account.
check_target_investor_scope(admin, user_id, session)
access = session.exec(
select(EntityAccess).where(
EntityAccess.entity_id == entity_id, EntityAccess.user_id == user_id
)
).first()
if access is None:
# A manually-entered investor may have statements without an access grant yet
# (e.g. from the Capital Accounts screen). Marking them exited creates the roster
# row with the flag set; clearing an exit that doesn't exist stays a 404.
if body.exited_on is None:
raise HTTPException(status_code=404, detail="That member has no access to this fund")
access = EntityAccess(user_id=user_id, entity_id=entity_id)
session.add(access)
session.flush()
access.exited_on = body.exited_on
session.add(access)
record_audit(session, admin.id, "set_exited", "entity_access", access.id, {
"entity_id": entity_id,
"user_id": user_id,
"exited_on": str(body.exited_on) if body.exited_on else None,
})
session.commit()
# Return the member's refreshed partner row for easy UI updates.
return next(p for p in list_partners(entity_id, admin, session) if p.user_id == user_id)
@router.delete("/{entity_id}/partners")
def clear_partners(
entity_id: int,
user: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> dict[str, int]:
"""Remove all partners from this fund — deletes its capital-account statements and the
investors' access grants, but keeps the investor accounts (they belong to other funds).
For undoing a wrong members import. Holdings/NAV are not affected."""
# Local import avoids a module-load cycle (capital_import_router imports import_router).
from ten31portal.routers.capital_import_router import reset_entity_partners
check_administrator_scope(user, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
res = reset_entity_partners(entity_id, session)
record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
session.commit()
return res
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityResponse]:
allowed = accessible_entity_ids(user, session)
rows = session.exec(select(Entity)).all()
if allowed is not None:
rows = [r for r in rows if r.id in allowed]
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{entity_id}")
def get_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> EntityResponse:
allowed = accessible_entity_ids(user, session)
if allowed is not None and entity_id not in allowed:
raise HTTPException(status_code=404, detail="Entity not found")
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
return EntityResponse.model_validate(entity, from_attributes=True)
def _validate_linked_user(linked_user_id: int | None, session: Session) -> None:
"""A linked account (for a GP entity that is also an LP) must be an investor account."""
if linked_user_id is None:
return
linked = session.get(User, linked_user_id)
if linked is None or linked.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Linked account must be an investor account.")
@router.post("", status_code=201)
def create_entity(
body: EntityCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
_validate_linked_user(body.linked_user_id, session)
entity = Entity(**body.model_dump())
session.add(entity)
session.flush()
record_audit(session, user.id, "create", "entity", entity.id, body.model_dump())
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@router.patch("/{entity_id}")
def update_entity(
entity_id: int,
body: EntityUpdate,
user: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
changes = body.model_dump(exclude_unset=True)
if "linked_user_id" in changes:
_validate_linked_user(changes["linked_user_id"], session)
for key, val in changes.items():
setattr(entity, key, val)
session.add(entity)
session.flush()
# mode="json" so date fields (close_date) serialize into the audit JSON column.
record_audit(session, user.id, "update", "entity", entity.id,
body.model_dump(exclude_unset=True, mode="json"))
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@router.get("/{entity_id}/asset-balances")
def asset_balances(
entity_id: int,
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> AssetBalancesResponse:
"""A GP/mgmt entity's assets = the linked account's capital balances across the funds.
Household-aware: if the linked account has other legal names linked to it (as the eNAV
often splits one LLC across names), their balances are included too.
"""
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
if entity.linked_user_id is None:
return AssetBalancesResponse()
linked = session.get(User, entity.linked_user_id)
if linked is None:
return AssetBalancesResponse(linked_user_id=entity.linked_user_id)
household = household_user_ids(linked, session)
rows = session.exec(
select(CapitalAccountStatement)
.where(col(CapitalAccountStatement.investor_user_id).in_(household))
.order_by(col(CapitalAccountStatement.as_of_date).desc())
).all()
# An Administrator only sees the slice of the linked account's balances that sits
# in funds granted to them — the linked account may also invest elsewhere.
allowed = accessible_entity_ids(user, session)
if allowed is not None:
rows = [r for r in rows if r.entity_id in allowed]
names = dict(session.exec(
select(User.id, User.name).where(col(User.id).in_({r.investor_user_id for r in rows}))
).all()) if rows else {}
balances: list[CapitalAccountResponse] = []
for r in rows:
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
d.investor_name = names.get(r.investor_user_id)
balances.append(d)
return AssetBalancesResponse(
linked_user_id=linked.id, linked_name=linked.name, balances=balances,
)
# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages ---
def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse:
data = EntityStakeResponse.model_validate(stake, from_attributes=True)
fund = funds.get(stake.fund_entity_id)
if fund is not None:
data.fund_name = fund.name
data.fund_type = fund.type
return data
@router.get("/{entity_id}/stakes")
def list_stakes(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityStakeResponse]:
"""The funds this entity holds a stake in (e.g. a GP's interest in its funds)."""
allowed = accessible_entity_ids(user, session)
if allowed is not None and entity_id not in allowed:
raise HTTPException(status_code=404, detail="Entity not found")
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
rows = session.exec(
select(EntityStake).where(EntityStake.holder_entity_id == entity_id)
).all()
funds = {
f.id: f for f in session.exec(
select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows}))
).all()
} if rows else {}
return [_stake_response(r, funds) for r in rows]
@router.post("/{entity_id}/stakes", status_code=201)
def create_stake(
entity_id: int,
body: EntityStakeCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityStakeResponse:
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
if body.fund_entity_id == entity_id:
raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.")
fund = session.get(Entity, body.fund_entity_id)
if fund is None:
raise HTTPException(status_code=404, detail="Fund not found")
if session.exec(
select(EntityStake).where(
EntityStake.holder_entity_id == entity_id,
EntityStake.fund_entity_id == body.fund_entity_id,
)
).first():
raise HTTPException(status_code=409, detail="A stake in this fund already exists.")
stake = EntityStake(
holder_entity_id=entity_id,
fund_entity_id=body.fund_entity_id,
ownership_pct=body.ownership_pct,
value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None,
note=body.note,
)
session.add(stake)
session.flush()
record_audit(session, user.id, "create", "entity_stake", stake.id, {
"holder_entity_id": entity_id,
"fund_entity_id": body.fund_entity_id,
})
session.commit()
session.refresh(stake)
return _stake_response(stake, {fund.id: fund})
@router.delete("/{entity_id}/stakes/{stake_id}")
def delete_stake(
entity_id: int,
stake_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
stake = session.get(EntityStake, stake_id)
if stake is None or stake.holder_entity_id != entity_id:
raise HTTPException(status_code=404, detail="Stake not found")
record_audit(session, user.id, "delete", "entity_stake", stake_id, {
"holder_entity_id": entity_id,
"fund_entity_id": stake.fund_entity_id,
})
session.delete(stake)
session.commit()
return {"status": "deleted"}