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:
@@ -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