From ae967494bd33f241da55f53b44c5051fa65e1e43 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Mon, 10 Aug 2026 15:38:39 -0500 Subject: [PATCH] 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. --- .gitignore | 3 + backend/ten31portal/auth.py | 31 +++ backend/ten31portal/cli.py | 56 +--- .../routers/capital_account_router.py | 21 +- .../routers/capital_import_router.py | 12 +- .../ten31portal/routers/document_router.py | 8 +- backend/ten31portal/routers/entity_router.py | 24 +- backend/ten31portal/routers/holding_router.py | 7 +- backend/ten31portal/routers/import_router.py | 12 +- .../ten31portal/routers/position_router.py | 7 +- backend/ten31portal/routers/round_router.py | 11 +- backend/ten31portal/routers/user_router.py | 213 ++++++++++++++-- backend/tests/test_administrator.py | 241 ++++++++++++++++++ deploy/package.json | 2 +- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_42.ts | 17 ++ frontend/public/sw.js | 2 +- frontend/src/App.tsx | 20 +- frontend/src/api.ts | 9 + frontend/src/components/Layout.tsx | 10 +- frontend/src/format.ts | 4 +- frontend/src/main.tsx | 5 + frontend/src/pages/EntityOverview.tsx | 4 +- frontend/src/pages/EntityPartners.tsx | 4 +- frontend/src/pages/Import.tsx | 33 ++- frontend/src/pages/Users.tsx | 80 ++++-- frontend/src/portal/FundAdminHome.tsx | 180 ------------- frontend/src/portal/PortalLayout.tsx | 2 - frontend/src/version.ts | 2 +- 29 files changed, 693 insertions(+), 332 deletions(-) create mode 100644 backend/tests/test_administrator.py create mode 100644 deploy/startos/install/versions/v_0_2_42.ts delete mode 100644 frontend/src/portal/FundAdminHome.tsx diff --git a/.gitignore b/.gitignore index ba5e8ca..c06e4e9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ deploy/javascript/ # Confidential fund-admin spreadsheets (never commit) *.xlsx *.xls + +# start-cli signing key + local config (never commit) +deploy/.startos/ diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py index 0f365b0..0fcfb89 100644 --- a/backend/ten31portal/auth.py +++ b/backend/ten31portal/auth.py @@ -92,3 +92,34 @@ require_approver = require_role(UserRole.approver) # final sign-off — Managin require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) # Account administration (create/manage users, documents, capital accounts) require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) + +# Internal admins plus the external Administrator (fund_administrator). Every endpoint +# using this gate must also call check_administrator_scope for the entity it touches — +# the role alone says nothing about WHICH entities an Administrator may manage. +require_admin = require_role( + UserRole.approver, UserRole.cfo, UserRole.operations, UserRole.fund_administrator +) +# Entity-record writers: internal writers plus the external Administrator (scope-checked). +require_entity_writer = require_role( + UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations, + UserRole.fund_administrator, +) + + +def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User: + """Read gate for admin screens: any internal role, or the external Administrator. + + Investors are blocked; Administrator calls must still be scope-checked per entity. + """ + if user.role == UserRole.investor: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + + +def check_administrator_scope(user: User, entity_id: int, session: Session) -> None: + """403 when an external Administrator touches an entity outside their grants. + + Internal roles pass through untouched — their reach is decided by the route's gate. + """ + if user.role == UserRole.fund_administrator and not can_access_entity(user, entity_id, session): + raise HTTPException(status_code=403, detail="No access to this entity") diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py index a164729..5490265 100644 --- a/backend/ten31portal/cli.py +++ b/backend/ten31portal/cli.py @@ -10,10 +10,7 @@ from ten31portal import config from ten31portal.auth import hash_password from ten31portal.database import engine from ten31portal.db_init import run_migrations -from ten31portal.models import ( - AuditLog, CapitalAccountStatement, Document, EntityAccess, User, UserRole, - ValuationRound, -) +from ten31portal.models import User, UserRole def _find_user(session: Session, username: str | None, email: str | None) -> User | None: @@ -183,7 +180,7 @@ def delete_user(args: argparse.Namespace) -> None: """Delete a user account and its dependent rows. The Service Admin is protected.""" run_migrations() - from ten31portal import storage + from ten31portal.routers.user_router import delete_user_cascade with Session(engine) as session: user = _find_user(session, args.username, args.email) @@ -195,52 +192,11 @@ def delete_user(args: argparse.Namespace) -> None: print(f"Error: '{user.username}' is the Service Admin and cannot be deleted.", file=sys.stderr) sys.exit(1) - uid = user.id - - # Entity-access grants and capital-account statements are this user's own data. - for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all(): - session.delete(acc) - for stmt in session.exec( - select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid) - ).all(): - session.delete(stmt) - - # Documents addressed privately to this investor are removed (file + row); documents - # they uploaded stay, with the uploader cleared. - for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all(): - try: - storage.delete_file(doc.storage_path) - except OSError as exc: - # Don't abort the whole deletion, but surface it — a swallowed disk/permission - # error would silently orphan the file on the data volume. - print(f"Warning: could not delete file {doc.storage_path}: {exc}", file=sys.stderr) - session.delete(doc) - for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all(): - doc.uploaded_by = None - session.add(doc) - - # Preserve history/rounds by clearing the references to this user. - for rnd in session.exec( - select(ValuationRound).where( - (ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid) - ) - ).all(): - if rnd.submitted_by == uid: - rnd.submitted_by = None - if rnd.approved_by == uid: - rnd.approved_by = None - session.add(rnd) - for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all(): - log.actor_user_id = None - session.add(log) - - # Detach any linked sub-accounts so they log in on their own again. - for sub in session.exec(select(User).where(User.primary_account_id == uid)).all(): - sub.primary_account_id = None - session.add(sub) - name, username = user.name, user.username - session.delete(user) + # Don't abort on a file that won't delete, but surface it — a swallowed disk or + # permission error would silently orphan the file on the data volume. + for warning in delete_user_cascade(session, user): + print(f"Warning: {warning}", file=sys.stderr) session.commit() print(f"Deleted user {name} ({username}).") diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index 82cd3d0..4a79cd4 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -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() diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py index 023da8e..b1ef254 100644 --- a/backend/ten31portal/routers/capital_import_router.py +++ b/backend/ten31portal/routers/capital_import_router.py @@ -20,7 +20,7 @@ from sqlmodel import Session, select from ten31portal import config, storage from ten31portal.audit import record_audit -from ten31portal.auth import hash_password, require_internal_admin +from ten31portal.auth import check_administrator_scope, hash_password, require_admin from ten31portal.database import get_session from ten31portal.models import ( CapitalAccountStatement, Entity, EntityAccess, User, UserRole, @@ -222,9 +222,11 @@ def preview_import( entity_id: int | None = Form(None), row_index: int | None = Form(None), password: str | None = Form(None), - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> CapitalImportPreview: + if entity_id is not None: + check_administrator_scope(admin, entity_id, session) if entity_id is not None and session.get(Entity, entity_id) is None: raise HTTPException(status_code=404, detail="Entity not found") @@ -289,9 +291,10 @@ def preview_import( @router.post("/commit") def commit_import( body: CapitalImportCommit, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> dict: + check_administrator_scope(admin, body.entity_id, session) entity = session.get(Entity, body.entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") @@ -395,7 +398,7 @@ def batch_import( files: list[UploadFile] = File(...), entity_id: int = Form(...), password: str | None = Form(None), - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> BatchCapitalImportResult: """Backfill several quarters of capital history for one fund from a batch of eNAV files. @@ -407,6 +410,7 @@ def batch_import( (no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is reported per-file and does not abort the rest. """ + check_administrator_scope(admin, entity_id, session) entity = session.get(Entity, entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") diff --git a/backend/ten31portal/routers/document_router.py b/backend/ten31portal/routers/document_router.py index 9241556..0c7bb06 100644 --- a/backend/ten31portal/routers/document_router.py +++ b/backend/ten31portal/routers/document_router.py @@ -8,8 +8,7 @@ from sqlmodel import Session, select from ten31portal.audit import record_audit from ten31portal.auth import ( - accessible_entity_ids, can_access_entity, get_current_user, - household_user_ids, require_internal_admin, + accessible_entity_ids, can_access_entity, get_current_user, household_user_ids, ) from ten31portal.database import get_session from ten31portal.models import ( @@ -161,12 +160,15 @@ def download_document( @router.delete("/{document_id}") def delete_document( document_id: int, - admin: User = Depends(require_internal_admin), + admin: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> dict[str, str]: doc = session.get(Document, document_id) if doc is None: raise HTTPException(status_code=404, detail="Document not found") + # Same reach as upload: internal admins anywhere, an Administrator on their entities. + if not _can_upload(admin, doc.entity_id, session): + raise HTTPException(status_code=403, detail="Insufficient permissions") storage.delete_file(doc.storage_path) record_audit(session, admin.id, "delete", "document", document_id, { "filename": doc.original_filename, diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 4c86a75..8d023e3 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -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 {} diff --git a/backend/ten31portal/routers/holding_router.py b/backend/ten31portal/routers/holding_router.py index 9e313b8..923f30e 100644 --- a/backend/ten31portal/routers/holding_router.py +++ b/backend/ten31portal/routers/holding_router.py @@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import require_internal, require_writer +from ten31portal.auth import ( + check_administrator_scope, require_internal_or_administrator, require_writer, +) from ten31portal.database import get_session from ten31portal.models import Entity, Holding, Position, User from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate @@ -15,9 +17,10 @@ router = APIRouter(tags=["holdings"]) @router.get("/api/entities/{entity_id}/holdings") def list_holdings( entity_id: int, - user: User = Depends(require_internal), + user: User = Depends(require_internal_or_administrator), session: Session = Depends(get_session), ) -> list[HoldingResponse]: + 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") diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index 525ff5b..0f47fa5 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -18,7 +18,7 @@ from sqlmodel import Session, select, col from ten31portal import storage from ten31portal.audit import record_audit -from ten31portal.auth import require_role +from ten31portal.auth import check_administrator_scope, require_admin, require_role from ten31portal.database import get_session from ten31portal.models import ( BtcPrice, Entity, EntityType, Holding, Position, @@ -341,9 +341,17 @@ def import_schedule( commit: bool = Query(default=True), replace_existing: bool = Query(default=False), password: str | None = Form(default=None), - user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)), + user: User = Depends(require_admin), session: Session = Depends(get_session), ) -> dict[str, Any]: + # An Administrator imports only into a fund granted to them — creating a new entity + # (or name-matching into an arbitrary one) is internal-only. + if user.role == UserRole.fund_administrator: + if entity_id is None: + raise HTTPException( + status_code=403, detail="Choose one of your funds to import into." + ) + check_administrator_scope(user, entity_id, session) try: file_bytes = storage.read_capped(file) except storage.UploadTooLarge as exc: diff --git a/backend/ten31portal/routers/position_router.py b/backend/ten31portal/routers/position_router.py index 208cb60..7e973d1 100644 --- a/backend/ten31portal/routers/position_router.py +++ b/backend/ten31portal/routers/position_router.py @@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import require_internal, require_writer +from ten31portal.auth import ( + check_administrator_scope, require_internal_or_administrator, require_writer, +) from ten31portal.database import get_session from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate @@ -22,12 +24,13 @@ def _dollars_to_cents(dollars: float) -> int: @router.get("/api/holdings/{holding_id}/positions") def list_positions( holding_id: int, - user: User = Depends(require_internal), + user: User = Depends(require_internal_or_administrator), session: Session = Depends(get_session), ) -> list[PositionResponse]: holding = session.get(Holding, holding_id) if holding is None: raise HTTPException(status_code=404, detail="Holding not found") + check_administrator_scope(user, holding.entity_id, session) rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all() return [PositionResponse.model_validate(r, from_attributes=True) for r in rows] diff --git a/backend/ten31portal/routers/round_router.py b/backend/ten31portal/routers/round_router.py index b0fa25d..4c5c561 100644 --- a/backend/ten31portal/routers/round_router.py +++ b/backend/ten31portal/routers/round_router.py @@ -6,7 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select, col from ten31portal.audit import record_audit -from ten31portal.auth import require_internal, require_writer, require_approver +from ten31portal.auth import ( + check_administrator_scope, require_internal_or_administrator, require_writer, + require_approver, +) from ten31portal.database import get_session from ten31portal.models import ( Entity, Holding, Position, Valuation, ValuationRound, @@ -30,9 +33,10 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse: @router.get("/api/entities/{entity_id}/rounds") def list_rounds( entity_id: int, - user: User = Depends(require_internal), + user: User = Depends(require_internal_or_administrator), session: Session = Depends(get_session), ) -> list[RoundResponse]: + 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") @@ -47,12 +51,13 @@ def list_rounds( @router.get("/api/rounds/{round_id}") def get_round( round_id: int, - user: User = Depends(require_internal), + user: User = Depends(require_internal_or_administrator), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") + check_administrator_scope(user, round.entity_id, session) return _round_response(round, session) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py index 5c6cd00..b453f85 100644 --- a/backend/ten31portal/routers/user_router.py +++ b/backend/ten31portal/routers/user_router.py @@ -5,12 +5,13 @@ 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, + accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user, + hash_password, household_user_ids, require_admin, require_internal_admin, ) from ten31portal.database import get_session from ten31portal.models import ( - CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole, + AuditLog, CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, + User, UserRole, ValuationRound, ) from ten31portal.schemas import ( AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse, @@ -21,17 +22,111 @@ from ten31portal.schemas import ( router = APIRouter(prefix="/api/users", tags=["users"]) +# --- Administrator (external, entity-scoped) visibility helpers --- + +def _visible_investor_ids(scope: set[int], session: Session) -> set[int]: + """Investors an Administrator may see and manage: anyone with an access grant or a + capital-account statement in one of the Administrator's entities.""" + if not scope: + return set() + ids = set(session.exec( + select(EntityAccess.user_id).where(col(EntityAccess.entity_id).in_(scope)) + ).all()) + ids |= set(session.exec( + select(CapitalAccountStatement.investor_user_id).where( + col(CapitalAccountStatement.entity_id).in_(scope) + ) + ).all()) + return ids + + +def _check_target_in_scope(admin: User, target: User, session: Session) -> None: + """403 unless the caller is internal, or the target is an investor tied to one of the + Administrator's entities. Keeps an external Administrator away from staff accounts and + from other funds' investors entirely.""" + scope = accessible_entity_ids(admin, session) + if scope is None: + return + if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session): + raise HTTPException(status_code=403, detail="No access to this account") + + +def delete_user_cascade(session: Session, user: User) -> list[str]: + """Delete a user and its dependent rows; returns warnings for files that would not delete. + + Shared by the API endpoint and the CLI. The caller is responsible for the guards + (Service Admin, self-deletion, scope) and for committing the session. + """ + from ten31portal import storage + + warnings: list[str] = [] + uid = user.id + + # Entity-access grants and capital-account statements are this user's own data. + for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all(): + session.delete(acc) + for stmt in session.exec( + select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid) + ).all(): + session.delete(stmt) + + # Documents addressed privately to this investor are removed (file + row); documents + # they uploaded stay, with the uploader cleared. + for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all(): + try: + storage.delete_file(doc.storage_path) + except OSError as exc: + warnings.append(f"could not delete file {doc.storage_path}: {exc}") + session.delete(doc) + for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all(): + doc.uploaded_by = None + session.add(doc) + + # Preserve history/rounds by clearing the references to this user. + for rnd in session.exec( + select(ValuationRound).where( + (ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid) + ) + ).all(): + if rnd.submitted_by == uid: + rnd.submitted_by = None + if rnd.approved_by == uid: + rnd.approved_by = None + session.add(rnd) + for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all(): + log.actor_user_id = None + session.add(log) + + # Detach any linked sub-accounts so they log in on their own again. + for sub in session.exec(select(User).where(User.primary_account_id == uid)).all(): + sub.primary_account_id = None + session.add(sub) + + session.delete(user) + return warnings + + @router.get("/access-matrix") def access_matrix( - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> AccessMatrixResponse: - """External accounts, all entities, and the grants linking them.""" + """External accounts, all entities, and the grants linking them. + + For an external Administrator the matrix is their slice of the world: only their + granted entities, and only the investors tied to those entities. + """ 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() + scope = accessible_entity_ids(admin, session) + if scope is not None: + visible = _visible_investor_ids(scope, session) + users = [u for u in users if u.id in visible] + entities = [e for e in entities if e.id in scope] + grants = [g for g in grants if g.entity_id in scope and g.user_id in visible] 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], @@ -43,12 +138,15 @@ def access_matrix( def grant_access( user_id: int, entity_id: int, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> dict[str, str]: + check_administrator_scope(admin, entity_id, session) user = session.get(User, user_id) if user is None: raise HTTPException(status_code=404, detail="User not found") + if admin.role == UserRole.fund_administrator and user.role != UserRole.investor: + raise HTTPException(status_code=403, detail="Administrators manage investor accounts only") if session.get(Entity, entity_id) is None: raise HTTPException(status_code=404, detail="Entity not found") existing = session.exec( @@ -67,9 +165,16 @@ def grant_access( def revoke_access( user_id: int, entity_id: int, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> dict[str, str]: + check_administrator_scope(admin, entity_id, session) + if admin.role == UserRole.fund_administrator: + target = session.get(User, user_id) + if target is not None and target.role != UserRole.investor: + raise HTTPException( + status_code=403, detail="Administrators manage investor accounts only" + ) existing = session.exec( select(EntityAccess).where( EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id @@ -224,31 +329,50 @@ def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> @router.get("") def list_users( - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> list[UserResponse]: rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type] + scope = accessible_entity_ids(admin, session) + if scope is not None: + visible = _visible_investor_ids(scope, session) + rows = [r for r in rows if r.role == UserRole.investor and r.id in visible] 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), + admin: User = Depends(require_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") + _check_target_in_scope(admin, user, session) return _user_detail(user, session) @router.post("", status_code=201) def create_user( body: UserCreate, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> UserDetailResponse: + entity_ids = body.entity_ids + scope = accessible_entity_ids(admin, session) + if scope is not None: + # An Administrator creates investor accounts only, and only on their own funds — + # anything else would be privilege escalation or an account they can't see again. + if body.role != UserRole.investor: + raise HTTPException( + status_code=403, detail="Administrators can only create investor accounts" + ) + entity_ids = [e for e in entity_ids if e in scope] + if not entity_ids: + raise HTTPException( + status_code=400, detail="Select at least one of your funds for this investor" + ) 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(): @@ -263,10 +387,10 @@ def create_user( ) session.add(user) session.flush() - _set_entity_access(user.id, body.entity_ids, session) + _set_entity_access(user.id, entity_ids, session) record_audit(session, admin.id, "create", "user", user.id, {"username": body.username, "role": body.role.value, - "entity_ids": body.entity_ids}) + "entity_ids": entity_ids}) session.commit() session.refresh(user) return _user_detail(user, session) @@ -276,16 +400,28 @@ def create_user( def update_user( user_id: int, body: UserUpdate, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_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") + _check_target_in_scope(admin, user, session) changes = body.model_dump(exclude_unset=True) entity_ids = changes.pop("entity_ids", None) + scope = accessible_entity_ids(admin, session) + if scope is not None: + if "role" in changes and changes["role"] != user.role: + raise HTTPException(status_code=403, detail="Administrators cannot change roles") + changes.pop("role", None) + if entity_ids is not None: + # Only this Administrator's funds are theirs to grant or revoke; the investor's + # access to any other fund is preserved untouched. + kept_elsewhere = set(_entity_ids_for(user_id, session)) - scope + entity_ids = list((set(entity_ids) & scope) | kept_elsewhere) + if "username" in changes: new_username = (changes["username"] or "").strip() if not new_username: @@ -319,7 +455,7 @@ def update_user( def link_account( user_id: int, body: AccountLink, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_admin), session: Session = Depends(get_session), ) -> UserDetailResponse: """Link an investor account to a primary login (or detach it when null). @@ -330,6 +466,7 @@ def link_account( user = session.get(User, user_id) if user is None: raise HTTPException(status_code=404, detail="User not found") + _check_target_in_scope(admin, user, session) if user.role != UserRole.investor: raise HTTPException(status_code=400, detail="Only investor accounts can be linked.") @@ -340,6 +477,7 @@ def link_account( 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.") + _check_target_in_scope(admin, primary, session) if primary.primary_account_id is not None: raise HTTPException( status_code=400, @@ -369,12 +507,13 @@ def link_account( def reset_password( user_id: int, body: PasswordReset, - admin: User = Depends(require_internal_admin), + admin: User = Depends(require_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") + _check_target_in_scope(admin, user, session) user.password_hash = hash_password(body.password) # Admin handed them a real password — no forced change on next login. user.must_change_password = False @@ -383,3 +522,47 @@ def reset_password( record_audit(session, admin.id, "reset_password", "user", user_id, None) session.commit() return {"status": "ok"} + + +@router.delete("/{user_id}") +def delete_user( + user_id: int, + admin: User = Depends(require_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + """Delete an account and its dependent data (grants, statements, private documents). + + The Service Admin can never be deleted, nor can you delete yourself. An external + Administrator may only delete an investor who belongs solely to their own funds — + an investor who also sits in another fund is another manager's problem too. + """ + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.is_service_admin: + raise HTTPException(status_code=400, detail="The Service Admin cannot be deleted") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="You cannot delete your own account") + _check_target_in_scope(admin, user, session) + + scope = accessible_entity_ids(admin, session) + if scope is not None: + footprint = set(_entity_ids_for(user_id, session)) + footprint |= set(session.exec( + select(CapitalAccountStatement.entity_id).where( + CapitalAccountStatement.investor_user_id == user_id + ) + ).all()) + if not footprint <= scope: + raise HTTPException( + status_code=403, + detail="This investor also belongs to funds outside your access; " + "remove them from your funds instead of deleting the account.", + ) + + name, username = user.name, user.username + delete_user_cascade(session, user) + record_audit(session, admin.id, "delete", "user", user_id, + {"username": username, "name": name}) + session.commit() + return {"status": "deleted"} diff --git a/backend/tests/test_administrator.py b/backend/tests/test_administrator.py new file mode 100644 index 0000000..8f43e3f --- /dev/null +++ b/backend/tests/test_administrator.py @@ -0,0 +1,241 @@ +"""External Administrator role (0.2.42): full management, fenced to granted entities. + +An Administrator (UserRole.fund_administrator) runs the same admin screens as internal +staff — users, partners, documents, capital accounts, imports — but only for the funds +granted to them via EntityAccess. These tests pin the fence. +""" + +import io +from datetime import date + +from sqlmodel import select + +from ten31portal.models import ( + CapitalAccountStatement, Document, Entity, EntityAccess, EntityType, User, UserRole, +) +from tests.conftest import make_user + + +def _login(client, username, password="password123"): + client.post("/api/auth/logout") + resp = client.post("/api/auth/login", json={"login": username, "password": password}) + assert resp.status_code == 200, resp.text + return resp + + +def _setup(session): + """Two funds; the Administrator manages fund A only. One LP in each fund.""" + fund_a = Entity(name="Fund A", type=EntityType.fund) + fund_b = Entity(name="Fund B", type=EntityType.fund) + session.add(fund_a) + session.add(fund_b) + session.commit() + + admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator, + name="Outside Administrator") + session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id)) + + lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha") + session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id)) + lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta") + session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id)) + session.commit() + return fund_a, fund_b, admin, lp_a, lp_b + + +def _stmt(entity_id, investor_id, as_of=date(2026, 3, 31), balance=1_000_000_00): + return CapitalAccountStatement( + entity_id=entity_id, investor_user_id=investor_id, as_of_date=as_of, + commitment_cents=balance, beginning_balance_cents=0, + contributions_cents=balance, distributions_cents=0, + ending_balance_cents=balance, + ) + + +def test_administrator_sees_only_their_funds_users(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + _login(client, "fundadmin") + + users = client.get("/api/users").json() + assert {u["username"] for u in users} == {"lp-a"} + + # Their fund's investor is reachable; the other fund's — and staff — are not. + assert client.get(f"/api/users/{lp_a.id}").status_code == 200 + assert client.get(f"/api/users/{lp_b.id}").status_code == 403 + assert client.get(f"/api/users/{approver.id}").status_code == 403 + + +def test_administrator_creates_investors_only_on_their_funds(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + _login(client, "fundadmin") + + # Investor on fund A: allowed; out-of-scope fund ids are silently dropped. + resp = client.post("/api/users", json={ + "name": "New LP", "username": "new-lp", "password": "secretpw", + "role": "investor", "entity_ids": [fund_a.id, fund_b.id], + }) + assert resp.status_code == 201, resp.text + assert resp.json()["entity_ids"] == [fund_a.id] + + # Staff roles are privilege escalation. + resp = client.post("/api/users", json={ + "name": "Sneaky", "username": "sneaky", "password": "secretpw", + "role": "operations", "entity_ids": [], + }) + assert resp.status_code == 403 + + # An investor with no in-scope fund would be invisible to its creator. + resp = client.post("/api/users", json={ + "name": "Orphan", "username": "orphan", "password": "secretpw", + "role": "investor", "entity_ids": [fund_b.id], + }) + assert resp.status_code == 400 + + +def test_administrator_partner_management_is_scoped(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + _login(client, "fundadmin") + + assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200 + assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403 + + resp = client.put( + f"/api/entities/{fund_a.id}/partners/{lp_a.id}/exited", + json={"exited_on": "2026-06-30"}, + ) + assert resp.status_code == 200 + resp = client.put( + f"/api/entities/{fund_b.id}/partners/{lp_b.id}/exited", + json={"exited_on": "2026-06-30"}, + ) + assert resp.status_code == 403 + + +def test_administrator_documents_upload_and_delete(client, session, approver, monkeypatch, tmp_path): + monkeypatch.setattr("ten31portal.storage.DOCS_DIR", str(tmp_path)) + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + + doc_b = Document( + entity_id=fund_b.id, category="statement", title="B statement", + original_filename="b.pdf", content_type="application/pdf", size_bytes=1, + storage_path="x-b", + ) + session.add(doc_b) + session.commit() + + _login(client, "fundadmin") + resp = client.post( + "/api/documents", + data={"entity_id": str(fund_a.id), "category": "statement", "title": "Q1 statement"}, + files={"file": ("q1.pdf", io.BytesIO(b"pdf"), "application/pdf")}, + ) + assert resp.status_code == 201, resp.text + doc_a_id = resp.json()["id"] + + assert client.delete(f"/api/documents/{doc_a_id}").json() == {"status": "deleted"} + assert client.delete(f"/api/documents/{doc_b.id}").status_code == 403 + + +def test_administrator_capital_accounts_are_scoped(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + session.add(_stmt(fund_a.id, lp_a.id)) + stmt_b = _stmt(fund_b.id, lp_b.id) + session.add(stmt_b) + session.commit() + + _login(client, "fundadmin") + rows = client.get("/api/capital-accounts").json() + assert {r["entity_id"] for r in rows} == {fund_a.id} + + resp = client.post("/api/capital-accounts", json={ + "entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-06-30", + "commitment_dollars": 100, "beginning_balance_dollars": 0, + "contributions_dollars": 100, "distributions_dollars": 0, + "ending_balance_dollars": 110, + }) + assert resp.status_code == 201, resp.text + + resp = client.post("/api/capital-accounts", json={ + "entity_id": fund_b.id, "investor_user_id": lp_b.id, "as_of_date": "2026-06-30", + "commitment_dollars": 100, "beginning_balance_dollars": 0, + "contributions_dollars": 100, "distributions_dollars": 0, + "ending_balance_dollars": 110, + }) + assert resp.status_code == 403 + assert client.delete(f"/api/capital-accounts/{stmt_b.id}").status_code == 403 + + +def test_administrator_deletes_only_investors_solely_in_their_funds(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + # lp_a also joins fund B — deleting them would reach beyond the Administrator's fence. + session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id)) + solo = make_user(session, username="solo", role=UserRole.investor, name="Solo LP") + session.add(EntityAccess(user_id=solo.id, entity_id=fund_a.id)) + session.commit() + + solo_id = solo.id + _login(client, "fundadmin") + assert client.delete(f"/api/users/{lp_a.id}").status_code == 403 + assert client.delete(f"/api/users/{approver.id}").status_code == 403 + assert client.delete(f"/api/users/{solo_id}").json() == {"status": "deleted"} + session.expire_all() # the API deleted through its own session; drop our cached copy + assert session.exec(select(User).where(User.id == solo_id)).first() is None + assert session.exec( + select(EntityAccess).where(EntityAccess.user_id == solo_id) + ).first() is None + + +def test_internal_admin_delete_guards(auth_client, session, approver): + service = make_user(session, username="svc", role=UserRole.operations, + is_service_admin=True) + victim = make_user(session, username="victim", role=UserRole.investor) + + assert auth_client.delete(f"/api/users/{service.id}").status_code == 400 + assert auth_client.delete(f"/api/users/{approver.id}").status_code == 400 # self + assert auth_client.delete(f"/api/users/{victim.id}").json() == {"status": "deleted"} + + +def test_administrator_update_preserves_other_funds_grants(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id)) + session.commit() + + _login(client, "fundadmin") + # Submitting only in-scope grants must not strip the investor's fund B access. + resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": [fund_a.id]}) + assert resp.status_code == 200 + assert set(resp.json()["entity_ids"]) == {fund_a.id, fund_b.id} + + # Revoking their own fund keeps fund B untouched. + resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": []}) + assert resp.status_code == 200 + assert resp.json()["entity_ids"] == [fund_b.id] + + +def test_administrator_blocked_from_internal_surfaces(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + _login(client, "fundadmin") + + assert client.get("/api/audit").status_code == 403 + assert client.post("/api/entities", json={"name": "New Fund", "type": "fund"}).status_code == 403 + # eNAV import into a fund outside their grants (or with no fund chosen) is refused. + resp = client.post( + "/api/import/schedule", + files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")}, + ) + assert resp.status_code == 403 + resp = client.post( + f"/api/import/schedule?entity_id={fund_b.id}", + files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")}, + ) + assert resp.status_code == 403 + + +def test_administrator_reads_rounds_and_holdings_in_scope(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _setup(session) + _login(client, "fundadmin") + + assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200 + assert client.get(f"/api/entities/{fund_b.id}/rounds").status_code == 403 + assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200 + assert client.get(f"/api/entities/{fund_b.id}/holdings").status_code == 403 diff --git a/deploy/package.json b/deploy/package.json index 4037f38..c28a2ae 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.40", + "version": "0.2.42", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index bb3910c..42f724e 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,6 +1,7 @@ -export { v_0_2_41 as current } from './v_0_2_41' +export { v_0_2_42 as current } from './v_0_2_42' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_40 } from './v_0_2_40' +import { v_0_2_41 } from './v_0_2_41' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' import { v_0_2_3 } from './v_0_2_3' @@ -40,4 +41,4 @@ import { v_0_2_36 } from './v_0_2_36' import { v_0_2_37 } from './v_0_2_37' import { v_0_2_38 } from './v_0_2_38' import { v_0_2_39 } from './v_0_2_39' -export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40] +export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41] diff --git a/deploy/startos/install/versions/v_0_2_42.ts b/deploy/startos/install/versions/v_0_2_42.ts new file mode 100644 index 0000000..9b515ef --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_42.ts @@ -0,0 +1,17 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_42 = VersionInfo.of({ + version: '0.2.42:0', + releaseNotes: { + en_US: + 'Administrator accounts: the external Administrator role now signs into the ' + + 'full admin interface, scoped to the funds and SPVs granted to it — fund ' + + 'overview, partners, capital accounts, documents (upload and delete), eNAV ' + + 'imports, and investor account management (create, edit, reset, delete) for ' + + 'its own funds only. Also carries the 0.2.41 static-path security fix.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index aec2881..bd76c78 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.40' +const CACHE = 'ten31-portal-0.2.42' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f10066..cbd4e36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { AuthProvider, useAuth } from "./context/AuthContext"; -import { isInternal } from "./api"; +import { isAdministrator, isInternal } from "./api"; import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin"; import Layout from "./components/Layout"; import Login from "./pages/Login"; @@ -20,9 +20,13 @@ import AccessGrid from "./pages/AccessGrid"; import InvestorView from "./pages/InvestorView"; import PortalLayout from "./portal/PortalLayout"; import InvestorHome from "./portal/InvestorHome"; -import FundAdminHome from "./portal/FundAdminHome"; -function InternalApp() { +function AdminApp() { + // Shared by internal staff and external Administrators. The internal-only screens + // (valuation sign-off, audit log, investor view) are simply absent for Administrators — + // the backend refuses them anyway. + const { user } = useAuth(); + const internal = user != null && isInternal(user.role); return ( @@ -32,11 +36,11 @@ function InternalApp() { } /> } /> } /> - } /> + {internal && } />} } /> - } /> + {internal && } />} } /> - } /> + {internal && } />} } /> } /> } /> @@ -50,7 +54,7 @@ function ExternalApp() { const { user } = useAuth(); return ( - {user?.role === "fund_administrator" ? : } + {user && !user.onboarded_at && } ); @@ -92,7 +96,7 @@ function ProtectedRoutes() { // Still on the shared default password → nothing else until they set their own. if (user.must_change_password) return ; - return isInternal(user.role) ? : ; + return isInternal(user.role) || isAdministrator(user.role) ? : ; } export default function App() { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3786497..85ee0f0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -34,6 +34,13 @@ export const isInternal = (role: UserRole) => INTERNAL_ROLES.includes(role); export const isAdmin = (role: UserRole) => ADMIN_ROLES.includes(role); export const canEditRound = (role: UserRole) => WRITER_ROLES.includes(role); export const isApprover = (role: UserRole) => role === "approver"; +// The external Administrator: full management, but only inside their granted entities. +export const isAdministrator = (role: UserRole) => role === "fund_administrator"; +// Can run the admin screens (users, documents, capital accounts, imports). +export const isManager = (role: UserRole) => isAdmin(role) || isAdministrator(role); +// Can edit entity records (partners, exits, fund details) — internal writers + Administrator. +export const canManageEntity = (role: UserRole) => + WRITER_ROLES.includes(role) || isAdministrator(role); export interface User { id: number; @@ -576,6 +583,8 @@ export const api = { method: "PUT", body: JSON.stringify({ primary_account_id }), }), + deleteUser: (id: number) => + request<{ status: string }>(`/api/users/${id}`, { method: "DELETE" }), // Documents listDocuments: (params?: { entity_id?: number; investor_user_id?: number }) => { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9c2ca2a..f0f2c39 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; -import { isAdmin } from "../api"; +import { isAdmin, isAdministrator } from "../api"; import { roleLabel } from "../format"; -import { APP_VERSION } from "../version"; import ChangePasswordModal from "./ChangePasswordModal"; import TwoFactorModal from "./TwoFactorModal"; @@ -28,9 +27,13 @@ export default function Layout({ children }: { children: React.ReactNode }) { const [navOpen, setNavOpen] = useState(false); const [changingPw, setChangingPw] = useState(false); const [managing2fa, setManaging2fa] = useState(false); + // An external Administrator gets the admin screens for their funds, but not the + // internal-only views (Investor View mirrors any LP; Audit Log spans the whole portal). const navItems = user && isAdmin(user.role) ? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV] - : [...TOP_NAV, ...BOTTOM_NAV]; + : user && isAdministrator(user.role) + ? [...TOP_NAV, ...ADMIN_NAV.filter((i) => i.path !== "/investor-view")] + : [...TOP_NAV, ...BOTTOM_NAV]; return (
@@ -91,7 +94,6 @@ export default function Layout({ children }: { children: React.ReactNode }) { > Two-factor auth -
v{APP_VERSION}
diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 5db123a..98daf55 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -60,10 +60,10 @@ export function roleLabel(role: string): string { approver: "Managing Partner", operations: "Operations", cfo: "CFO", - fund_admin: "Fund Admin", + fund_admin: "Staff (all funds)", viewer: "Viewer (internal)", investor: "Investor", - fund_administrator: "Fund administrator", + fund_administrator: "Administrator", }; return map[role] ?? role; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 04dbce1..889ff37 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; +import { APP_VERSION } from "./version"; + +// Not shown in the UI; open the browser console to confirm which build is being served +// (the stale-deploy diagnostic the old sidebar badge used to provide). +console.info(`Ten31 Portal v${APP_VERSION}`); createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/pages/EntityOverview.tsx b/frontend/src/pages/EntityOverview.tsx index 7824fb9..261ca11 100644 --- a/frontend/src/pages/EntityOverview.tsx +++ b/frontend/src/pages/EntityOverview.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { api, canEditRound, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api"; +import { api, canManageEntity, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api"; import { useAuth } from "../context/AuthContext"; import { formatMoney, formatDate, formatQuarter } from "../format"; import EntityHeader from "../components/EntityHeader"; @@ -25,7 +25,7 @@ export default function EntityOverview() { const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(false); - const isWriter = !!user && canEditRound(user.role); + const isWriter = !!user && canManageEntity(user.role); // For a GP/mgmt entity linked to an investor account, total its balance across funds. const linkedTotal = useMemo(() => { diff --git a/frontend/src/pages/EntityPartners.tsx b/frontend/src/pages/EntityPartners.tsx index 17ce913..40da90e 100644 --- a/frontend/src/pages/EntityPartners.tsx +++ b/frontend/src/pages/EntityPartners.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { api, canEditRound, type Entity, type Partner } from "../api"; +import { api, canManageEntity, type Entity, type Partner } from "../api"; import { useAuth } from "../context/AuthContext"; import { formatDate, formatMoneyExact } from "../format"; import EntityHeader from "../components/EntityHeader"; @@ -18,7 +18,7 @@ export default function EntityPartners() { const [exitingId, setExitingId] = useState(null); const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10)); - const isWriter = !!user && canEditRound(user.role); + const isWriter = !!user && canManageEntity(user.role); useEffect(() => { if (!id) return; diff --git a/frontend/src/pages/Import.tsx b/frontend/src/pages/Import.tsx index 8942093..2f5dc28 100644 --- a/frontend/src/pages/Import.tsx +++ b/frontend/src/pages/Import.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { - api, isAdmin, + api, isAdministrator, isManager, type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult, type BtcPricesStatus, } from "../api"; @@ -47,7 +47,10 @@ export default function Import() { const [step, setStep] = useState(""); const [confirmReplace, setConfirmReplace] = useState(false); - const canImport = user && isAdmin(user.role); + const canImport = user && isManager(user.role); + // An Administrator imports into their own funds only — no fund creation, and the + // portal-wide BTC price table stays internal. + const administrator = !!user && isAdministrator(user.role); const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm"; useEffect(() => { @@ -209,7 +212,7 @@ export default function Import() { } if (!canImport) { - return
Import requires Managing Partner, Operations, or CFO role.
; + return
Import requires an admin role.
; } return ( @@ -239,16 +242,18 @@ export default function Import() {
-
- - -
+ {!administrator && ( +
+ + +
+ )} {entityMode === "existing" ? ( setRole(e.target.value as UserRole)}> - {CREATABLE_ROLES.map((r) => ( - - ))} - + {investorOnly ? ( +

+ Investor +

+ ) : ( + + )} {external && ( @@ -285,7 +295,28 @@ function EditUserModal({ const [linkTo, setLinkTo] = useState(user.primary_account_id ?? ""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const { user: me } = useAuth(); const external = !isInternal(user.role); + const canDelete = !user.is_service_admin && user.id !== me?.id; + + const remove = async () => { + if ( + !window.confirm( + `Delete ${user.name} (${user.username})? Their fund access, capital statements, ` + + "and private documents are removed too. This cannot be undone.", + ) + ) + return; + setError(""); + setBusy(true); + try { + await api.deleteUser(user.id); + onSaved(); + } catch (e: any) { + setError(e.message || "Failed to delete"); + setBusy(false); + } + }; const isInvestor = user.role === "investor"; const isPrimary = user.linked_accounts.length > 0; const linked = linkTo !== ""; @@ -380,17 +411,28 @@ function EditUserModal({ )} {error &&

{error}

} -
- - +
+ {canDelete && ( + + )} +
+ + +
diff --git a/frontend/src/portal/FundAdminHome.tsx b/frontend/src/portal/FundAdminHome.tsx deleted file mode 100644 index 5b65d81..0000000 --- a/frontend/src/portal/FundAdminHome.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useEffect, useState } from "react"; -import { - api, - type DocumentCategory, - type Entity, - type PortalDocument, - type User, -} from "../api"; -import { categoryLabel, formatBytes, formatDate } from "../format"; - -const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"]; - -export default function FundAdminHome() { - const [entities, setEntities] = useState([]); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api - .listEntities() - .then(setEntities) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, []); - - if (loading) return

Loading…

; - if (error) return

{error}

; - if (entities.length === 0) - return

You don't have access to any entities yet.

; - - return ( -
- {entities.map((e) => ( - - ))} -
- ); -} - -function EntityDocs({ entity }: { entity: Entity }) { - const [docs, setDocs] = useState([]); - const [investors, setInvestors] = useState([]); - const [error, setError] = useState(""); - - const load = () => { - api.listDocuments({ entity_id: entity.id }).then(setDocs).catch((e) => setError(e.message)); - }; - useEffect(() => { - load(); - api.investorsForEntity(entity.id).then(setInvestors).catch(() => {}); - }, [entity.id]); - - const investorName = (id: number | null) => - id == null ? "Shared (all investors)" : investors.find((i) => i.id === id)?.name ?? `Investor ${id}`; - - return ( -
-
-

{entity.name}

- {entity.type} -
- {error &&

{error}

} - - - -

Documents

- {docs.length === 0 ? ( -

No documents yet.

- ) : ( -
    - {docs.map((d) => ( -
  • - {d.title} - {categoryLabel(d.category)} - · {investorName(d.investor_user_id)} - {formatDate(d.created_at)} - - Download - -
  • - ))} -
- )} -
- ); -} - -function UploadForm({ - entity, - investors, - onUploaded, - setError, -}: { - entity: Entity; - investors: User[]; - onUploaded: () => void; - setError: (s: string) => void; -}) { - const [category, setCategory] = useState("statement"); - const [investorId, setInvestorId] = useState(""); - const [title, setTitle] = useState(""); - const [file, setFile] = useState(null); - const [busy, setBusy] = useState(false); - - const submit = async () => { - if (!file) return; - setError(""); - setBusy(true); - try { - const form = new FormData(); - form.set("entity_id", String(entity.id)); - form.set("category", category); - if (title) form.set("title", title); - if (investorId !== "") form.set("investor_user_id", String(investorId)); - form.set("file", file); - await api.uploadDocument(form); - setTitle(""); - setFile(null); - setInvestorId(""); - onUploaded(); - } catch (e: any) { - setError(e.message || "Upload failed"); - } finally { - setBusy(false); - } - }; - - return ( -
-
-
- - -
-
- - -
-
- - setTitle(e.target.value)} /> -
-
- - setFile(e.target.files?.[0] ?? null)} className="text-sm" /> - {file && {formatBytes(file.size)}} -
-
-
- -
-
- ); -} - -const inputCls = - "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"; diff --git a/frontend/src/portal/PortalLayout.tsx b/frontend/src/portal/PortalLayout.tsx index cdffb2b..1a89b57 100644 --- a/frontend/src/portal/PortalLayout.tsx +++ b/frontend/src/portal/PortalLayout.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { useAuth } from "../context/AuthContext"; import { roleLabel } from "../format"; -import { APP_VERSION } from "../version"; import ChangePasswordModal from "../components/ChangePasswordModal"; import TwoFactorModal from "../components/TwoFactorModal"; @@ -31,7 +30,6 @@ export default function PortalLayout({ children }: { children: React.ReactNode } - v{APP_VERSION}
{children}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 0940ed9..3ae03fb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ // Bumped each release so the running build is visible in the UI. // If the number shown in the app doesn't match the installed s9pk version, // the new frontend isn't actually being served. -export const APP_VERSION = "0.2.40"; +export const APP_VERSION = "0.2.42";