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")
+14
View File
@@ -5,6 +5,20 @@ import os
DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db")
SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production")
# Session secrets that must never reach production. The app refuses to start on any of these
# (see main.lifespan) so a packaging slip cannot silently boot on a source-visible key that
# would let anyone forge a session cookie.
INSECURE_SESSION_SECRETS: frozenset[str] = frozenset({
"", "change-me", "change-me-in-production",
})
# Send the session cookie only over HTTPS. Off by default so local dev and the test client
# (both plain HTTP) work; the container turns it on via TEN31_SESSION_COOKIE_SECURE=true.
SESSION_COOKIE_SECURE: bool = os.getenv(
"TEN31_SESSION_COOKIE_SECURE", "false"
).lower() in ("1", "true", "yes")
# Session lifetime. Default 12 hours (was the Starlette 14-day default), so a captured cookie
# ages out in a workday rather than a fortnight.
SESSION_MAX_AGE: int = int(os.getenv("TEN31_SESSION_MAX_AGE", str(12 * 60 * 60)))
DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents")
# Cap on a single uploaded document. The docs dir shares the data volume with the DB, so an
# unbounded upload could fill the disk and take the portal down. Default 50 MB. The same cap
+45 -3
View File
@@ -4,13 +4,16 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
from ten31portal.config import SESSION_SECRET
from ten31portal import config
from ten31portal.config import (
INSECURE_SESSION_SECRETS, SESSION_COOKIE_SECURE, SESSION_MAX_AGE, SESSION_SECRET,
)
from ten31portal.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_router
@@ -27,14 +30,53 @@ from ten31portal.routers.capital_import_router import router as capital_import_r
@asynccontextmanager
async def lifespan(app: FastAPI):
# Refuse to boot on a publicly-known session secret: with one, anyone can forge a session
# cookie for any user id and bypass authentication. start.sh generates a real secret; this
# is the backstop for a misconfigured or bypassed entrypoint.
if SESSION_SECRET in INSECURE_SESSION_SECRETS or len(SESSION_SECRET) < 32:
raise RuntimeError(
"TEN31_SESSION_SECRET is unset, a known default, or too short. Set it to a random "
"value of at least 32 characters before starting the portal."
)
run_migrations()
from ten31portal.storage import ensure_docs_dir
ensure_docs_dir()
yield
# Security headers applied to every response. The SPA loads only same-origin hashed assets and
# renders the TOTP QR as a data: image, so this CSP does not need to loosen script-src.
_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": (
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; "
"img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; "
"connect-src 'self'; font-src 'self' data:; form-action 'self'"
),
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
for name, value in _SECURITY_HEADERS.items():
response.headers.setdefault(name, value)
return response
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
https_only=SESSION_COOKIE_SECURE,
same_site="lax",
max_age=SESSION_MAX_AGE,
)
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
# tiny payloads (health checks, small JSON) where compression isn't worth it.
@@ -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"},
)
+6 -2
View File
@@ -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(
+65 -7
View File
@@ -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)
+256
View File
@@ -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