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:
Jonathan Kirkwood
2026-08-10 15:38:39 -05:00
parent 3c7094241c
commit ae967494bd
29 changed files with 693 additions and 332 deletions
@@ -7,8 +7,8 @@ from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, get_current_user, household_user_ids,
require_internal_admin,
accessible_entity_ids, check_administrator_scope, get_current_user,
household_user_ids, require_admin,
)
from ten31portal.database import get_session
from ten31portal.models import (
@@ -76,8 +76,15 @@ def list_statements(
query = select(CapitalAccountStatement)
allowed = accessible_entity_ids(user, session)
if allowed is not None:
# External accounts see statements for every legal name linked to their login.
if user.role == UserRole.fund_administrator:
# An Administrator sees every investor's statements, but only inside their funds.
if not allowed:
return []
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
if investor_user_id is not None:
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
elif allowed is not None:
# Investors see statements for every legal name linked to their login.
query = query.where(
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
)
@@ -118,9 +125,10 @@ def list_statements(
@router.post("", status_code=201)
def create_statement(
body: CapitalAccountCreate,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> CapitalAccountResponse:
check_administrator_scope(admin, body.entity_id, session)
if session.get(Entity, body.entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
investor = session.get(User, body.investor_user_id)
@@ -167,12 +175,13 @@ def create_statement(
@router.delete("/{statement_id}")
def delete_statement(
statement_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
stmt = session.get(CapitalAccountStatement, statement_id)
if stmt is None:
raise HTTPException(status_code=404, detail="Statement not found")
check_administrator_scope(admin, stmt.entity_id, session)
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
session.delete(stmt)
session.commit()