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:
@@ -7,8 +7,8 @@ from sqlmodel import Session, select, col
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, check_administrator_scope, get_current_user,
|
||||
household_user_ids, require_admin,
|
||||
accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
|
||||
get_current_user, household_user_ids, require_admin,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
@@ -135,6 +135,9 @@ def create_statement(
|
||||
investor = session.get(User, body.investor_user_id)
|
||||
if investor is None or investor.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account")
|
||||
# An external Administrator can only write statements for an investor already in their
|
||||
# scope; otherwise this row would itself bring an out-of-scope investor into reach.
|
||||
check_target_investor_scope(admin, body.investor_user_id, session)
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
|
||||
@@ -20,7 +20,10 @@ from sqlmodel import Session, select
|
||||
|
||||
from ten31portal import config, storage
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import check_administrator_scope, hash_password, require_admin
|
||||
from ten31portal.auth import (
|
||||
check_administrator_scope, check_target_investor_scope, hash_password, require_admin,
|
||||
visible_investor_ids,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||
@@ -232,6 +235,15 @@ def preview_import(
|
||||
if entity_id is not None and session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
# An external Administrator must name one of their funds, and may only be shown matches
|
||||
# against investors already inside their scope. Otherwise the name-matching would confirm
|
||||
# which uploaded names correspond to real accounts (and their usernames) in other funds.
|
||||
scope_ids = visible_investor_ids(admin, session)
|
||||
if scope_ids is not None and entity_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Select one of your funds before previewing an import."
|
||||
)
|
||||
|
||||
try:
|
||||
file_bytes = storage.read_capped(file)
|
||||
except storage.UploadTooLarge as exc:
|
||||
@@ -239,6 +251,8 @@ def preview_import(
|
||||
wb = _open_workbook(file_bytes, password)
|
||||
|
||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||
if scope_ids is not None:
|
||||
investors = [u for u in investors if u.id in scope_ids]
|
||||
by_name = {u.name.strip().lower(): u for u in investors}
|
||||
by_username = {u.username.strip().lower(): u for u in investors}
|
||||
by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id}
|
||||
@@ -340,6 +354,9 @@ def commit_import(
|
||||
user = session.get(User, inv.user_id) if inv.user_id else None
|
||||
if user is None or user.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="match requires a valid investor user_id.")
|
||||
# An external Administrator can only match an investor already inside their scope;
|
||||
# a new investor for their fund must go through the "create" action instead.
|
||||
check_target_investor_scope(admin, user.id, session)
|
||||
if inv.external_id and not user.external_investor_id:
|
||||
user.external_investor_id = inv.external_id
|
||||
session.add(user)
|
||||
|
||||
@@ -154,6 +154,8 @@ def download_document(
|
||||
path,
|
||||
media_type=doc.content_type,
|
||||
filename=doc.original_filename,
|
||||
# K-1s and capital statements must not linger in a shared-machine browser cache.
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlmodel import Session, col, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids,
|
||||
require_entity_writer, require_internal, require_internal_or_administrator, require_writer,
|
||||
accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
|
||||
get_current_user, household_user_ids, require_entity_writer, require_internal,
|
||||
require_internal_or_administrator, require_writer,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
@@ -178,6 +179,9 @@ def set_partner_exited(
|
||||
member = session.get(User, user_id)
|
||||
if member is None or member.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Only investor members can be marked exited")
|
||||
# Marking an exit can mint an access grant (below); an external Administrator may only do
|
||||
# that for an investor already inside their scope, never to pull in an out-of-fund account.
|
||||
check_target_investor_scope(admin, user_id, session)
|
||||
|
||||
access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
|
||||
@@ -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