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 sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.database import get_session
|
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()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
@@ -24,6 +26,16 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
return False
|
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:
|
def get_current_user(request: Request, session: Session = Depends(get_session)) -> User:
|
||||||
"""FastAPI dependency: extract user from session cookie."""
|
"""FastAPI dependency: extract user from session cookie."""
|
||||||
user_id = request.session.get("user_id")
|
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)
|
user = session.get(User, user_id)
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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
|
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):
|
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")
|
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")
|
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_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")
|
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
|
# 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
|
# 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 contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import FileResponse
|
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.db_init import run_migrations
|
||||||
from ten31portal.routers.auth_router import router as auth_router
|
from ten31portal.routers.auth_router import router as auth_router
|
||||||
from ten31portal.routers.audit_router import router as audit_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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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()
|
run_migrations()
|
||||||
from ten31portal.storage import ensure_docs_dir
|
from ten31portal.storage import ensure_docs_dir
|
||||||
ensure_docs_dir()
|
ensure_docs_dir()
|
||||||
yield
|
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 = 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
|
# 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
|
# 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.
|
# 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.audit import record_audit
|
||||||
from ten31portal.auth import (
|
from ten31portal.auth import (
|
||||||
accessible_entity_ids, check_administrator_scope, get_current_user,
|
accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
|
||||||
household_user_ids, require_admin,
|
get_current_user, household_user_ids, require_admin,
|
||||||
)
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
@@ -135,6 +135,9 @@ def create_statement(
|
|||||||
investor = session.get(User, body.investor_user_id)
|
investor = session.get(User, body.investor_user_id)
|
||||||
if investor is None or investor.role != UserRole.investor:
|
if investor is None or investor.role != UserRole.investor:
|
||||||
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account")
|
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(
|
existing = session.exec(
|
||||||
select(CapitalAccountStatement).where(
|
select(CapitalAccountStatement).where(
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from ten31portal import config, storage
|
from ten31portal import config, storage
|
||||||
from ten31portal.audit import record_audit
|
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.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
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:
|
if entity_id is not None and session.get(Entity, entity_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
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:
|
try:
|
||||||
file_bytes = storage.read_capped(file)
|
file_bytes = storage.read_capped(file)
|
||||||
except storage.UploadTooLarge as exc:
|
except storage.UploadTooLarge as exc:
|
||||||
@@ -239,6 +251,8 @@ def preview_import(
|
|||||||
wb = _open_workbook(file_bytes, password)
|
wb = _open_workbook(file_bytes, password)
|
||||||
|
|
||||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
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_name = {u.name.strip().lower(): u for u in investors}
|
||||||
by_username = {u.username.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}
|
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
|
user = session.get(User, inv.user_id) if inv.user_id else None
|
||||||
if user is None or user.role != UserRole.investor:
|
if user is None or user.role != UserRole.investor:
|
||||||
raise HTTPException(status_code=400, detail="match requires a valid investor user_id.")
|
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:
|
if inv.external_id and not user.external_investor_id:
|
||||||
user.external_investor_id = inv.external_id
|
user.external_investor_id = inv.external_id
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ def download_document(
|
|||||||
path,
|
path,
|
||||||
media_type=doc.content_type,
|
media_type=doc.content_type,
|
||||||
filename=doc.original_filename,
|
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.audit import record_audit
|
||||||
from ten31portal.auth import (
|
from ten31portal.auth import (
|
||||||
accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids,
|
accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
|
||||||
require_entity_writer, require_internal, require_internal_or_administrator, require_writer,
|
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.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
@@ -178,6 +179,9 @@ def set_partner_exited(
|
|||||||
member = session.get(User, user_id)
|
member = session.get(User, user_id)
|
||||||
if member is None or member.role != UserRole.investor:
|
if member is None or member.role != UserRole.investor:
|
||||||
raise HTTPException(status_code=400, detail="Only investor members can be marked exited")
|
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(
|
access = session.exec(
|
||||||
select(EntityAccess).where(
|
select(EntityAccess).where(
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from sqlmodel import Session, select, col
|
|||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import (
|
from ten31portal.auth import (
|
||||||
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
|
accessible_entity_ids, can_access_entity, check_administrator_scope,
|
||||||
hash_password, household_user_ids, require_admin, require_internal_admin, require_role,
|
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.database import get_session
|
||||||
from ten31portal.models import (
|
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:
|
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
|
"""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
|
Administrator's entities. Keeps an external Administrator away from staff accounts and
|
||||||
from other funds' investors entirely."""
|
from other funds' investors entirely. Delegates to the shared helper so the scope
|
||||||
scope = accessible_entity_ids(admin, session)
|
definition can never drift between routers."""
|
||||||
if scope is None:
|
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
|
return
|
||||||
if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session):
|
check_target_investor_scope(admin, target.id, session)
|
||||||
raise HTTPException(status_code=403, detail="No access to this account")
|
|
||||||
|
|
||||||
|
|
||||||
def delete_user_cascade(session: Session, user: User) -> list[str]:
|
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
|
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")
|
@router.get("/access-matrix")
|
||||||
def access_matrix(
|
def access_matrix(
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
@@ -147,6 +196,10 @@ def grant_access(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if admin.role == UserRole.fund_administrator and user.role != UserRole.investor:
|
if admin.role == UserRole.fund_administrator and user.role != UserRole.investor:
|
||||||
raise HTTPException(status_code=403, detail="Administrators manage investor accounts only")
|
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:
|
if session.get(Entity, entity_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
existing = session.exec(
|
existing = session.exec(
|
||||||
@@ -379,6 +432,8 @@ def create_user(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Select at least one of your funds for this investor"
|
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():
|
if session.exec(select(User).where(User.username == body.username)).first():
|
||||||
raise HTTPException(status_code=409, detail="Username already taken")
|
raise HTTPException(status_code=409, detail="Username already taken")
|
||||||
if body.email and session.exec(select(User).where(User.email == body.email)).first():
|
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:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
_check_target_in_scope(admin, user, session)
|
_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)
|
changes = body.model_dump(exclude_unset=True)
|
||||||
entity_ids = changes.pop("entity_ids", None)
|
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_DB_PATH=/data/portal.db
|
||||||
ENV TEN31_DOCS_DIR=/data/documents
|
ENV TEN31_DOCS_DIR=/data/documents
|
||||||
ENV TEN31_SESSION_SECRET=change-me
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
+26
-12
@@ -1,16 +1,24 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Generate session secret if not set or default
|
# Generate a session secret unless the operator supplied a real one. Any unset/empty value or a
|
||||||
if [ "$TEN31_SESSION_SECRET" = "change-me" ]; then
|
# known placeholder counts as "not set" — the app also refuses to boot on these (see
|
||||||
if [ -f /data/.session-secret ]; then
|
# main.lifespan), so the two checks must agree on the full placeholder list.
|
||||||
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
case "$TEN31_SESSION_SECRET" in
|
||||||
else
|
""|change-me|change-me-in-production)
|
||||||
export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
if [ -f /data/.session-secret ]; then
|
||||||
echo "$TEN31_SESSION_SECRET" > /data/.session-secret
|
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
||||||
chmod 600 /data/.session-secret
|
else
|
||||||
fi
|
export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
fi
|
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
|
# Create first approver on first boot if no users exist
|
||||||
if [ ! -f /data/.initialized ]; then
|
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
|
# 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
|
# (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.
|
# 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
|
if [ "$(id -u)" = "0" ]; then
|
||||||
chown -R 10001:10001 /data
|
chown -R 10001:10001 /data
|
||||||
exec setpriv --reuid=10001 --regid=10001 --clear-groups \
|
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
|
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
|
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_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_40 } from './v_0_2_40'
|
||||||
import { v_0_2_41 } from './v_0_2_41'
|
import { v_0_2_41 } from './v_0_2_41'
|
||||||
import { v_0_2_42 } from './v_0_2_42'
|
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_37 } from './v_0_2_37'
|
||||||
import { v_0_2_38 } from './v_0_2_38'
|
import { v_0_2_38 } from './v_0_2_38'
|
||||||
import { v_0_2_39 } from './v_0_2_39'
|
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.
|
// 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,
|
// If the number shown in the app doesn't match the installed s9pk version,
|
||||||
// the new frontend isn't actually being served.
|
// 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