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