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:
Jonathan Kirkwood
2026-08-10 15:38:39 -05:00
parent 3c7094241c
commit ae967494bd
29 changed files with 693 additions and 332 deletions
+6 -50
View File
@@ -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}).")