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
+50 -1
View File
@@ -8,7 +8,9 @@ from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select, col
from ten31portal.database import get_session
from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES
from ten31portal.models import (
CapitalAccountStatement, EntityAccess, User, UserRole, EXTERNAL_ROLES,
)
ph = PasswordHasher()
@@ -24,6 +26,16 @@ def verify_password(password: str, password_hash: str) -> bool:
return False
# While an account is on the shared default password it may reach only these endpoints, so the
# forced password change cannot be skipped by calling the API directly (the browser modal is not
# a security control). Everything else is blocked until the password is rotated.
_PASSWORD_CHANGE_ALLOWED = frozenset({
"/api/auth/me",
"/api/auth/change-password",
"/api/auth/logout",
})
def get_current_user(request: Request, session: Session = Depends(get_session)) -> User:
"""FastAPI dependency: extract user from session cookie."""
user_id = request.session.get("user_id")
@@ -32,6 +44,8 @@ def get_current_user(request: Request, session: Session = Depends(get_session))
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Not authenticated")
if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_ALLOWED:
raise HTTPException(status_code=403, detail="password_change_required")
return user
@@ -124,3 +138,38 @@ def check_administrator_scope(user: User, entity_id: int, session: Session) -> N
"""
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")
def visible_investor_ids(user: User, session: Session) -> set[int] | None:
"""Investor ids an external account may see or manage. None means unrestricted (internal).
An investor is in scope when they already have an access grant or a capital-account
statement in one of the caller's entities. This is the set an external Administrator is
allowed to touch; it is derived from rows that predate the current request, so a write
that would CREATE such a row for a new investor must be scope-checked BEFORE it runs.
"""
scope = accessible_entity_ids(user, session)
if scope is None:
return None
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_investor_scope(admin: User, target_user_id: int, session: Session) -> None:
"""403 unless the caller is internal, or the target is an investor already inside the
caller's scope. Blocks an external Administrator from pulling an out-of-scope investor
into their funds (which would otherwise widen their scope and unlock further actions)."""
allowed = visible_investor_ids(admin, session)
if allowed is None:
return
if target_user_id not in allowed:
raise HTTPException(status_code=403, detail="No access to this account")