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,283 @@
|
||||
# Ten31Portal pre-release security audit
|
||||
|
||||
## Remediation status (2026-08-28)
|
||||
|
||||
Fixed and covered by tests in `backend/tests/test_security_hardening.py` (76 tests pass):
|
||||
|
||||
- **#1 Cross-tenant investor takeover (Critical)** — fixed. A shared `check_target_investor_scope`
|
||||
now gates every write that could mint an access grant or a statement for an arbitrary investor:
|
||||
`grant_access`, `create_statement`, `set_partner_exited`, and the import "match" action. An
|
||||
external Administrator can only ever act on an investor already inside their funds.
|
||||
- **#2 Internal role/is_active mass-assignment (Critical)** — fixed. `_enforce_privileged_fields`
|
||||
blocks self-promotion, minting or tampering with approver accounts by non-approvers, and any
|
||||
change to the Service Admin's role or status.
|
||||
- **#3 Shared default password bypass (Critical)** — fixed. `must_change_password` is now enforced
|
||||
server-side: such an account may reach only `me`, `change-password`, and `logout` until it
|
||||
rotates. (The shared default is now safe; moving to per-investor one-time secrets is still worth
|
||||
doing and is left as a follow-up.)
|
||||
- **#4 Global login-throttle DoS (High)** — fixed. `start.sh` runs uvicorn with `--proxy-headers`
|
||||
so the limiter keys on the real client IP behind the platform proxy. Depends on the StartOS
|
||||
proxy forwarding `X-Forwarded-For`; verify on the box.
|
||||
- **#5 Session-secret fallback (High)** — fixed. The app refuses to boot on a known/short secret;
|
||||
the `start.sh` placeholder list now matches `config.py`.
|
||||
- **#6 Cookie hardening (Medium)** — partly fixed. `Secure` (via `TEN31_SESSION_COOKIE_SECURE`,
|
||||
on in the image), `SameSite=lax`, and a 12-hour max-age are set. Server-side session revocation
|
||||
is still a follow-up (sessions remain stateless signed cookies).
|
||||
- **#7 Security headers (Medium)** — fixed. CSP, `X-Frame-Options: DENY`, `nosniff`,
|
||||
`Referrer-Policy`, and HSTS are set on every response; CSP verified against the built SPA with no
|
||||
violations.
|
||||
- **#9 Import-preview cross-fund leak (Medium)** — fixed. External Administrators must name a fund
|
||||
and only see matches against investors in their scope.
|
||||
- **P3 quick wins** — fixed: `Cache-Control: no-store` on document downloads; `nosniff` (via #7).
|
||||
|
||||
Still outstanding (documented below, not yet coded): **#8** XLSX zip-bomb / dimension memory
|
||||
exhaustion and unthrottled argon2 on change-password / TOTP-disable; the money-as-float / Decimal
|
||||
and range-validation cleanup; TOTP secret-at-rest encryption and replay protection; list-endpoint
|
||||
pagination. These are Medium/Low and were left as a reviewed backlog rather than rushed in.
|
||||
|
||||
---
|
||||
|
||||
# Original findings
|
||||
|
||||
Date: 2026-08-28. Method: 10-agent adversarial swarm (one finder plus one independent
|
||||
verifier per attack surface), followed by hand re-verification of every Critical and High
|
||||
finding against the source. Overlapping findings are deduped below. Line numbers are from the
|
||||
audited tree.
|
||||
|
||||
Scope reviewed: backend routers, auth/session/2FA, file storage and document serving,
|
||||
CSV/XLSX import, money handling, rate limiting, StartOS deploy, and the React frontend.
|
||||
|
||||
## The short version
|
||||
|
||||
The app is written defensively (argon2, timing-safe login, path-traversal guards, upload
|
||||
caps, privilege-dropping container). The real exposure is in **authorization logic**, not
|
||||
memory-safety or injection. Three issues would let one account reach another account's money
|
||||
and documents, or let one staff member seize the whole system. Fix the three P0 items before
|
||||
any investor logs in. The P1/P2 items should follow before or immediately after launch.
|
||||
|
||||
| # | Severity | Issue | Core location |
|
||||
|---|----------|-------|---------------|
|
||||
| 1 | Critical | A fund administrator can take over any investor account in any fund | `user_router.py:144,157,522` |
|
||||
| 2 | Critical | operations/cfo can self-promote to approver and disable other admins | `user_router.py:405-457,362-402`; `schemas.py:79-80` |
|
||||
| 3 | Critical | Shared default investor password, forced-change enforced only in the browser | `config.py:17`; `capital_import_router.py:324-335`; `auth_router.py:85-98` |
|
||||
| 4 | High | Login throttle is effectively global behind the proxy: 10 bad tries locks out everyone | `auth_router.py:33,49`; `ratelimit.py` |
|
||||
| 5 | High | Session signing key silently falls back to a published constant | `config.py:7`; `start.sh:5`; `Dockerfile:44` |
|
||||
| 6 | Medium | Signed-cookie sessions cannot be revoked; no Secure flag; up to 14-day life | `main.py:37`; `auth_router.py:144` |
|
||||
| 7 | Medium | No security headers (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS) | `main.py:36-52` |
|
||||
| 8 | Medium | Malicious XLSX (zip bomb / huge declared dimension) exhausts the single worker | `import_router.py:72-83`; `capital_import_router.py:230-239` |
|
||||
| 9 | Medium | Import preview leaks investor usernames across funds when entity_id is omitted | `capital_import_router.py:221-267` |
|
||||
| 10 | Low set | Money-as-float, doc caching, latent stored XSS, self-approval, dev-server entrypoint, etc. | see below |
|
||||
|
||||
---
|
||||
|
||||
## P0 - fix before release
|
||||
|
||||
### 1. Cross-tenant investor account takeover by a fund administrator (Critical)
|
||||
|
||||
`fund_administrator` is an external, entity-scoped, semi-trusted third party. It should never
|
||||
reach an investor outside its own funds, and never seize an investor login. It can do both.
|
||||
|
||||
The scope model has a gap: several write endpoints check that the *entity* is in the caller's
|
||||
grants, but not that the *target investor* is already in scope. Because "in scope" is derived
|
||||
from `EntityAccess` and `CapitalAccountStatement` rows (`_visible_investor_ids`,
|
||||
`user_router.py:27-40`), any endpoint that *creates* one of those rows for an arbitrary
|
||||
investor pulls that investor into the attacker's scope. After that, the stricter checks pass.
|
||||
|
||||
Exploit chain:
|
||||
|
||||
1. Attacker is any `fund_administrator`, holding a grant on their own entity `E`.
|
||||
2. `PUT /api/users/{victim_id}/access/{E}` (`grant_access`, `user_router.py:137`). It calls
|
||||
only `check_administrator_scope(admin, E)` (entity check) and requires the target be an
|
||||
investor. It creates `EntityAccess(victim_id, E)` for any investor id, enumerable.
|
||||
3. The victim is now returned by `_visible_investor_ids`, so they are "in scope."
|
||||
4. `POST /api/users/{victim_id}/reset-password` (`reset_password`, `user_router.py:512`). Its
|
||||
`_check_target_in_scope` now passes. It sets `password_hash` to an attacker-chosen value,
|
||||
`login_enabled=True`, `must_change_password=False` (`user_router.py:523-526`).
|
||||
5. Attacker logs in as the victim. Unless the victim self-enrolled TOTP, this is full takeover:
|
||||
their capital account, statements, and private documents (K-1s).
|
||||
|
||||
The same "mint a scope-granting row for an arbitrary investor" gap exists at three more
|
||||
injection points, so fixing only `grant_access` is not enough:
|
||||
|
||||
- `create_statement` (`capital_account_router.py:126-173`): writes a `CapitalAccountStatement`
|
||||
for any investor id on the caller's entity, both fabricating capital figures under the
|
||||
victim's name and bringing them into scope.
|
||||
- `set_partner_exited` (`entity_router.py:162-197`): creates an `EntityAccess` row for an
|
||||
arbitrary investor when marking an exit.
|
||||
- `commit_import` "match" (`capital_import_router.py:299,339-382`): binds an arbitrary existing
|
||||
investor to the caller's fund.
|
||||
|
||||
Fix: in every one of these endpoints, when `accessible_entity_ids(admin)` is not None, require
|
||||
the target investor to already be within scope before writing (call `_check_target_in_scope`,
|
||||
or verify membership in `_visible_investor_ids`). The clean invariant: an external
|
||||
Administrator can only ever act on an investor whose footprint is already a subset of their
|
||||
grants, exactly the check `delete_user` already performs at `user_router.py:554-567`. Consider
|
||||
also decoupling "reset password" from "is currently visible" so a freshly minted grant cannot
|
||||
immediately unlock a credential reset.
|
||||
|
||||
### 2. Internal privilege escalation via mass-assignment (Critical)
|
||||
|
||||
`update_user` (`PATCH /api/users/{id}`, `user_router.py:405`) and `create_user`
|
||||
(`POST /api/users`, `user_router.py:362`) are gated by `require_admin`, which includes
|
||||
`operations` and `cfo`. `UserUpdate` exposes `role` and `is_active` as client-settable fields
|
||||
(`schemas.py:79-80`); `UserCreate` exposes `role` (`schemas.py:70`).
|
||||
|
||||
The only role guard sits inside `if scope is not None` (`user_router.py:421-424`). For every
|
||||
internal role `accessible_entity_ids` returns None (`auth.py:71`), so that block is skipped and
|
||||
execution falls through to `setattr(user, key, val)` (`user_router.py:445-446`), writing `role`
|
||||
and `is_active` verbatim. There is no self-edit guard and no `is_service_admin` guard on this
|
||||
path (unlike `delete_user`).
|
||||
|
||||
Consequences for any authenticated `operations` or `cfo` account:
|
||||
|
||||
- `PATCH /api/users/{own_id}` with `{"role":"approver"}` grants final valuation sign-off, the
|
||||
one authority `operations` is explicitly denied (`models.py:16-17`, `round_router.py:182`).
|
||||
Segregation of duties collapses.
|
||||
- `POST /api/users` with `{"role":"approver", ...}` mints brand-new approver accounts.
|
||||
- `PATCH` with `{"is_active":false}` on any approver or the Service Admin locks them out.
|
||||
|
||||
Fix: strip `role` and `is_active` from the client-settable set for everyone except an explicit
|
||||
role-management path guarded by `require_approver`; forbid self-role-change and any change to
|
||||
`is_service_admin` accounts; treat `UserUpdate`/`UserCreate` role changes as a privileged
|
||||
operation, not a field write.
|
||||
|
||||
### 3. Shared default investor password, forced change enforced only in the browser (Critical)
|
||||
|
||||
Imported and CLI-created investor accounts are minted with `login_enabled=True` and
|
||||
`password_hash = hash(DEFAULT_INVESTOR_PASSWORD)` where `DEFAULT_INVESTOR_PASSWORD = "Ten31Portal"`
|
||||
(`config.py:17`, `capital_import_router.py:324-335`). `must_change_password` is set True, but
|
||||
nothing on the server enforces it: `login` issues a full authenticated session regardless
|
||||
(`auth_router.py:85-98`), and no dependency in `auth.py` blocks API calls when the flag is set.
|
||||
The forced-change screen exists only in the React client (`frontend/src/App.tsx`).
|
||||
|
||||
So during the window between account creation and the investor's first manual password change,
|
||||
anyone who knows or guesses an investor's username or email (investor emails are frequently
|
||||
known) can log in with the constant `"Ten31Portal"` and, by calling the API directly, skip the
|
||||
change-password screen entirely and read that investor's capital account and documents.
|
||||
|
||||
Fix: enforce `must_change_password` server-side. A user with the flag set should get a session
|
||||
that is allowed to hit only `change-password`, `me`, and `logout`, and be 403'd everywhere else
|
||||
until they rotate. Better still, do not set a shared password at all: generate a unique random
|
||||
initial secret per investor (as `start.sh` already does for the admin) and surface it once to
|
||||
the operator, or drive onboarding through a one-time invite token instead of a standing default.
|
||||
|
||||
---
|
||||
|
||||
## P1 - fix before or right at launch
|
||||
|
||||
### 4. Login throttle is effectively global behind the proxy (High, availability)
|
||||
|
||||
The limiter is keyed on `request.client.host` (`auth_router.py:49,108`). StartOS serves the
|
||||
service behind its own reverse proxy and the app does not parse forwarded headers, so
|
||||
`request.client.host` is the proxy's single address for every user. The limiter
|
||||
(`SlidingWindowLimiter(max_attempts=10, window=300)`) therefore behaves as one global bucket.
|
||||
|
||||
Impact: any unauthenticated visitor can send 10 bad logins and lock out *every* user for five
|
||||
minutes, repeatable indefinitely, a trivial login denial-of-service against the whole firm. The
|
||||
same coupling means real per-source throttling is impossible.
|
||||
|
||||
Fix: terminate identity at the proxy and pass a trusted client IP (uvicorn `--proxy-headers`
|
||||
with a locked-down trusted-hosts set), then key the limiter on that. Add a separate per-account
|
||||
failure counter with backoff (not a hard lockout by name) so one noisy source cannot deny
|
||||
everyone, and apply the same throttle to `login/verify-totp` per account.
|
||||
|
||||
### 5. Session signing key can silently fall back to a published constant (High)
|
||||
|
||||
`config.SESSION_SECRET` defaults to the literal `"change-me-in-production"` (`config.py:7`).
|
||||
`start.sh` only replaces it when the value is exactly `"change-me"` (`start.sh:5`), the sentinel
|
||||
the Dockerfile sets (`Dockerfile:44`). The two sentinels do not match. If the service is ever
|
||||
run with `TEN31_SESSION_SECRET` unset, empty, or set to anything other than `"change-me"`
|
||||
(a different entrypoint, a compose override, a future packaging change), the guard does not
|
||||
fire and the app boots on the hardcoded, source-visible key. Anyone can then forge a valid
|
||||
session cookie for any user id and bypass authentication entirely.
|
||||
|
||||
Fix: make the app refuse to start when `SESSION_SECRET` is the known default or shorter than a
|
||||
safe length, rather than silently accepting it. Align the `start.sh` sentinel with the config
|
||||
default (or generate unconditionally when it matches either sentinel).
|
||||
|
||||
---
|
||||
|
||||
## P2 - fix soon after launch
|
||||
|
||||
### 6. Signed-cookie sessions cannot be revoked; cookie hardening (Medium)
|
||||
|
||||
`SessionMiddleware` stores the session in a client-side signed cookie (`main.py:37`), so the
|
||||
SPEC's "server-side sessions" claim does not hold. There is no server-side session store and no
|
||||
revocation: `logout` (`auth_router.py:144`) only clears the client's copy, and password change
|
||||
or TOTP disable do not invalidate an already-stolen cookie. With the Starlette default max-age
|
||||
(14 days), a captured cookie stays valid for up to two weeks. The cookie is also added without
|
||||
`https_only=True`, so it has no `Secure` flag and can leak over any plaintext hop. Deactivating
|
||||
a user is honored on each request (`auth.py:33`), which is the one saving grace.
|
||||
|
||||
Fix: pass `https_only=True` and a shorter `max_age` to `SessionMiddleware`. For real
|
||||
revocation, add a server-side session table or a per-user `session_epoch` that increments on
|
||||
logout, password change, and TOTP disable, and reject cookies minted before it.
|
||||
|
||||
### 7. Missing HTTP security headers (Medium)
|
||||
|
||||
No CSP, `X-Frame-Options`/frame-ancestors (clickjacking), `X-Content-Type-Options: nosniff`,
|
||||
`Referrer-Policy`, or HSTS are set anywhere (`main.py:36-52`, SPA served via bare
|
||||
`FileResponse`). Fix: add a small middleware that sets these on every response; nosniff also
|
||||
hardens the document-download path against content sniffing (see item 10).
|
||||
|
||||
### 8. XLSX decompression / dimension memory exhaustion (Medium, availability)
|
||||
|
||||
The 50 MB upload cap (`storage.py`) bounds bytes on the wire, but a small crafted `.xlsx`
|
||||
(zip bomb, or a tiny sheet declaring an enormous used range) can expand to gigabytes when
|
||||
parsed (`import_router.py:72-83,145`; `capital_import_router.py:230-239`). On the single-worker
|
||||
deployment this takes the portal down. Related: `change-password` and `totp-disable` run an
|
||||
unthrottled argon2 verify (`auth_router.py`), a cheap way to pin CPU. Fix: parse with
|
||||
read-only/streaming mode and cap row/column counts and decompressed size; throttle the argon2
|
||||
endpoints.
|
||||
|
||||
### 9. Import-preview cross-fund username disclosure (Medium)
|
||||
|
||||
The capital-import preview path skips the administrator scope check when `entity_id` is omitted
|
||||
(`capital_import_router.py:221-267`), letting an external Administrator confirm whether given
|
||||
investor usernames exist in other funds. Related lower-severity leak: `get_user` returns an
|
||||
investor's full cross-fund entity ids and linked-account names to an in-scope external
|
||||
Administrator (`user_router.py:285-308`). Fix: require and enforce entity scope on preview;
|
||||
filter `_entity_ids_for` and `linked_accounts` to the caller's scope.
|
||||
|
||||
---
|
||||
|
||||
## P3 - hardening backlog (Low / defense-in-depth)
|
||||
|
||||
- **Document downloads lack `Cache-Control: no-store`** (`document_router.py:153`): K-1s and
|
||||
statements can linger in shared-machine browser caches.
|
||||
- **Uploaded Content-Type is stored unvalidated and echoed on download**
|
||||
(`document_router.py:120,155`; `storage.py:47`): currently defused because `FileResponse`
|
||||
forces `Content-Disposition: attachment`. Keep the attachment disposition, add `nosniff`, and
|
||||
consider allow-listing upload types so this stays latent.
|
||||
- **Money handled as float**: `round(dollars*100)` instead of integer/Decimal across
|
||||
`capital_account_router.py:22-23`, `position_router.py:19-21`, `import_router.py:50,65-67`.
|
||||
No sign/range validation on `value_cents`/cents inputs (`schemas.py:202,255-263`), `abs()`
|
||||
forces distributions positive and flips legitimate negative reversals
|
||||
(`capital_import_router.py:158`), and NaN/Infinity floats can reach `round()` and persist.
|
||||
Validate sign/range and parse money with `Decimal`.
|
||||
- **No maker-checker on valuation sign-off** (`round_router.py:179-202`): the same user can
|
||||
submit and approve. Enforce a different approver.
|
||||
- **Packaged `ten31portal` console entrypoint runs `uvicorn --reload` on 0.0.0.0**
|
||||
(`main.py:103-105`, `pyproject.toml:28-29`). Confirm production uses `start.sh` (it does) and
|
||||
drop or guard the reload entrypoint so it cannot be the prod launch by accident.
|
||||
- **TOTP**: secret stored in plaintext (`models.py:86`); codes replayable within the validation
|
||||
window (no last-used timestep, `totp.py:37-39`); brute-force throttle is global-per-IP only,
|
||||
no per-account cap. Encrypt the secret at rest, record and reject the last-used step, add a
|
||||
per-account 2FA counter.
|
||||
- **Upload size enforced only after Starlette buffers the whole multipart body**
|
||||
(`storage.py:40-63`): add a global request-size limit at the proxy.
|
||||
- **Unbounded, un-paginated list endpoints with N+1 queries**
|
||||
(`document_router.py:48-80`, `entity_router.py:39-112`): paginate and batch.
|
||||
|
||||
---
|
||||
|
||||
## Method note and honest caveats
|
||||
|
||||
The swarm's automated verifier confirmed all 38 raw findings and refuted none, which is weaker
|
||||
skepticism than ideal. Because of that, every Critical and High item above was re-checked by
|
||||
hand against the source before being written here, and the raw findings were deduped (the
|
||||
default-password issue, cookie flags, security headers, session-secret fallback, and
|
||||
money-as-float each surfaced from multiple angles and are listed once). The P3 items are lower
|
||||
confidence and lower impact; treat them as a backlog, not blockers. This review is static
|
||||
analysis of the code; it is not a penetration test against a running instance, and it does not
|
||||
cover the StartOS platform's own network exposure or the reverse proxy's TLS configuration.
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -42,6 +42,8 @@ RUN groupadd --gid 10001 appuser \
|
||||
ENV TEN31_DB_PATH=/data/portal.db
|
||||
ENV TEN31_DOCS_DIR=/data/documents
|
||||
ENV TEN31_SESSION_SECRET=change-me
|
||||
# Served behind the platform's TLS-terminating proxy, so the session cookie is Secure.
|
||||
ENV TEN31_SESSION_COOKIE_SECURE=true
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
+26
-12
@@ -1,16 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Generate session secret if not set or default
|
||||
if [ "$TEN31_SESSION_SECRET" = "change-me" ]; then
|
||||
if [ -f /data/.session-secret ]; then
|
||||
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
||||
else
|
||||
export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
echo "$TEN31_SESSION_SECRET" > /data/.session-secret
|
||||
chmod 600 /data/.session-secret
|
||||
fi
|
||||
fi
|
||||
# Generate a session secret unless the operator supplied a real one. Any unset/empty value or a
|
||||
# known placeholder counts as "not set" — the app also refuses to boot on these (see
|
||||
# main.lifespan), so the two checks must agree on the full placeholder list.
|
||||
case "$TEN31_SESSION_SECRET" in
|
||||
""|change-me|change-me-in-production)
|
||||
if [ -f /data/.session-secret ]; then
|
||||
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
||||
else
|
||||
export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
echo "$TEN31_SESSION_SECRET" > /data/.session-secret
|
||||
chmod 600 /data/.session-secret
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# The platform terminates TLS and reverse-proxies to us, so the session cookie is served over
|
||||
# HTTPS to the client: mark it Secure. Default on here; overridable for a plain-HTTP setup.
|
||||
export TEN31_SESSION_COOKIE_SECURE="${TEN31_SESSION_COOKIE_SECURE:-true}"
|
||||
|
||||
# Create first approver on first boot if no users exist
|
||||
if [ ! -f /data/.initialized ]; then
|
||||
@@ -58,10 +66,16 @@ LOG_LEVEL="${TEN31_LOG_LEVEL:-info}"
|
||||
# Hand off to the server as an unprivileged user. The platform mounts /data owned by root, so
|
||||
# (while still root) we take ownership of the data volume first, then drop privileges with
|
||||
# setpriv — the long-running server process is never root, limiting what a compromise can reach.
|
||||
# --proxy-headers lets uvicorn read X-Forwarded-For from the platform's reverse proxy so the
|
||||
# login rate limiter keys on the real client IP, not the single proxy address (otherwise one
|
||||
# source's failures throttle every user). The container port is only reachable via that proxy,
|
||||
# so trusting forwarded headers from any upstream is safe here.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R 10001:10001 /data
|
||||
exec setpriv --reuid=10001 --regid=10001 --clear-groups \
|
||||
uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL"
|
||||
uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" \
|
||||
--proxy-headers --forwarded-allow-ips="*"
|
||||
else
|
||||
exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL"
|
||||
exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" \
|
||||
--proxy-headers --forwarded-allow-ips="*"
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { v_0_2_45 as current } from './v_0_2_45'
|
||||
export { v_0_2_46 as current } from './v_0_2_46'
|
||||
import { v_0_1_0 } from './v_0_1_0'
|
||||
import { v_0_2_45 } from './v_0_2_45'
|
||||
import { v_0_2_40 } from './v_0_2_40'
|
||||
import { v_0_2_41 } from './v_0_2_41'
|
||||
import { v_0_2_42 } from './v_0_2_42'
|
||||
@@ -44,4 +45,4 @@ import { v_0_2_36 } from './v_0_2_36'
|
||||
import { v_0_2_37 } from './v_0_2_37'
|
||||
import { v_0_2_38 } from './v_0_2_38'
|
||||
import { v_0_2_39 } from './v_0_2_39'
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44]
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44, v_0_2_45]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_46 = VersionInfo.of({
|
||||
version: '0.2.46:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Security hardening ahead of the investor release. External Administrators can now only ' +
|
||||
'act on investors already in their own funds (closing a cross-fund account-takeover path). ' +
|
||||
'Role and active-status changes are restricted so staff cannot escalate their own ' +
|
||||
'permissions or alter the Service Admin. The forced first-login password change is now ' +
|
||||
'enforced by the server, not just the browser. Session cookies are marked Secure with a ' +
|
||||
'shorter lifetime, standard security headers are set on every response, and login ' +
|
||||
'throttling now sees the real client address behind the platform proxy.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// Bumped each release so the running build is visible in the UI.
|
||||
// If the number shown in the app doesn't match the installed s9pk version,
|
||||
// the new frontend isn't actually being served.
|
||||
export const APP_VERSION = "0.2.45";
|
||||
export const APP_VERSION = "0.2.46";
|
||||
|
||||
Reference in New Issue
Block a user