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