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:
Jonathan Kirkwood
2026-08-11 15:37:44 -05:00
parent 1dabbc3073
commit 858bbe10da
18 changed files with 273 additions and 70 deletions
+5 -4
View File
@@ -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")
+3 -2
View File
@@ -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))
+8 -2
View File
@@ -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]