0.2.45: Administrator (view only) role
New external role administrator_viewer: signs into the admin interface and reads everything for its granted funds and SPVs (overview, partners, capital accounts with every investor's statements, documents, valuation history) but every write is refused: no imports, uploads, deletions, entity edits, exit marking, or account management. No migration needed; roles are stored as strings. Internal admins can flip an Administrator between full management and view only via a new Access level dropdown in Users > Manage. The user-list endpoint is read-widened for the viewer role so investor names resolve on its screens; all mutating endpoints keep the stricter gate.
This commit is contained in:
@@ -107,9 +107,10 @@ require_entity_writer = require_role(
|
||||
|
||||
|
||||
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
|
||||
"""Read gate for admin screens: any internal role, or the external Administrator.
|
||||
"""Read gate for admin screens: any internal role, or an external Administrator
|
||||
(managing or view-only).
|
||||
|
||||
Investors are blocked; Administrator calls must still be scope-checked per entity.
|
||||
Investors are blocked; external calls must still be scope-checked per entity.
|
||||
"""
|
||||
if user.role == UserRole.investor:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
@@ -117,9 +118,9 @@ def require_internal_or_administrator(user: User = Depends(get_current_user)) ->
|
||||
|
||||
|
||||
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
|
||||
"""403 when an external Administrator touches an entity outside their grants.
|
||||
"""403 when an external account 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):
|
||||
if user.role in EXTERNAL_ROLES and not can_access_entity(user, entity_id, session):
|
||||
raise HTTPException(status_code=403, detail="No access to this entity")
|
||||
|
||||
@@ -20,11 +20,12 @@ class UserRole(str, enum.Enum):
|
||||
viewer = "viewer"
|
||||
# External accounts (entity-scoped via EntityAccess)
|
||||
investor = "investor"
|
||||
fund_administrator = "fund_administrator"
|
||||
fund_administrator = "fund_administrator" # "Administrator" — manages its funds
|
||||
administrator_viewer = "administrator_viewer" # "Administrator (view only)" — reads its funds
|
||||
|
||||
|
||||
# External roles see only the entities granted to them.
|
||||
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator)
|
||||
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer)
|
||||
|
||||
|
||||
class DocumentCategory(str, enum.Enum):
|
||||
|
||||
@@ -76,8 +76,9 @@ def list_statements(
|
||||
query = select(CapitalAccountStatement)
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
|
||||
if user.role == UserRole.fund_administrator:
|
||||
# An Administrator sees every investor's statements, but only inside their funds.
|
||||
if user.role in (UserRole.fund_administrator, UserRole.administrator_viewer):
|
||||
# An Administrator (managing or view-only) sees every investor's statements,
|
||||
# but only inside their funds.
|
||||
if not allowed:
|
||||
return []
|
||||
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlmodel import Session, select, col
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
|
||||
hash_password, household_user_ids, require_admin, require_internal_admin,
|
||||
hash_password, household_user_ids, require_admin, require_internal_admin, require_role,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
@@ -329,7 +329,13 @@ def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) ->
|
||||
|
||||
@router.get("")
|
||||
def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
# Read-widened: a view-only Administrator may list the investors of its funds (it needs
|
||||
# their names on the capital-accounts and documents screens); every mutating endpoint
|
||||
# below keeps the stricter require_admin gate.
|
||||
admin: User = Depends(require_role(
|
||||
UserRole.approver, UserRole.cfo, UserRole.operations,
|
||||
UserRole.fund_administrator, UserRole.administrator_viewer,
|
||||
)),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[UserResponse]:
|
||||
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Administrator (view only) role (0.2.45): reads everything on its granted entities,
|
||||
changes nothing anywhere."""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, 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):
|
||||
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()
|
||||
|
||||
viewer = make_user(session, username="viewonly", role=UserRole.administrator_viewer,
|
||||
name="Read Only Admin")
|
||||
session.add(EntityAccess(user_id=viewer.id, entity_id=fund_a.id))
|
||||
|
||||
lp = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||
session.add(EntityAccess(user_id=lp.id, entity_id=fund_a.id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=fund_a.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||
commitment_cents=100_00, beginning_balance_cents=0, contributions_cents=100_00,
|
||||
distributions_cents=0, ending_balance_cents=110_00,
|
||||
))
|
||||
session.commit()
|
||||
return fund_a, fund_b, viewer, lp
|
||||
|
||||
|
||||
def test_viewer_reads_only_their_fund(client, session, approver):
|
||||
fund_a, fund_b, viewer, lp = _setup(session)
|
||||
_login(client, "viewonly")
|
||||
|
||||
entities = client.get("/api/entities").json()
|
||||
assert [e["name"] for e in entities] == ["Fund A"]
|
||||
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
|
||||
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||
|
||||
# Sees every investor's statements within the fund, plus their names.
|
||||
rows = client.get("/api/capital-accounts").json()
|
||||
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||
users = client.get("/api/users").json()
|
||||
assert {u["username"] for u in users} == {"lp-a"}
|
||||
|
||||
docs = client.get("/api/documents").json()
|
||||
assert isinstance(docs, list)
|
||||
|
||||
|
||||
def test_viewer_cannot_change_anything(client, session, approver):
|
||||
fund_a, fund_b, viewer, lp = _setup(session)
|
||||
_login(client, "viewonly")
|
||||
|
||||
# Entity records
|
||||
assert client.patch(f"/api/entities/{fund_a.id}", json={"name": "X"}).status_code == 403
|
||||
assert client.put(
|
||||
f"/api/entities/{fund_a.id}/partners/{lp.id}/exited", json={"exited_on": "2026-06-30"}
|
||||
).status_code == 403
|
||||
assert client.delete(f"/api/entities/{fund_a.id}/partners").status_code == 403
|
||||
|
||||
# Documents
|
||||
resp = client.post(
|
||||
"/api/documents",
|
||||
data={"entity_id": str(fund_a.id), "category": "statement"},
|
||||
files={"file": ("x.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Capital accounts
|
||||
assert client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_a.id, "investor_user_id": lp.id, "as_of_date": "2026-06-30",
|
||||
"commitment_dollars": 1, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 1, "distributions_dollars": 0, "ending_balance_dollars": 1,
|
||||
}).status_code == 403
|
||||
|
||||
# Imports
|
||||
assert client.post(
|
||||
f"/api/import/schedule?entity_id={fund_a.id}",
|
||||
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||
).status_code == 403
|
||||
assert client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": str(fund_a.id)},
|
||||
files=[("files", ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream"))],
|
||||
).status_code == 403
|
||||
|
||||
# User management
|
||||
assert client.post("/api/users", json={
|
||||
"name": "N", "username": "n", "password": "secretpw",
|
||||
"role": "investor", "entity_ids": [fund_a.id],
|
||||
}).status_code == 403
|
||||
assert client.patch(f"/api/users/{lp.id}", json={"is_active": False}).status_code == 403
|
||||
assert client.delete(f"/api/users/{lp.id}").status_code == 403
|
||||
assert client.get("/api/users/access-matrix").status_code == 403
|
||||
|
||||
|
||||
def test_internal_admin_toggles_administrator_level(auth_client, session):
|
||||
admin_acct = make_user(session, username="mgr", role=UserRole.fund_administrator,
|
||||
name="Managing Admin")
|
||||
|
||||
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "administrator_viewer"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["role"] == "administrator_viewer"
|
||||
|
||||
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "fund_administrator"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "fund_administrator"
|
||||
Reference in New Issue
Block a user