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:
@@ -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}).")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user