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.
242 lines
9.9 KiB
Python
242 lines
9.9 KiB
Python
"""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
|