0.2.46: security hardening (external-fund scope, role guards, forced password rotation, headers)

Pre-release audit fixes:
- External Administrators can only act on investors already in their own funds
  (grant_access, create_statement, set_partner_exited, import match), closing a
  cross-fund account-takeover chain.
- Role and is_active changes are gated: no self-escalation, non-approvers cannot
  mint or alter approver accounts, and the Service Admin is protected.
- must_change_password is enforced server-side, not just in the client.
- Session cookie is Secure with a 12h max-age and SameSite; CSP and standard
  security headers on every response; the app refuses to boot on a known or short
  session secret; uvicorn runs with proxy-headers so login throttling keys on the
  real client IP behind the platform proxy.
- Import preview no longer confirms cross-fund investors; no-store on document
  downloads.
- Adds backend/tests/test_security_hardening.py (15 tests); full suite 76 passing.
- SECURITY-AUDIT.md records the swarm audit, findings, and remediation status.
This commit is contained in:
Jonathan Kirkwood
2026-08-28 09:53:16 -05:00
parent 858bbe10da
commit 3a4b7dfd8b
15 changed files with 795 additions and 31 deletions
+65 -7
View File
@@ -5,8 +5,9 @@ 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, require_role,
accessible_entity_ids, can_access_entity, check_administrator_scope,
check_target_investor_scope, get_current_user, hash_password, household_user_ids,
require_admin, require_internal_admin, require_role, visible_investor_ids,
)
from ten31portal.database import get_session
from ten31portal.models import (
@@ -43,12 +44,15 @@ def _visible_investor_ids(scope: set[int], session: Session) -> set[int]:
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:
from other funds' investors entirely. Delegates to the shared helper so the scope
definition can never drift between routers."""
if target.role != UserRole.investor:
# An external Administrator may only ever act on investor accounts.
scope = accessible_entity_ids(admin, session)
if scope is not None:
raise HTTPException(status_code=403, detail="No access to this account")
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")
check_target_investor_scope(admin, target.id, session)
def delete_user_cascade(session: Session, user: User) -> list[str]:
@@ -106,6 +110,51 @@ def delete_user_cascade(session: Session, user: User) -> list[str]:
return warnings
def _enforce_privileged_fields(
admin: User,
target: User | None,
new_role: UserRole | None,
new_is_active: bool | None,
session: Session,
) -> None:
"""Guard role/is_active against privilege escalation and admin tampering.
`role` and `is_active` are not ordinary profile fields: writing them changes who can do
what. Without this, any internal admin (operations/cfo) could PATCH their own role to
approver, mint new approver accounts, or deactivate the Service Admin. Rules:
- Only an approver may create or confer the `approver` role, or change an existing
approver's role/status (approver holds final valuation sign-off — the one control
operations is defined not to have).
- The Service Admin's role and status are fixed via the API.
- No account may change its own role or active status.
`target` is None on create. Internal non-approver admins keep managing non-approver
accounts (investors, viewers, fund admins, and each other) unchanged.
"""
changing_role = new_role is not None and (target is None or new_role != target.role)
changing_active = (
new_is_active is not None and target is not None and new_is_active != target.is_active
)
if target is not None and (changing_role or changing_active):
if target.is_service_admin:
raise HTTPException(
status_code=403, detail="The Service Admin's role and status are fixed."
)
if target.id == admin.id:
raise HTTPException(
status_code=403, detail="You cannot change your own role or status."
)
touches_approver = (new_role == UserRole.approver) or (
target is not None and target.role == UserRole.approver and (changing_role or changing_active)
)
if touches_approver and admin.role != UserRole.approver:
raise HTTPException(
status_code=403,
detail="Only a Managing Partner (approver) can manage approver accounts.",
)
@router.get("/access-matrix")
def access_matrix(
admin: User = Depends(require_admin),
@@ -147,6 +196,10 @@ def grant_access(
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")
# An external Administrator may only add funds for an investor already inside their scope;
# otherwise granting access would pull an out-of-fund investor in and widen the caller's
# reach. Internal staff pass through. New investors are onboarded via create_user instead.
_check_target_in_scope(admin, user, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
existing = session.exec(
@@ -379,6 +432,8 @@ def create_user(
raise HTTPException(
status_code=400, detail="Select at least one of your funds for this investor"
)
# Internal callers may pick any role here; block escalation to approver by non-approvers.
_enforce_privileged_fields(admin, None, body.role, None, session)
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():
@@ -413,6 +468,9 @@ def update_user(
if user is None:
raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session)
# role/is_active are privileged: block self-escalation, approver tampering, and any change
# to the Service Admin before applying the field writes below.
_enforce_privileged_fields(admin, user, body.role, body.is_active, session)
changes = body.model_dump(exclude_unset=True)
entity_ids = changes.pop("entity_ids", None)