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:
@@ -0,0 +1,256 @@
|
||||
"""Regression tests for the pre-release security audit fixes.
|
||||
|
||||
Covers three classes of finding:
|
||||
- Cross-tenant investor takeover by an external Administrator (scope-check on every write that
|
||||
can mint an access grant or a capital statement for an arbitrary investor).
|
||||
- Internal privilege escalation via role/is_active mass-assignment on the user endpoints.
|
||||
- Server-side enforcement of the forced password change (not just the browser modal).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _login(client, username, password="password123"):
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={"login": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def _two_funds(session):
|
||||
"""Fund A (managed by `fundadmin`) and Fund B. lp_a is in A, lp_b is in B (out of scope)."""
|
||||
fund_a = Entity(name="Fund A", type=EntityType.fund)
|
||||
fund_b = Entity(name="Fund B", type=EntityType.fund)
|
||||
session.add(fund_a)
|
||||
session.add(fund_b)
|
||||
session.commit()
|
||||
|
||||
admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator,
|
||||
name="Outside Administrator")
|
||||
session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id))
|
||||
lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id))
|
||||
lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta")
|
||||
session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id))
|
||||
session.commit()
|
||||
return fund_a, fund_b, admin, lp_a, lp_b
|
||||
|
||||
|
||||
# --- P0-1: cross-tenant investor takeover ------------------------------------------------
|
||||
|
||||
def test_administrator_cannot_grant_out_of_scope_investor(client, session, approver):
|
||||
"""The takeover chain: grant an out-of-fund investor access to my fund, which would pull
|
||||
them into scope and unlock reset-password. The grant itself must be refused."""
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
resp = client.put(f"/api/users/{lp_b.id}/access/{fund_a.id}")
|
||||
assert resp.status_code == 403, resp.text
|
||||
# No EntityAccess row was minted, so lp_b never entered the Administrator's scope.
|
||||
rows = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id
|
||||
)
|
||||
).all()
|
||||
assert rows == []
|
||||
# And the follow-on reset-password stays blocked.
|
||||
assert client.post(f"/api/users/{lp_b.id}/reset-password",
|
||||
json={"password": "attacker-set-pw"}).status_code == 403
|
||||
|
||||
|
||||
def test_administrator_cannot_create_statement_for_out_of_scope_investor(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
resp = client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_a.id,
|
||||
"investor_user_id": lp_b.id,
|
||||
"as_of_date": "2026-03-31",
|
||||
"commitment_dollars": 1000, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 1000, "distributions_dollars": 0,
|
||||
"ending_balance_dollars": 1000,
|
||||
})
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.investor_user_id == lp_b.id
|
||||
)
|
||||
).all() == []
|
||||
|
||||
|
||||
def test_administrator_cannot_mint_exit_grant_for_out_of_scope_investor(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
resp = client.put(f"/api/entities/{fund_a.id}/partners/{lp_b.id}/exited",
|
||||
json={"exited_on": "2026-06-30"})
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id
|
||||
)
|
||||
).all() == []
|
||||
|
||||
|
||||
def test_administrator_can_still_manage_in_scope_investor(client, session, approver):
|
||||
"""Positive control: the legitimate in-scope path is untouched."""
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
# lp_a is already in fund_a, so a statement and a password reset both succeed.
|
||||
assert client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-03-31",
|
||||
"commitment_dollars": 500, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 500, "distributions_dollars": 0,
|
||||
"ending_balance_dollars": 500,
|
||||
}).status_code == 201
|
||||
assert client.post(f"/api/users/{lp_a.id}/reset-password",
|
||||
json={"password": "a-fresh-password"}).status_code == 200
|
||||
|
||||
|
||||
# --- P0-2: internal privilege escalation via mass-assignment -----------------------------
|
||||
|
||||
def test_operations_cannot_self_promote_to_approver(client, session):
|
||||
ops = make_user(session, username="ops", role=UserRole.operations)
|
||||
_login(client, "ops")
|
||||
|
||||
resp = client.patch(f"/api/users/{ops.id}", json={"role": "approver"})
|
||||
assert resp.status_code == 403, resp.text
|
||||
session.refresh(ops)
|
||||
assert ops.role == UserRole.operations
|
||||
|
||||
|
||||
def test_operations_cannot_create_approver(client, session):
|
||||
make_user(session, username="ops", role=UserRole.operations)
|
||||
_login(client, "ops")
|
||||
|
||||
resp = client.post("/api/users", json={
|
||||
"name": "Sneaky", "username": "sneaky", "password": "password123", "role": "approver",
|
||||
})
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
def test_operations_cannot_deactivate_service_admin(client, session):
|
||||
make_user(session, username="ops", role=UserRole.operations)
|
||||
svc = make_user(session, username="svc", role=UserRole.approver, is_service_admin=True)
|
||||
_login(client, "ops")
|
||||
|
||||
resp = client.patch(f"/api/users/{svc.id}", json={"is_active": False})
|
||||
assert resp.status_code == 403, resp.text
|
||||
session.refresh(svc)
|
||||
assert svc.is_active is True
|
||||
|
||||
|
||||
def test_operations_cannot_disable_an_approver(client, session):
|
||||
make_user(session, username="ops", role=UserRole.operations)
|
||||
boss = make_user(session, username="boss", role=UserRole.approver)
|
||||
_login(client, "ops")
|
||||
|
||||
resp = client.patch(f"/api/users/{boss.id}", json={"is_active": False})
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
def test_approver_can_still_manage_roles(client, session):
|
||||
"""Positive control: an approver retains full role management."""
|
||||
approver = make_user(session, username="chief", role=UserRole.approver)
|
||||
ops = make_user(session, username="ops", role=UserRole.operations)
|
||||
_login(client, "chief")
|
||||
|
||||
assert client.patch(f"/api/users/{ops.id}", json={"role": "approver"}).status_code == 200
|
||||
session.refresh(ops)
|
||||
assert ops.role == UserRole.approver
|
||||
|
||||
|
||||
def test_operations_can_still_edit_ordinary_fields(client, session):
|
||||
"""Non-privileged edits by an internal admin keep working."""
|
||||
make_user(session, username="ops", role=UserRole.operations)
|
||||
viewer = make_user(session, username="v", role=UserRole.viewer, name="Old Name")
|
||||
_login(client, "ops")
|
||||
|
||||
assert client.patch(f"/api/users/{viewer.id}", json={"name": "New Name"}).status_code == 200
|
||||
session.refresh(viewer)
|
||||
assert viewer.name == "New Name"
|
||||
|
||||
|
||||
# --- P0-3: server-side forced password change --------------------------------------------
|
||||
|
||||
def test_must_change_password_blocks_api_until_rotated(client, session):
|
||||
make_user(session, username="lp", role=UserRole.investor, password="Ten31Portal",
|
||||
must_change_password=True)
|
||||
resp = client.post("/api/auth/login", json={"login": "lp", "password": "Ten31Portal"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# /me is allowed (the client needs it to learn it must prompt), but data endpoints are not.
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
assert client.get("/api/entities").status_code == 403
|
||||
assert client.get("/api/documents").status_code == 403
|
||||
|
||||
# Rotating the password lifts the block.
|
||||
changed = client.post("/api/auth/change-password", json={
|
||||
"current_password": "Ten31Portal", "new_password": "my-own-strong-pw",
|
||||
})
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert client.get("/api/entities").status_code == 200
|
||||
|
||||
|
||||
# --- P1/P2: transport and header hardening -----------------------------------------------
|
||||
|
||||
def test_security_headers_present(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert resp.headers["X-Frame-Options"] == "DENY"
|
||||
assert "default-src 'self'" in resp.headers["Content-Security-Policy"]
|
||||
assert "frame-ancestors 'none'" in resp.headers["Content-Security-Policy"]
|
||||
|
||||
|
||||
def test_session_cookie_is_hardened(client, session):
|
||||
make_user(session, username="u", role=UserRole.approver)
|
||||
resp = client.post("/api/auth/login", json={"login": "u", "password": "password123"})
|
||||
assert resp.status_code == 200
|
||||
set_cookie = resp.headers["set-cookie"].lower()
|
||||
assert "httponly" in set_cookie
|
||||
assert "samesite=lax" in set_cookie
|
||||
assert "max-age=" in set_cookie # bounded lifetime, not the 14-day default
|
||||
|
||||
|
||||
def test_download_is_no_store(client, session, tmp_path, monkeypatch):
|
||||
"""Document downloads must not be cached on shared machines."""
|
||||
from ten31portal import storage
|
||||
from ten31portal.models import Document, DocumentCategory, Entity, EntityType
|
||||
|
||||
monkeypatch.setattr(storage, "DOCS_DIR", str(tmp_path))
|
||||
(tmp_path / "file.pdf").write_bytes(b"%PDF-1.4 test")
|
||||
ent = Entity(name="F", type=EntityType.fund)
|
||||
session.add(ent)
|
||||
session.commit()
|
||||
doc = Document(entity_id=ent.id, category=DocumentCategory.k1, title="K1",
|
||||
original_filename="k1.pdf", content_type="application/pdf",
|
||||
size_bytes=13, storage_path="file.pdf", uploaded_by=None)
|
||||
session.add(doc)
|
||||
session.commit()
|
||||
make_user(session, username="staff", role=UserRole.approver)
|
||||
_login(client, "staff")
|
||||
|
||||
resp = client.get(f"/api/documents/{doc.id}/download")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["Cache-Control"] == "no-store"
|
||||
|
||||
|
||||
# --- P2-9: import preview must not leak cross-fund investors ------------------------------
|
||||
|
||||
def test_external_admin_preview_requires_a_fund(client, session, approver):
|
||||
_two_funds(session)
|
||||
_login(client, "fundadmin")
|
||||
# No entity_id: refused before any name-matching can leak which accounts exist.
|
||||
resp = client.post(
|
||||
"/api/import/capital-accounts/preview",
|
||||
files={"file": ("x.xlsx", b"not-a-real-workbook", "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
Reference in New Issue
Block a user