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
+31
View File
@@ -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")
+6 -50
View File
@@ -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}).")
@@ -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()
@@ -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")
@@ -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,
+17 -7
View File
@@ -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 {}
@@ -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")
+10 -2
View File
@@ -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:
@@ -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]
+8 -3
View File
@@ -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)
+198 -15
View File
@@ -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"}