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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user