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.
This commit is contained in:
@@ -7,8 +7,8 @@ from sqlmodel import Session, col, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, get_current_user, household_user_ids, require_internal,
|
||||
require_writer,
|
||||
accessible_entity_ids, check_administrator_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 (
|
||||
@@ -115,10 +115,11 @@ def entity_rollup(
|
||||
@router.get("/{entity_id}/partners")
|
||||
def list_partners(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_internal),
|
||||
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")
|
||||
|
||||
@@ -163,7 +164,7 @@ def set_partner_exited(
|
||||
entity_id: int,
|
||||
user_id: int,
|
||||
body: PartnerExitUpdate,
|
||||
admin: User = Depends(require_writer),
|
||||
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.
|
||||
@@ -171,6 +172,7 @@ def set_partner_exited(
|
||||
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)
|
||||
@@ -208,7 +210,7 @@ def set_partner_exited(
|
||||
@router.delete("/{entity_id}/partners")
|
||||
def clear_partners(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_writer),
|
||||
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
|
||||
@@ -217,6 +219,7 @@ def clear_partners(
|
||||
# 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)
|
||||
@@ -281,9 +284,10 @@ def create_entity(
|
||||
def update_entity(
|
||||
entity_id: int,
|
||||
body: EntityUpdate,
|
||||
user: User = Depends(require_writer),
|
||||
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")
|
||||
@@ -305,7 +309,7 @@ def update_entity(
|
||||
@router.get("/{entity_id}/asset-balances")
|
||||
def asset_balances(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_internal),
|
||||
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.
|
||||
@@ -313,6 +317,7 @@ def asset_balances(
|
||||
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")
|
||||
@@ -328,6 +333,11 @@ def asset_balances(
|
||||
.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 {}
|
||||
|
||||
Reference in New Issue
Block a user