Compare commits
28
Commits
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"cwd": "frontend",
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# The image build installs its own deps (npm ci, pip install) inside the stages, so none of
|
||||||
|
# these belong in the build context. Keeps the context small and the build fast.
|
||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
**/.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.mypy_cache
|
||||||
|
backend/tests
|
||||||
|
backend/static
|
||||||
|
deploy/*.s9pk
|
||||||
|
deploy/javascript
|
||||||
|
*.xlsx
|
||||||
|
SECURITY-AUDIT.md
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
+16
@@ -10,6 +10,9 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
# Built frontend served by backend (produced by the deploy build)
|
||||||
|
backend/static/
|
||||||
|
|
||||||
# DB
|
# DB
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
@@ -17,6 +20,19 @@ frontend/dist/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Packaged service artifacts
|
||||||
|
*.s9pk
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
deploy/node_modules/
|
deploy/node_modules/
|
||||||
deploy/javascript/
|
deploy/javascript/
|
||||||
|
|
||||||
|
# Confidential fund-admin spreadsheets (never commit)
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
|
||||||
|
# start-cli signing key + local config (never commit)
|
||||||
|
deploy/.startos/
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Ten31Portal
|
||||||
|
|
||||||
|
Ten31's internal system of record for entities, holdings, positions, valuations,
|
||||||
|
investor capital accounts, and documents. The backend is FastAPI, SQLModel, and
|
||||||
|
SQLite. The frontend is React and Vite. `deploy/` packages the application for
|
||||||
|
StartOS.
|
||||||
|
|
||||||
|
## Structure and checks
|
||||||
|
|
||||||
|
- `backend/ten31portal/`: API, models, importers, storage, authentication, and CLI.
|
||||||
|
- `backend/tests/`: pytest coverage for permissions, imports, NAV, and rollups.
|
||||||
|
- `frontend/src/`: internal and investor-facing interfaces.
|
||||||
|
- `deploy/`: StartOS package, migrations, and service image build.
|
||||||
|
- Backend tests: `cd backend && python -m pytest`.
|
||||||
|
- Frontend checks: `cd frontend && npm run lint && npm run build`.
|
||||||
|
- StartOS type check: `cd deploy && npm run check`.
|
||||||
|
|
||||||
|
## Durable gotchas
|
||||||
|
|
||||||
|
- This repo handles confidential fund and investor information. Real databases,
|
||||||
|
uploaded documents, credentials, and source spreadsheets never enter git.
|
||||||
|
- Financial data changes on the live service use application code and migrations,
|
||||||
|
followed by a sideload. Never edit the live SQLite database over SSH.
|
||||||
|
- Bump the StartOS version or revision before every package build. Keep the
|
||||||
|
current version module and `deploy/package.json` aligned.
|
||||||
|
- `frontend/src/version.ts` is a separate visible build marker. If it lags the
|
||||||
|
package version, the interface can display an old version even when the newer
|
||||||
|
package is installed.
|
||||||
|
- `ROADMAP.md` contains an old current-state section. This file's `## Current
|
||||||
|
state` is the current snapshot; the roadmap should be pruned before using it as
|
||||||
|
an active work queue.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- Work is on `investor-access`; its existing application history through
|
||||||
|
`34842cd` is on private Gitea, verified with `ls-remote` on 2026-09-04.
|
||||||
|
- The StartOS version is 0.2.48:0 and matches the version recorded live in
|
||||||
|
`../FLEET.md` on 2026-09-02.
|
||||||
|
- Recent work imports credit-fund loans into NAV and supports queued re-imports
|
||||||
|
for already-signed rounds.
|
||||||
|
- `frontend/src/version.ts` still reports 0.2.46 while the package is 0.2.48.
|
||||||
|
- Next: reconcile the visible frontend version marker and stale roadmap status.
|
||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
|
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
|
||||||
|
|
||||||
|
## Accounts and access
|
||||||
|
|
||||||
|
Two kinds of accounts:
|
||||||
|
|
||||||
|
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) — the full back-office app
|
||||||
|
(entities, holdings, valuations, import, audit). `approver` and `cfo` also get the
|
||||||
|
admin screens below.
|
||||||
|
- **External accounts** (`investor`, `fund_administrator`) — a separate, entity-scoped
|
||||||
|
portal. An external account only sees the entities granted to it.
|
||||||
|
- **Investor** — sees, per fund, their latest capital-account value and history, plus
|
||||||
|
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||||
|
- **Fund administrator** — sees assigned entities and can upload documents for them
|
||||||
|
(shared or addressed to a specific investor).
|
||||||
|
|
||||||
|
Admin screens (Users / Documents / Capital Accounts, visible to `approver` and `cfo`)
|
||||||
|
let you create an account with a username and password, check off which entities it can
|
||||||
|
view, upload documents, and enter each investor's capital-account figures.
|
||||||
|
|
||||||
|
Login accepts a username **or** an email. The first admin is created from the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ten31portal-cli create-user --name "You" --username admin --role cfo --password '...'
|
||||||
|
# --email is optional; external accounts are normally created from the Users screen.
|
||||||
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
# Ten31Portal — ROADMAP
|
||||||
|
|
||||||
|
Deferred / parked work, grounded in a read-only survey of the current tree on 2026-07-01 and
|
||||||
|
adjudicated the same day (investigate → debate → judge over each item). 13 items were dropped
|
||||||
|
as not-worth-it and removed; what remains is either scheduled work (DO) with a ready plan, or
|
||||||
|
your decision (ESCALATE / HIGH). Priority key: **P2** = nice-to-have, **P3** = trivial.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
Working internal fund-admin portal (FastAPI + SQLModel + SQLite backend, React/Vite/Tailwind
|
||||||
|
frontend, StartOS 0.4.0 packaging). Phase-1 (entities, holdings, positions, valuation rounds,
|
||||||
|
sign-off, audit, CSV import, four roles) is built. Phase-2 (external investor / fund-admin
|
||||||
|
accounts, capital accounts, documents) is built but largely **uncommitted** in the working
|
||||||
|
tree. No test suite. Single git commit to date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — owner's call (not backlog; surfaced, never auto-judged)
|
||||||
|
|
||||||
|
- **H1. Session secret: backup coverage + `change-me` fallbacks.** `start.sh:5-11` persists a
|
||||||
|
random secret to `/data/.session-secret`, but `backups.ts:4` backs up only the `main`
|
||||||
|
volume — if `/data` is a separate volume, a restore regenerates the secret and silently logs
|
||||||
|
everyone out. Separately, `main.ts:27` and `config.py:7` fall back to a hardcoded `change-me`
|
||||||
|
string if the env var is unset. **Reinforced by D8 below:** adjudication confirmed the secret
|
||||||
|
really is regenerated whenever `/data` is wiped, so the backup-coverage question is live.
|
||||||
|
Needs verification of the volume mapping, then a fix. *(security + data continuity)*
|
||||||
|
- **H2. Login hardening.** `auth_router.py:14` has no rate limiting / lockout on failed logins,
|
||||||
|
and `auth_router.py:60` allows a 4-character password minimum. External-facing attack
|
||||||
|
surface. *(security)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DO — adjudicated worth-doing + low-risk (your go-ahead to schedule)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **B3. Backend test suite** (P2). ~3,500-line financial system of record with zero tests.
|
||||||
|
*Plan:* add pytest/pytest-asyncio/httpx as dev deps in `pyproject.toml` (leave runtime deps);
|
||||||
|
add `backend/tests/` with a `conftest.py` overriding `get_session` (database.py:10) to a fresh
|
||||||
|
in-memory SQLite + a TestClient fixture and an authenticated-user fixture; first tests cover
|
||||||
|
auth (login works; anon + cross-user access rejected), one CRUD router round-trip, and the
|
||||||
|
rollup (entity_router.py:36) asserting correct aggregate numbers. Additive; no prod-code change.
|
||||||
|
- **B4. Document upload size limit** (P2). Uploads stream to disk with no cap, on the same volume
|
||||||
|
as the DB — a runaway upload can fill it and take the portal down. *Plan:* add
|
||||||
|
`MAX_UPLOAD_SIZE` to `config.py` (env `TEN31_MAX_UPLOAD_SIZE`, default 50 MB); in
|
||||||
|
`storage.save_upload` (19-30) check size in the stream loop, `unlink` the partial and raise
|
||||||
|
413; optional early Content-Length check; add an over-limit test.
|
||||||
|
- **B7. Fix audit `detail` type label** (P3). `models.py:154` types `detail` as `str | None` but
|
||||||
|
callers store dicts. *Plan:* change annotation to `dict | list | str | None` to match
|
||||||
|
`schemas.py`; add a one-line comment with examples; note it in `record_audit` (audit.py:17).
|
||||||
|
Documentation/label only — no migration, no call-site changes.
|
||||||
|
- **B10. FK indexes** (P3). ~a dozen linking columns have no indexes; zero indexes anywhere.
|
||||||
|
*Plan:* one additive Alembic migration adding `create_index` on the FK columns
|
||||||
|
(users.primary_account_id, holdings.entity_id, positions.holding_id, valuation_rounds.*,
|
||||||
|
valuations.*, audit_logs.actor_user_id, entity_access.*, documents.*,
|
||||||
|
capital_account_statements.*); mirror with `index=True` on the model Fields; `downgrade`
|
||||||
|
drops them. No query-code change.
|
||||||
|
- **B11. cli.py silent delete errors** (P3). `cli.py:123-126` `except Exception: pass` discards
|
||||||
|
real disk/permission failures and orphans files. *Plan:* capture the exception, print a
|
||||||
|
warning to stderr (matching existing style) with the failing `storage_path`, continue the
|
||||||
|
loop; optionally narrow the catch to `OSError`. No DB/API change.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **F2. AuthContext: network vs auth failure** (P2, medium conf). A momentary connection blip
|
||||||
|
wrongly shows a logged-out login screen. *Plan:* export `ApiError` from `api.ts`; add an
|
||||||
|
`error`/`offline` flag to `AuthState`; change the line-18 bootstrap catch to inspect the error
|
||||||
|
— 401/403 = logged out, network/5xx = offline flag; render a "couldn't reach the server,
|
||||||
|
retrying" banner (with retry) vs. the login screen.
|
||||||
|
- **F4. Import page: add a real confirmation** (P2). One checkbox + Confirm can permanently
|
||||||
|
erase a fund's holdings, positions, and valuation history with no final "are you sure?".
|
||||||
|
*Plan:* in `Import.tsx`, gate the destructive replace (before the commit at ~145-152) behind a
|
||||||
|
confirmation dialog that spells out what's cleared; add per-step progress labels ("Saving
|
||||||
|
members…", "Loading holdings…"). Inputs already persist, so the retry concern is minor.
|
||||||
|
- **F6. AuditLog expandable detail** (P2). The "what changed" column is truncated with no way to
|
||||||
|
read the rest — exactly when the log matters (a dispute). Data's already sent. *Plan:* add an
|
||||||
|
expanded-row state; remove the always-truncate classes / make the row clickable; render a
|
||||||
|
full-width row with pretty-printed JSON in a `<pre>`; add a `title` on the collapsed cell.
|
||||||
|
- **F7. Investments empty-state** (P3, medium conf). An entity with no holdings shows a row of
|
||||||
|
zeros + empty table that reads as "failed to load." *Plan:* in `Investments.tsx`, when
|
||||||
|
`groups.length === 0` render an empty-state block ("No investments recorded yet" + one line of
|
||||||
|
guidance) instead of the table. Display-only.
|
||||||
|
- **F8. Centralize role checks** (P3). Four pages hand-copy role lists; the shared helpers
|
||||||
|
already exist in `api.ts` and just aren't used. *Plan:* add `WRITER_ROLES` + `canEditRound` to
|
||||||
|
`api.ts`; point AuditLog/Import at the existing `isAdmin`; import the shared list in
|
||||||
|
EntitiesList; use `canEditRound` in ValuationWorkflow. Keep every list byte-identical; do NOT
|
||||||
|
merge `fund_admin` (internal) and `fund_administrator` (external) — they are distinct roles.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
- **D2. Commit the version stub files** (P2). `index.ts` is tracked and imports 20 untracked
|
||||||
|
`v_0_2_*.ts` files — committing as-is breaks a fresh checkout. *Plan:* stage the 20 files with
|
||||||
|
the modified `index.ts` and commit together (they carry real release notes); verify a clean
|
||||||
|
checkout builds. The missing `v_0_2_2` is intentional (versions are an unordered set).
|
||||||
|
- **D5. Type-check in build** (P2). A `tsc --noEmit` `check` script exists but never runs; it
|
||||||
|
passes clean today. *Plan:* in `s9pk.mk` (~117) change `npm run build` to
|
||||||
|
`npm run check && npm run build`. One line; identical behavior today, catches future type errors.
|
||||||
|
- **D6. `TEN31_LOG_LEVEL` env var** (P3). `start.sh:41` hardcodes `--log-level info`. *Plan:* add
|
||||||
|
`LOG_LEVEL="${TEN31_LOG_LEVEL:-info}"` near the other env defaults and use `"$LOG_LEVEL"` on
|
||||||
|
line 41 — same generate-or-default pattern already in the file. Default stays `info`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESCALATE — your decision (touches something that matters)
|
||||||
|
|
||||||
|
- **B1. Pagination on list endpoints** (P2, blast radius HIGH). Every list except audit loads all
|
||||||
|
rows, two with an extra query per row — fine now, will slow and time out as data grows.
|
||||||
|
- *Judge's lean:* worth doing, but **split it first** — it's an epic.
|
||||||
|
- *Why it's yours:* changes observable API behaviour across 5 investor-facing endpoints; two
|
||||||
|
lists filter for permissions **in Python after the query**, so a naive page limit could
|
||||||
|
silently return short/wrong lists of capital-account figures. Sequence the mechanical
|
||||||
|
offset/limit pass separately from the two permission-filtered endpoints.
|
||||||
|
- **F5. ValuationWorkflow input validation** (P2, blast radius HIGH). The Value box accepts a
|
||||||
|
negative number and it reaches the DB uncorrected.
|
||||||
|
- *Judge's lean:* fix it, but the robust fix is **not frontend-only**.
|
||||||
|
- *Why it's yours:* `ValuationBulkItem.value_cents` (schemas.py:165) has no `ge=0` and
|
||||||
|
`round_router.py:140` writes it directly — proper fix adds a backend constraint + the three
|
||||||
|
frontend guards, crossing onto a money-write path beyond the item's stated scope.
|
||||||
|
- **D7. Force override of default admin creds** (P3, blast radius HIGH). A normal install ships
|
||||||
|
the top admin account with a password printed in the public setup guide (`admin` / `Ten31`) and
|
||||||
|
nothing forces a change.
|
||||||
|
- *Judge's lean:* worth fixing **now** — external investor/fund-admin accounts are being
|
||||||
|
onboarded, so "I'm the only user" no longer holds.
|
||||||
|
- *Why it's yours:* changes auth on the most powerful account and rewrites first-boot in ~3
|
||||||
|
spots + 2 docs; a careless change could lock you (or a live deploy) out. Suggested direction:
|
||||||
|
reuse the session-secret generate-and-persist pattern to mint a random admin password on
|
||||||
|
first boot and print it once; add a StartOS config field; test an upgrade path.
|
||||||
|
- **D8. Uninstall alert wording** (P3, blast radius LOW — escalated on a corrected fact). The
|
||||||
|
adjudication caught that the original survey premise was wrong: the session secret **is**
|
||||||
|
regenerated whenever `/data` is wiped (`start.sh:5-11`), so the item is valid, not moot. It
|
||||||
|
sits next to the open **H1** backup question. Re-triage together with H1 using the corrected
|
||||||
|
facts rather than as a standalone wording tweak.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""investor access: username, entity access, documents, capital accounts
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 2792c4ff4612
|
||||||
|
Create Date: 2026-06-26 14:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '2792c4ff4612'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# users: add username (login handle), make email optional.
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.alter_column('email', existing_type=sa.String(), nullable=True)
|
||||||
|
|
||||||
|
# Backfill username for any existing internal accounts so the NOT NULL holds.
|
||||||
|
op.execute("UPDATE users SET username = email WHERE username IS NULL")
|
||||||
|
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'username',
|
||||||
|
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_unique_constraint('uq_users_username', ['username'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'entity_access',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'documents',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('investor_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False),
|
||||||
|
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('uploaded_by', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'capital_account_statements',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('investor_user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('as_of_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('beginning_balance_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('contributions_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('distributions_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ending_balance_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('document_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_table('capital_account_statements')
|
||||||
|
op.drop_table('documents')
|
||||||
|
op.drop_table('entity_access')
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('uq_users_username', type_='unique')
|
||||||
|
batch_op.alter_column('email', existing_type=sa.String(), nullable=False)
|
||||||
|
batch_op.drop_column('username')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""btc_prices table + entities.close_date + first-login flow columns on users
|
||||||
|
|
||||||
|
Revision ID: a3b4c5d6e7f8
|
||||||
|
Revises: f2a3b4c5d6e7
|
||||||
|
Create Date: 2026-07-12 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'a3b4c5d6e7f8'
|
||||||
|
down_revision: Union[str, None] = 'f2a3b4c5d6e7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'btc_prices',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('date', sa.Date(), nullable=False, unique=True),
|
||||||
|
sa.Column('price_cents', sa.Integer(), nullable=False),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('close_date', sa.Date(), nullable=True))
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('must_change_password', sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false())
|
||||||
|
)
|
||||||
|
batch_op.add_column(sa.Column('onboarded_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('onboarded_at')
|
||||||
|
batch_op.drop_column('must_change_password')
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('close_date')
|
||||||
|
op.drop_table('btc_prices')
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""add indexes on foreign-key columns
|
||||||
|
|
||||||
|
Indexes the FK columns that are commonly filtered/joined and are not already the left-most
|
||||||
|
column of an existing unique constraint (those are covered by the constraint's index). Names
|
||||||
|
match SQLModel's index=True default (ix_<table>_<column>) so the ORM and DB stay in sync.
|
||||||
|
|
||||||
|
Revision ID: a7b8c9d0e1f2
|
||||||
|
Revises: f6a7b8c9d0e1
|
||||||
|
Create Date: 2026-07-01 09:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'a7b8c9d0e1f2'
|
||||||
|
down_revision: Union[str, None] = 'f6a7b8c9d0e1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# (index_name, table, column)
|
||||||
|
_INDEXES = [
|
||||||
|
('ix_users_primary_account_id', 'users', 'primary_account_id'),
|
||||||
|
('ix_holdings_entity_id', 'holdings', 'entity_id'),
|
||||||
|
('ix_positions_holding_id', 'positions', 'holding_id'),
|
||||||
|
('ix_valuation_rounds_submitted_by', 'valuation_rounds', 'submitted_by'),
|
||||||
|
('ix_valuation_rounds_approved_by', 'valuation_rounds', 'approved_by'),
|
||||||
|
('ix_valuations_position_id', 'valuations', 'position_id'),
|
||||||
|
('ix_audit_log_actor_user_id', 'audit_log', 'actor_user_id'),
|
||||||
|
('ix_entity_access_entity_id', 'entity_access', 'entity_id'),
|
||||||
|
('ix_documents_entity_id', 'documents', 'entity_id'),
|
||||||
|
('ix_documents_investor_user_id', 'documents', 'investor_user_id'),
|
||||||
|
('ix_documents_uploaded_by', 'documents', 'uploaded_by'),
|
||||||
|
('ix_capital_account_statements_investor_user_id',
|
||||||
|
'capital_account_statements', 'investor_user_id'),
|
||||||
|
('ix_capital_account_statements_document_id',
|
||||||
|
'capital_account_statements', 'document_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name, table, column in _INDEXES:
|
||||||
|
op.create_index(name, table, [column])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for name, table, _column in reversed(_INDEXES):
|
||||||
|
op.drop_index(name, table_name=table)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add users.external_investor_id
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-06-28 11:10:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'b2c3d4e5f6a7'
|
||||||
|
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('external_investor_id')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add entity_stakes (a holder entity's stake in the funds it manages)
|
||||||
|
|
||||||
|
Revision ID: b8c9d0e1f2a3
|
||||||
|
Revises: a7b8c9d0e1f2
|
||||||
|
Create Date: 2026-07-01 10:15:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'b8c9d0e1f2a3'
|
||||||
|
down_revision: Union[str, None] = 'a7b8c9d0e1f2'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'entity_stakes',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column('holder_entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('fund_entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ownership_pct', sa.Float(), nullable=True),
|
||||||
|
sa.Column('value_cents', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('note', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']),
|
||||||
|
sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']),
|
||||||
|
sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id'])
|
||||||
|
op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes')
|
||||||
|
op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes')
|
||||||
|
op.drop_table('entity_stakes')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add users.login_enabled
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-06-28 11:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c3d4e5f6a7b8'
|
||||||
|
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('login_enabled')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add entities.linked_user_id (link a GP entity to its investor account)
|
||||||
|
|
||||||
|
Revision ID: c9d0e1f2a3b4
|
||||||
|
Revises: b8c9d0e1f2a3
|
||||||
|
Create Date: 2026-07-01 10:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c9d0e1f2a3b4'
|
||||||
|
down_revision: Union[str, None] = 'b8c9d0e1f2a3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('linked_user_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index('ix_entities_linked_user_id', 'entities', ['linked_user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_entities_linked_user_id', table_name='entities')
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('linked_user_id')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add users.docs_seen_at (portal "New" document badge watermark)
|
||||||
|
|
||||||
|
Revision ID: d0e1f2a3b4c5
|
||||||
|
Revises: c9d0e1f2a3b4
|
||||||
|
Create Date: 2026-07-03 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'd0e1f2a3b4c5'
|
||||||
|
down_revision: Union[str, None] = 'c9d0e1f2a3b4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('docs_seen_at')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add capital_account_statements.commitment_cents
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: c3d4e5f6a7b8
|
||||||
|
Create Date: 2026-06-28 16:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'd4e5f6a7b8c9'
|
||||||
|
down_revision: Union[str, None] = 'c3d4e5f6a7b8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('commitment_cents')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add entity_access.exited_on (member sold/transferred their stake)
|
||||||
|
|
||||||
|
Revision ID: e1f2a3b4c5d6
|
||||||
|
Revises: d0e1f2a3b4c5
|
||||||
|
Create Date: 2026-07-03 10:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'e1f2a3b4c5d6'
|
||||||
|
down_revision: Union[str, None] = 'd0e1f2a3b4c5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('entity_access', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('exited_on', sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('entity_access', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('exited_on')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add users.primary_account_id (linked investor logins)
|
||||||
|
|
||||||
|
Revision ID: e5f6a7b8c9d0
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-06-28 17:10:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'e5f6a7b8c9d0'
|
||||||
|
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('primary_account_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('primary_account_id')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add users TOTP two-factor columns (secret, enabled flag, recovery codes)
|
||||||
|
|
||||||
|
Revision ID: f2a3b4c5d6e7
|
||||||
|
Revises: e1f2a3b4c5d6
|
||||||
|
Create Date: 2026-07-11 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f2a3b4c5d6e7'
|
||||||
|
down_revision: Union[str, None] = 'e1f2a3b4c5d6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('totp_secret', sa.String(), nullable=True))
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
batch_op.add_column(sa.Column('totp_recovery_codes', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('totp_recovery_codes')
|
||||||
|
batch_op.drop_column('totp_enabled')
|
||||||
|
batch_op.drop_column('totp_secret')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add users.is_service_admin and flag the bootstrap admin
|
||||||
|
|
||||||
|
Revision ID: f6a7b8c9d0e1
|
||||||
|
Revises: e5f6a7b8c9d0
|
||||||
|
Create Date: 2026-06-29 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f6a7b8c9d0e1'
|
||||||
|
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
# The first account created (the first-boot bootstrap admin) is the Service Admin.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE users SET is_service_admin = 1 "
|
||||||
|
"WHERE id = (SELECT MIN(id) FROM users)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_service_admin')
|
||||||
@@ -14,6 +14,15 @@ dependencies = [
|
|||||||
"aiosqlite==0.21.0",
|
"aiosqlite==0.21.0",
|
||||||
"starlette-session==0.4.3",
|
"starlette-session==0.4.3",
|
||||||
"openpyxl==3.1.5",
|
"openpyxl==3.1.5",
|
||||||
|
"msoffcrypto-tool==6.0.0",
|
||||||
|
"pyotp==2.9.0",
|
||||||
|
"qrcode==8.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest==8.3.4",
|
||||||
|
"httpx==0.28.1", # required by starlette's TestClient
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -23,3 +32,6 @@ ten31portal-cli = "ten31portal.cli:main"
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ def record_audit(
|
|||||||
object_id: int | None = None,
|
object_id: int | None = None,
|
||||||
detail: Any = None,
|
detail: Any = None,
|
||||||
) -> AuditLog:
|
) -> AuditLog:
|
||||||
"""Write one audit log entry and flush it."""
|
"""Write one audit log entry and flush it.
|
||||||
|
|
||||||
|
``detail`` is a small JSON-serializable payload describing the change (typically a dict of
|
||||||
|
changed fields, sometimes an identifying value on delete, or None). It is persisted to a
|
||||||
|
JSON column on AuditLog.
|
||||||
|
"""
|
||||||
entry = AuditLog(
|
entry = AuditLog(
|
||||||
actor_user_id=actor_user_id,
|
actor_user_id=actor_user_id,
|
||||||
action=action,
|
action=action,
|
||||||
|
|||||||
+129
-5
@@ -5,10 +5,12 @@ from typing import Annotated
|
|||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import User, UserRole
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +58,118 @@ def require_role(*roles: UserRole):
|
|||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_internal(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Block external (entity-scoped) accounts from internal staff endpoints."""
|
||||||
|
if user.role in EXTERNAL_ROLES:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def household_user_ids(user: User, session: Session) -> list[int]:
|
||||||
|
"""All account ids that share this user's login.
|
||||||
|
|
||||||
|
An investor who invests under several legal names has one "primary" account (the login)
|
||||||
|
and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as
|
||||||
|
the primary should surface every linked name's entities, statements, and documents. For a
|
||||||
|
standalone account this is just ``[user.id]``.
|
||||||
|
"""
|
||||||
|
root_id = user.primary_account_id or user.id
|
||||||
|
linked = session.exec(
|
||||||
|
select(User.id).where(User.primary_account_id == root_id)
|
||||||
|
).all()
|
||||||
|
return list({root_id, user.id, *linked})
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_entity_ids(user: User, session: Session) -> set[int] | None:
|
||||||
|
"""Entity ids an external account may view. None means unrestricted (internal staff)."""
|
||||||
|
if user.role not in EXTERNAL_ROLES:
|
||||||
|
return None
|
||||||
|
rows = session.exec(
|
||||||
|
select(EntityAccess.entity_id).where(
|
||||||
|
col(EntityAccess.user_id).in_(household_user_ids(user, session))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_entity(user: User, entity_id: int, session: Session) -> bool:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
return allowed is None or entity_id in allowed
|
||||||
|
|
||||||
|
|
||||||
# Convenience aliases
|
# Convenience aliases
|
||||||
require_user = get_current_user
|
require_user = get_current_user
|
||||||
require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver)
|
require_writer = require_role(
|
||||||
require_approver = require_role(UserRole.approver)
|
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations
|
||||||
require_audit_reader = require_role(UserRole.approver, UserRole.cfo)
|
)
|
||||||
|
require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only
|
||||||
|
require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||||
|
# Account administration (create/manage users, documents, capital accounts)
|
||||||
|
require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||||
|
|
||||||
|
# Internal admins plus the external Administrator (fund_administrator). Every endpoint
|
||||||
|
# using this gate must also call check_administrator_scope for the entity it touches —
|
||||||
|
# the role alone says nothing about WHICH entities an Administrator may manage.
|
||||||
|
require_admin = require_role(
|
||||||
|
UserRole.approver, UserRole.cfo, UserRole.operations, UserRole.fund_administrator
|
||||||
|
)
|
||||||
|
# Entity-record writers: internal writers plus the external Administrator (scope-checked).
|
||||||
|
require_entity_writer = require_role(
|
||||||
|
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations,
|
||||||
|
UserRole.fund_administrator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Read gate for admin screens: any internal role, or an external Administrator
|
||||||
|
(managing or view-only).
|
||||||
|
|
||||||
|
Investors are blocked; external calls must still be scope-checked per entity.
|
||||||
|
"""
|
||||||
|
if user.role == UserRole.investor:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
|
||||||
|
"""403 when an external account touches an entity outside their grants.
|
||||||
|
|
||||||
|
Internal roles pass through untouched — their reach is decided by the route's gate.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|||||||
+314
-6
@@ -1,23 +1,38 @@
|
|||||||
"""CLI commands for Ten31Portal."""
|
"""CLI commands for Ten31Portal (also driven by the StartOS service Actions)."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal import config
|
||||||
from ten31portal.auth import hash_password
|
from ten31portal.auth import hash_password
|
||||||
from ten31portal.database import engine
|
from ten31portal.database import engine
|
||||||
from ten31portal.db_init import run_migrations
|
from ten31portal.db_init import run_migrations
|
||||||
from ten31portal.models import User, UserRole
|
from ten31portal.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def _find_user(session: Session, username: str | None, email: str | None) -> User | None:
|
||||||
|
"""Look a user up by username (preferred) or email."""
|
||||||
|
if username:
|
||||||
|
u = session.exec(select(User).where(User.username == username)).first()
|
||||||
|
if u:
|
||||||
|
return u
|
||||||
|
if email:
|
||||||
|
return session.exec(select(User).where(User.email == email)).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_user(args: argparse.Namespace) -> None:
|
def create_user(args: argparse.Namespace) -> None:
|
||||||
"""Create a new user account."""
|
"""Create a new user account."""
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
existing = session.exec(select(User).where(User.email == args.email)).first()
|
if session.exec(select(User).where(User.username == args.username)).first():
|
||||||
if existing:
|
print(f"Error: user with username {args.username} already exists.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if args.email and session.exec(select(User).where(User.email == args.email)).first():
|
||||||
print(f"Error: user with email {args.email} already exists.", file=sys.stderr)
|
print(f"Error: user with email {args.email} already exists.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -29,13 +44,247 @@ def create_user(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
name=args.name,
|
name=args.name,
|
||||||
email=args.email,
|
username=args.username,
|
||||||
|
email=args.email or None,
|
||||||
password_hash=hash_password(args.password),
|
password_hash=hash_password(args.password),
|
||||||
role=role,
|
role=role,
|
||||||
|
is_service_admin=bool(getattr(args, "service_admin", False)),
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
print(f"Created user: {user.name} ({user.email}) with role {user.role.value}")
|
print(f"Created user: {user.name} ({user.username}) with role {user.role.value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_admin_password_file() -> None:
|
||||||
|
"""Remove the recorded initial admin password (it is no longer valid once changed)."""
|
||||||
|
try:
|
||||||
|
os.remove(config.ADMIN_PASSWORD_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(args: argparse.Namespace) -> None:
|
||||||
|
"""Reset a user's password (and re-enable their login)."""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = _find_user(session, args.username, args.email)
|
||||||
|
if user is None:
|
||||||
|
who = args.username or args.email
|
||||||
|
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
user.password_hash = hash_password(args.password)
|
||||||
|
user.login_enabled = True
|
||||||
|
user.must_change_password = False
|
||||||
|
is_admin = user.is_service_admin
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
# Resetting the built-in admin invalidates the initial password recorded on first boot.
|
||||||
|
if is_admin:
|
||||||
|
_clear_admin_password_file()
|
||||||
|
print(f"Password reset for {user.name} ({user.username}).")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_2fa(args: argparse.Namespace) -> None:
|
||||||
|
"""Clear a user's two-factor enrollment so they can sign in with password alone."""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = _find_user(session, args.username, args.email)
|
||||||
|
if user is None:
|
||||||
|
who = args.username or args.email
|
||||||
|
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not user.totp_enabled and not user.totp_secret:
|
||||||
|
print(f"{user.name} ({user.username}) does not have two-factor enabled.")
|
||||||
|
return
|
||||||
|
user.totp_secret = None
|
||||||
|
user.totp_enabled = False
|
||||||
|
user.totp_recovery_codes = None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
print(f"Two-factor disabled for {user.name} ({user.username}). "
|
||||||
|
"They can sign in with just their password and re-enroll from the app.")
|
||||||
|
|
||||||
|
|
||||||
|
def enable_investor_logins(args: argparse.Namespace) -> None:
|
||||||
|
"""Give every no-login investor account the shared default password and enable sign-in.
|
||||||
|
|
||||||
|
Targets investor-role accounts with login_enabled=False that log in on their own
|
||||||
|
(linked secondary names are skipped — they sign in under their primary). Accounts that
|
||||||
|
already have a working login are never touched.
|
||||||
|
"""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
from ten31portal.models import UserRole
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(
|
||||||
|
User.role == UserRole.investor,
|
||||||
|
User.login_enabled == False, # noqa: E712 — SQL expression
|
||||||
|
User.primary_account_id == None, # noqa: E711
|
||||||
|
).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
if not users:
|
||||||
|
print("Nothing to do — every investor account already has a login.")
|
||||||
|
return
|
||||||
|
for u in users:
|
||||||
|
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
|
u.login_enabled = True
|
||||||
|
u.must_change_password = True
|
||||||
|
session.add(u)
|
||||||
|
session.commit()
|
||||||
|
for u in users:
|
||||||
|
print(f"Enabled login for {u.name} ({u.username})")
|
||||||
|
print(
|
||||||
|
f"\n{len(users)} investor account(s) set to the default password "
|
||||||
|
f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the "
|
||||||
|
"portal (Change password)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_admin_password(args: argparse.Namespace) -> None:
|
||||||
|
"""Print the randomly-generated initial admin password recorded on first boot."""
|
||||||
|
path = config.ADMIN_PASSWORD_FILE
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(
|
||||||
|
"No stored initial password. It was either set explicitly at install time, or the "
|
||||||
|
"admin password has already been changed. Use Reset Password to set a new one."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
with open(path) as f:
|
||||||
|
pw = f.read().strip()
|
||||||
|
print(pw if pw else "(the recorded initial password is empty)")
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(args: argparse.Namespace) -> None:
|
||||||
|
"""Print all user accounts."""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
users = session.exec(select(User).order_by(User.role, User.name)).all() # type: ignore[arg-type]
|
||||||
|
if not users:
|
||||||
|
print("No users.")
|
||||||
|
return
|
||||||
|
print(f"{'USERNAME':<20} {'NAME':<24} {'ROLE':<14} {'STATUS':<10} EMAIL")
|
||||||
|
print("-" * 84)
|
||||||
|
for u in users:
|
||||||
|
status = "active" if u.is_active else "disabled"
|
||||||
|
tag = " [SERVICE ADMIN]" if u.is_service_admin else ""
|
||||||
|
print(f"{u.username:<20} {u.name:<24} {u.role.value:<14} {status:<10} {u.email or '-'}{tag}")
|
||||||
|
print(f"\n{len(users)} user(s). The Service Admin cannot be deleted.")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(args: argparse.Namespace) -> None:
|
||||||
|
"""Delete a user account and its dependent rows. The Service Admin is protected."""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
from ten31portal.routers.user_router import delete_user_cascade
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = _find_user(session, args.username, args.email)
|
||||||
|
if user is None:
|
||||||
|
who = args.username or args.email
|
||||||
|
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if user.is_service_admin:
|
||||||
|
print(f"Error: '{user.username}' is the Service Admin and cannot be deleted.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
name, username = user.name, user.username
|
||||||
|
# Don't abort on a file that won't delete, but surface it — a swallowed disk or
|
||||||
|
# permission error would silently orphan the file on the data volume.
|
||||||
|
for warning in delete_user_cascade(session, user):
|
||||||
|
print(f"Warning: {warning}", file=sys.stderr)
|
||||||
|
session.commit()
|
||||||
|
print(f"Deleted user {name} ({username}).")
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_holdings(args: argparse.Namespace) -> None:
|
||||||
|
"""Remove duplicate holdings/positions left by old double-imports (fixes inflated Invested)."""
|
||||||
|
run_migrations()
|
||||||
|
from ten31portal.models import Entity
|
||||||
|
from ten31portal.routers.import_router import dedupe_entity
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
if args.entity_id:
|
||||||
|
entities = [e for e in [session.get(Entity, args.entity_id)] if e]
|
||||||
|
else:
|
||||||
|
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||||
|
total_h = total_p = 0
|
||||||
|
for ent in entities:
|
||||||
|
res = dedupe_entity(ent.id, session)
|
||||||
|
if res["removed_holdings"] or res["removed_positions"]:
|
||||||
|
print(f"{ent.name}: removed {res['removed_holdings']} holding(s), "
|
||||||
|
f"{res['removed_positions']} position(s)")
|
||||||
|
total_h += res["removed_holdings"]
|
||||||
|
total_p += res["removed_positions"]
|
||||||
|
session.commit()
|
||||||
|
if total_h or total_p:
|
||||||
|
print(f"\nDone. Removed {total_h} duplicate holding(s) and {total_p} position(s).")
|
||||||
|
else:
|
||||||
|
print("No duplicates found — nothing to clean up.")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_holdings(args: argparse.Namespace) -> None:
|
||||||
|
"""Clear a fund's holdings/positions/rounds so it can be re-imported from scratch."""
|
||||||
|
run_migrations()
|
||||||
|
from ten31portal.models import Entity
|
||||||
|
from ten31portal.routers.import_router import reset_entity_holdings
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
entity = None
|
||||||
|
if args.entity_id:
|
||||||
|
entity = session.get(Entity, args.entity_id)
|
||||||
|
elif args.name:
|
||||||
|
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
|
||||||
|
if entity is None:
|
||||||
|
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
|
||||||
|
f"Check the exact name with list-funds.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
res = reset_entity_holdings(entity.id, session)
|
||||||
|
session.commit()
|
||||||
|
print(f"Cleared {entity.name}: removed {res['holdings']} holding(s), "
|
||||||
|
f"{res['positions']} position(s), {res['rounds']} round(s). "
|
||||||
|
f"Re-import the fund's NAV to repopulate it.")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_partners(args: argparse.Namespace) -> None:
|
||||||
|
"""Remove all partners (capital-account statements + access grants) from one fund."""
|
||||||
|
run_migrations()
|
||||||
|
from ten31portal.models import Entity
|
||||||
|
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
entity = None
|
||||||
|
if args.entity_id:
|
||||||
|
entity = session.get(Entity, args.entity_id)
|
||||||
|
elif args.name:
|
||||||
|
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
|
||||||
|
if entity is None:
|
||||||
|
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
|
||||||
|
f"Check the exact name with list-funds.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
res = reset_entity_partners(entity.id, session)
|
||||||
|
session.commit()
|
||||||
|
print(f"Cleared partners from {entity.name}: removed {res['statements']} capital "
|
||||||
|
f"statement(s) and {res['access_grants']} access grant(s). "
|
||||||
|
f"Investor accounts were kept. Re-import the correct roster to repopulate.")
|
||||||
|
|
||||||
|
|
||||||
|
def list_funds(args: argparse.Namespace) -> None:
|
||||||
|
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
|
||||||
|
run_migrations()
|
||||||
|
from ten31portal.models import Entity
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||||
|
if not entities:
|
||||||
|
print("No funds yet.")
|
||||||
|
return
|
||||||
|
for e in entities:
|
||||||
|
print(f"[{e.id}] {e.name} ({e.type.value})")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -44,13 +293,72 @@ def main() -> None:
|
|||||||
|
|
||||||
create = sub.add_parser("create-user", help="Provision a new user")
|
create = sub.add_parser("create-user", help="Provision a new user")
|
||||||
create.add_argument("--name", required=True)
|
create.add_argument("--name", required=True)
|
||||||
create.add_argument("--email", required=True)
|
create.add_argument("--username", required=True)
|
||||||
|
create.add_argument("--email", required=False, default=None)
|
||||||
create.add_argument("--role", required=True, choices=[r.value for r in UserRole])
|
create.add_argument("--role", required=True, choices=[r.value for r in UserRole])
|
||||||
create.add_argument("--password", required=True)
|
create.add_argument("--password", required=True)
|
||||||
|
create.add_argument("--service-admin", action="store_true",
|
||||||
|
help="Mark as the protected built-in Service Admin")
|
||||||
|
|
||||||
|
reset = sub.add_parser("reset-password", help="Reset a user's password")
|
||||||
|
reset.add_argument("--username", required=False, default=None)
|
||||||
|
reset.add_argument("--email", required=False, default=None)
|
||||||
|
reset.add_argument("--password", required=True)
|
||||||
|
|
||||||
|
r2fa = sub.add_parser("reset-2fa", help="Clear a user's two-factor enrollment (lost phone)")
|
||||||
|
r2fa.add_argument("--username", required=False, default=None)
|
||||||
|
r2fa.add_argument("--email", required=False, default=None)
|
||||||
|
|
||||||
|
sub.add_parser("list-users", help="List all user accounts")
|
||||||
|
|
||||||
|
sub.add_parser(
|
||||||
|
"enable-investor-logins",
|
||||||
|
help="Set every no-login investor account to the default password and enable sign-in",
|
||||||
|
)
|
||||||
|
|
||||||
|
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
|
||||||
|
|
||||||
|
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
|
||||||
|
delete.add_argument("--username", required=False, default=None)
|
||||||
|
delete.add_argument("--email", required=False, default=None)
|
||||||
|
|
||||||
|
dedupe = sub.add_parser("dedupe-holdings", help="Remove duplicate holdings/positions")
|
||||||
|
dedupe.add_argument("--entity-id", type=int, required=False, default=None,
|
||||||
|
help="Limit to one entity; omit to clean all")
|
||||||
|
|
||||||
|
sub.add_parser("list-funds", help="List entities (funds/SPVs) with their ids")
|
||||||
|
|
||||||
|
reset = sub.add_parser("reset-holdings", help="Clear a fund's holdings to re-import fresh")
|
||||||
|
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||||
|
reset.add_argument("--entity-id", type=int, required=False, default=None)
|
||||||
|
|
||||||
|
rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund")
|
||||||
|
rparts.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||||
|
rparts.add_argument("--entity-id", type=int, required=False, default=None)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.command == "create-user":
|
if args.command == "create-user":
|
||||||
create_user(args)
|
create_user(args)
|
||||||
|
elif args.command == "reset-password":
|
||||||
|
reset_password(args)
|
||||||
|
elif args.command == "reset-2fa":
|
||||||
|
reset_2fa(args)
|
||||||
|
elif args.command == "list-users":
|
||||||
|
list_users(args)
|
||||||
|
elif args.command == "enable-investor-logins":
|
||||||
|
enable_investor_logins(args)
|
||||||
|
elif args.command == "show-admin-password":
|
||||||
|
show_admin_password(args)
|
||||||
|
elif args.command == "delete-user":
|
||||||
|
delete_user(args)
|
||||||
|
elif args.command == "dedupe-holdings":
|
||||||
|
dedupe_holdings(args)
|
||||||
|
elif args.command == "list-funds":
|
||||||
|
list_funds(args)
|
||||||
|
elif args.command == "reset-holdings":
|
||||||
|
reset_holdings(args)
|
||||||
|
elif args.command == "reset-partners":
|
||||||
|
reset_partners(args)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,32 @@ 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")
|
||||||
|
# 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
|
||||||
|
# also bounds spreadsheet imports (which must be read fully into memory to parse).
|
||||||
|
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
|
||||||
|
|
||||||
|
# Investor accounts created by the eNAV import (and existing no-login accounts converted via
|
||||||
|
# the enable-investor-logins CLI/action) start with this password so the admin can hand out
|
||||||
|
# credentials easily; each investor changes it via the portal's own Change password.
|
||||||
|
DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal")
|
||||||
|
|
||||||
|
# Where start.sh records the randomly-generated initial admin password on first boot, so the
|
||||||
|
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
|
||||||
|
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
|
||||||
|
ADMIN_PASSWORD_FILE: str = os.path.join(os.path.dirname(DB_PATH) or ".", ".admin-password")
|
||||||
|
|||||||
+103
-6
@@ -1,15 +1,20 @@
|
|||||||
"""Ten31Portal FastAPI application."""
|
"""Ten31Portal FastAPI application."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
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.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
|
||||||
@@ -18,16 +23,72 @@ from ten31portal.routers.holding_router import router as holding_router
|
|||||||
from ten31portal.routers.position_router import router as position_router
|
from ten31portal.routers.position_router import router as position_router
|
||||||
from ten31portal.routers.round_router import router as round_router
|
from ten31portal.routers.round_router import router as round_router
|
||||||
from ten31portal.routers.import_router import router as import_router
|
from ten31portal.routers.import_router import router as import_router
|
||||||
|
from ten31portal.routers.user_router import router as user_router
|
||||||
|
from ten31portal.routers.document_router import router as document_router
|
||||||
|
from ten31portal.routers.capital_account_router import router as capital_account_router
|
||||||
|
from ten31portal.routers.capital_import_router import router as capital_import_router
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
ensure_docs_dir()
|
||||||
|
# Drain any operator-queued eNAV re-imports (e.g. to apply an importer fix to a
|
||||||
|
# signed round). Guarded so a queue problem can never block the app from booting.
|
||||||
|
try:
|
||||||
|
from ten31portal.pending_reimports import process_pending_reimports
|
||||||
|
process_pending_reimports()
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger("ten31portal").exception("pending re-import sweep failed")
|
||||||
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
|
||||||
|
# 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.
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(audit_router)
|
app.include_router(audit_router)
|
||||||
app.include_router(entity_router)
|
app.include_router(entity_router)
|
||||||
@@ -35,6 +96,10 @@ app.include_router(holding_router)
|
|||||||
app.include_router(position_router)
|
app.include_router(position_router)
|
||||||
app.include_router(round_router)
|
app.include_router(round_router)
|
||||||
app.include_router(import_router)
|
app.include_router(import_router)
|
||||||
|
app.include_router(user_router)
|
||||||
|
app.include_router(document_router)
|
||||||
|
app.include_router(capital_account_router)
|
||||||
|
app.include_router(capital_import_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -42,15 +107,47 @@ def health() -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_static_path(static_root: Path, path: str) -> Path | None:
|
||||||
|
"""Resolve `path` under `static_root`, returning the file only if it stays
|
||||||
|
within the root. Percent-encoded traversal (..%2f) survives routing and
|
||||||
|
would otherwise let an unauthenticated caller read files outside static/
|
||||||
|
(e.g. the DB or session secret on the data volume). Returns None if the
|
||||||
|
resolved path escapes the root."""
|
||||||
|
root = static_root.resolve()
|
||||||
|
candidate = (root / path).resolve()
|
||||||
|
if candidate != root and root not in candidate.parents:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
# Serve built frontend in production (when static/ dir exists next to the app)
|
# Serve built frontend in production (when static/ dir exists next to the app)
|
||||||
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||||
if _static_dir.is_dir():
|
if _static_dir.is_dir():
|
||||||
@app.get("/{path:path}")
|
# index.html must always be revalidated so a new build is picked up right after an
|
||||||
|
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
|
||||||
|
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||||
|
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
|
||||||
|
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||||
|
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
|
||||||
|
|
||||||
|
def _index() -> FileResponse:
|
||||||
|
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
|
||||||
|
|
||||||
|
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||||
async def serve_spa(path: str):
|
async def serve_spa(path: str):
|
||||||
file = _static_dir / path
|
file = _contained_static_path(_static_dir, path)
|
||||||
|
if file is None:
|
||||||
|
return _index()
|
||||||
if file.is_file():
|
if file.is_file():
|
||||||
|
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
|
||||||
|
if file.name == "index.html":
|
||||||
|
return _index()
|
||||||
|
if path.startswith("assets/"):
|
||||||
|
return FileResponse(file, headers=_IMMUTABLE)
|
||||||
|
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
|
||||||
|
return FileResponse(file, headers=_ONE_DAY)
|
||||||
return FileResponse(file)
|
return FileResponse(file)
|
||||||
return FileResponse(_static_dir / "index.html")
|
return _index()
|
||||||
|
|
||||||
|
|
||||||
def cli() -> None:
|
def cli() -> None:
|
||||||
|
|||||||
+152
-10
@@ -2,19 +2,38 @@
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
from datetime import date as _date # for fields literally named "date"
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
|
from sqlmodel import Field, SQLModel, Column, Date, String, JSON, UniqueConstraint
|
||||||
|
|
||||||
|
|
||||||
# --- Enums ---
|
# --- Enums ---
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
approver = "approver"
|
# Internal staff
|
||||||
|
approver = "approver" # "Managing Partner" — full access incl. valuation sign-off
|
||||||
|
operations = "operations" # full access except final sign-off
|
||||||
cfo = "cfo"
|
cfo = "cfo"
|
||||||
fund_admin = "fund_admin"
|
fund_admin = "fund_admin"
|
||||||
viewer = "viewer"
|
viewer = "viewer"
|
||||||
|
# External accounts (entity-scoped via EntityAccess)
|
||||||
|
investor = "investor"
|
||||||
|
fund_administrator = "fund_administrator" # "Administrator" — manages its funds
|
||||||
|
administrator_viewer = "administrator_viewer" # "Administrator (view only)" — reads its funds
|
||||||
|
|
||||||
|
|
||||||
|
# External roles see only the entities granted to them.
|
||||||
|
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCategory(str, enum.Enum):
|
||||||
|
capital_account = "capital_account"
|
||||||
|
k1 = "k1"
|
||||||
|
statement = "statement"
|
||||||
|
tax = "tax"
|
||||||
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
class EntityType(str, enum.Enum):
|
class EntityType(str, enum.Enum):
|
||||||
@@ -22,6 +41,7 @@ class EntityType(str, enum.Enum):
|
|||||||
spv = "spv"
|
spv = "spv"
|
||||||
gp = "gp"
|
gp = "gp"
|
||||||
mgmt_co = "mgmt_co"
|
mgmt_co = "mgmt_co"
|
||||||
|
carry = "carry"
|
||||||
|
|
||||||
|
|
||||||
class EntityStatus(str, enum.Enum):
|
class EntityStatus(str, enum.Enum):
|
||||||
@@ -43,10 +63,35 @@ class User(SQLModel, table=True):
|
|||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
name: str
|
name: str
|
||||||
email: str = Field(sa_column=Column(String, unique=True, nullable=False))
|
username: str = Field(sa_column=Column(String, unique=True, nullable=False))
|
||||||
|
email: str | None = Field(default=None, sa_column=Column(String, unique=True, nullable=True))
|
||||||
password_hash: str
|
password_hash: str
|
||||||
role: UserRole
|
role: UserRole
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
|
# The built-in Service Admin (bootstrap account). Can be reset but never deleted.
|
||||||
|
is_service_admin: bool = Field(default=False)
|
||||||
|
# False for members imported without a password; set True when an admin sets one.
|
||||||
|
login_enabled: bool = Field(default=True)
|
||||||
|
# When an investor invests under several legal names (one per Partner/vehicle), each name
|
||||||
|
# is its own account. Linking the secondary accounts to one "primary" lets that person sign
|
||||||
|
# in once and see every name's investments. Null = this account logs in on its own.
|
||||||
|
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
|
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
|
||||||
|
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
|
||||||
|
# When this investor last loaded their documents list — docs newer than this get a "New"
|
||||||
|
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
|
||||||
|
docs_seen_at: datetime | None = Field(default=None)
|
||||||
|
# Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only
|
||||||
|
# counts once totp_enabled is True (enrollment is confirmed with a first valid code).
|
||||||
|
totp_secret: str | None = Field(default=None)
|
||||||
|
totp_enabled: bool = Field(default=False)
|
||||||
|
# JSON list of sha256 hex digests of unused one-time recovery codes.
|
||||||
|
totp_recovery_codes: str | None = Field(default=None)
|
||||||
|
# True while the account is on the shared default password — the portal forces a
|
||||||
|
# password change before anything else. Cleared by change-password / admin reset.
|
||||||
|
must_change_password: bool = Field(default=False)
|
||||||
|
# When the investor finished (or skipped) the first-login welcome flow. Null = show it.
|
||||||
|
onboarded_at: datetime | None = Field(default=None)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,14 +104,33 @@ class Entity(SQLModel, table=True):
|
|||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
status: EntityStatus = Field(default=EntityStatus.active)
|
status: EntityStatus = Field(default=EntityStatus.active)
|
||||||
|
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
|
||||||
|
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
|
||||||
|
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
|
# Final close of the fund/SPV — the BTC entry mark: paid-in capital is valued at the BTC
|
||||||
|
# price on this date for the bitcoin-denominated view. Null = no BTC view for this fund.
|
||||||
|
close_date: date | None = Field(default=None)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BtcPrice(SQLModel, table=True):
|
||||||
|
"""Daily (or as-uploaded) BTC/USD closing prices from the admin's CSV.
|
||||||
|
|
||||||
|
Statements are valued at the newest price on or before their as-of date, so the CSV
|
||||||
|
doesn't need every calendar day — quarter-end rows are enough."""
|
||||||
|
|
||||||
|
__tablename__ = "btc_prices"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
date: _date = Field(sa_column=Column(Date, unique=True, nullable=False))
|
||||||
|
price_cents: int
|
||||||
|
|
||||||
|
|
||||||
class Holding(SQLModel, table=True):
|
class Holding(SQLModel, table=True):
|
||||||
__tablename__ = "holdings"
|
__tablename__ = "holdings"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
entity_id: int = Field(foreign_key="entities.id")
|
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||||
company_name: str
|
company_name: str
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@@ -75,7 +139,7 @@ class Position(SQLModel, table=True):
|
|||||||
__tablename__ = "positions"
|
__tablename__ = "positions"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
holding_id: int = Field(foreign_key="holdings.id")
|
holding_id: int = Field(foreign_key="holdings.id", index=True)
|
||||||
security_name: str
|
security_name: str
|
||||||
investment_date: date
|
investment_date: date
|
||||||
shares: str | None = None # Decimal stored as string
|
shares: str | None = None # Decimal stored as string
|
||||||
@@ -93,9 +157,9 @@ class ValuationRound(SQLModel, table=True):
|
|||||||
entity_id: int = Field(foreign_key="entities.id")
|
entity_id: int = Field(foreign_key="entities.id")
|
||||||
quarter_end: date
|
quarter_end: date
|
||||||
status: RoundStatus = Field(default=RoundStatus.draft)
|
status: RoundStatus = Field(default=RoundStatus.draft)
|
||||||
submitted_by: int | None = Field(default=None, foreign_key="users.id")
|
submitted_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
submitted_at: datetime | None = None
|
submitted_at: datetime | None = None
|
||||||
approved_by: int | None = Field(default=None, foreign_key="users.id")
|
approved_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
approved_at: datetime | None = None
|
approved_at: datetime | None = None
|
||||||
return_note: str | None = None
|
return_note: str | None = None
|
||||||
is_seed: bool = Field(default=False)
|
is_seed: bool = Field(default=False)
|
||||||
@@ -110,7 +174,7 @@ class Valuation(SQLModel, table=True):
|
|||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
round_id: int = Field(foreign_key="valuation_rounds.id")
|
round_id: int = Field(foreign_key="valuation_rounds.id")
|
||||||
position_id: int = Field(foreign_key="positions.id")
|
position_id: int = Field(foreign_key="positions.id", index=True)
|
||||||
value_cents: int
|
value_cents: int
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@@ -119,9 +183,87 @@ class AuditLog(SQLModel, table=True):
|
|||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
actor_user_id: int | None = Field(default=None, foreign_key="users.id")
|
actor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
action: str
|
action: str
|
||||||
object_type: str
|
object_type: str
|
||||||
object_id: int | None = None
|
object_id: int | None = None
|
||||||
detail: str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
# An action-specific JSON payload describing the change (stored in a JSON column). Most
|
||||||
|
# callers pass a dict of changed fields (e.g. an entity update); some pass an identifying
|
||||||
|
# field on delete, and some pass None. Matches AuditLogResponse.detail in schemas.py.
|
||||||
|
detail: dict | list | str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityAccess(SQLModel, table=True):
|
||||||
|
"""Which entities an external account may view."""
|
||||||
|
__tablename__ = "entity_access"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "entity_id", name="uq_access_user_entity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="users.id")
|
||||||
|
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||||
|
# Set when this member sold/transferred their stake (e.g. a secondary sale): the fund's
|
||||||
|
# books show a $0 balance with no distribution, which is NOT a loss. An exited position
|
||||||
|
# shows a badge instead of gain/loss, keeps its documents, and drops out of totals.
|
||||||
|
exited_on: date | None = Field(default=None)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(SQLModel, table=True):
|
||||||
|
"""An uploaded file. Shared to a fund (investor_user_id null) or private to one investor."""
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||||
|
investor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
|
category: DocumentCategory = Field(default=DocumentCategory.other)
|
||||||
|
title: str
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
storage_path: str # opaque filename on the data volume, relative to DOCS_DIR
|
||||||
|
uploaded_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalAccountStatement(SQLModel, table=True):
|
||||||
|
"""An investor's capital-account figures for one fund as of a date."""
|
||||||
|
__tablename__ = "capital_account_statements"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entity_id", "investor_user_id", "as_of_date",
|
||||||
|
name="uq_capacct_entity_investor_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
entity_id: int = Field(foreign_key="entities.id")
|
||||||
|
investor_user_id: int = Field(foreign_key="users.id", index=True)
|
||||||
|
as_of_date: date
|
||||||
|
commitment_cents: int = 0 # initial capital commitment
|
||||||
|
beginning_balance_cents: int = 0
|
||||||
|
contributions_cents: int = 0 # paid-in capital
|
||||||
|
distributions_cents: int = 0 # capital returned (for DPI)
|
||||||
|
ending_balance_cents: int = 0 # current capital value
|
||||||
|
document_id: int | None = Field(default=None, foreign_key="documents.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityStake(SQLModel, table=True):
|
||||||
|
"""A holder entity's ownership stake in a fund/SPV it manages.
|
||||||
|
|
||||||
|
Models the assets of a GP or management company: its interest in each fund it manages,
|
||||||
|
which are entities in their own right rather than portfolio-company holdings.
|
||||||
|
"""
|
||||||
|
__tablename__ = "entity_stakes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co
|
||||||
|
fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held
|
||||||
|
ownership_pct: float | None = None # e.g. 20.0 for a 20% interest
|
||||||
|
value_cents: int | None = None # optional current value of the stake
|
||||||
|
note: str | None = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""One-shot re-import queue, drained on startup.
|
||||||
|
|
||||||
|
An operator can drop an eNAV workbook plus a small JSON descriptor into
|
||||||
|
``<data>/pending_reimports/`` and the app re-imports it on the next boot using the
|
||||||
|
same code path as the admin Import screen. This exists so a fix to the importer
|
||||||
|
(e.g. reading the loan schedule) can be applied to an already-signed round without a
|
||||||
|
manual re-upload. Each workbook is processed exactly once and then deleted, because
|
||||||
|
its JSON descriptor may carry the workbook's open password.
|
||||||
|
|
||||||
|
Descriptor ``<name>.json`` fields:
|
||||||
|
entity_id (required) the fund/SPV to import into
|
||||||
|
password (optional) open password for an encrypted workbook
|
||||||
|
replace (optional) wipe the entity's holdings first (default false)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal import config
|
||||||
|
|
||||||
|
log = logging.getLogger("ten31portal.pending_reimports")
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_dir() -> Path:
|
||||||
|
return Path(os.path.dirname(config.DB_PATH) or ".") / "pending_reimports"
|
||||||
|
|
||||||
|
|
||||||
|
def process_pending_reimports() -> None:
|
||||||
|
"""Import every queued eNAV, then delete the ones that succeed. Never raises."""
|
||||||
|
base = _queue_dir()
|
||||||
|
if not base.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Imported lazily so importing this module never pulls in the router graph.
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from ten31portal.database import engine
|
||||||
|
from ten31portal.models import EntityType, User, UserRole
|
||||||
|
from ten31portal.routers.import_router import import_schedule
|
||||||
|
|
||||||
|
for xlsx in sorted(base.glob("*.xlsx")):
|
||||||
|
meta = xlsx.with_suffix(".json")
|
||||||
|
try:
|
||||||
|
cfg = json.loads(meta.read_text()) if meta.exists() else {}
|
||||||
|
entity_id = cfg.get("entity_id")
|
||||||
|
if entity_id is None:
|
||||||
|
log.warning("pending re-import %s has no entity_id; skipping", xlsx.name)
|
||||||
|
continue
|
||||||
|
data = xlsx.read_bytes()
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Attribute the audit entry to a signer-capable staff account.
|
||||||
|
actor = (
|
||||||
|
session.exec(select(User).where(User.role == UserRole.approver)).first()
|
||||||
|
or session.exec(select(User)).first()
|
||||||
|
)
|
||||||
|
upload = UploadFile(io.BytesIO(data), filename=xlsx.name)
|
||||||
|
result = import_schedule(
|
||||||
|
file=upload,
|
||||||
|
as_of=None,
|
||||||
|
entity_id=int(entity_id),
|
||||||
|
create_entity_type=EntityType.fund,
|
||||||
|
create_vintage_year=None,
|
||||||
|
commit=True,
|
||||||
|
replace_existing=bool(cfg.get("replace", False)),
|
||||||
|
password=cfg.get("password"),
|
||||||
|
user=actor,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
log.info("pending re-import %s applied: %s", xlsx.name, result)
|
||||||
|
except Exception:
|
||||||
|
# Leave a failed item queued so a later boot can retry once the cause is fixed.
|
||||||
|
log.exception("pending re-import %s failed; leaving queued", xlsx.name)
|
||||||
|
continue
|
||||||
|
# Success: remove both files. The JSON holds a workbook password, so it must not linger.
|
||||||
|
for path in (xlsx, meta):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""A tiny in-process sliding-window rate limiter for the login endpoint.
|
||||||
|
|
||||||
|
The portal runs as a single uvicorn process, so an in-memory counter is enough to blunt
|
||||||
|
online password guessing without adding a dependency or a shared store. It is keyed by client
|
||||||
|
IP; only failed attempts are counted, and a successful login clears the key. This deliberately
|
||||||
|
does NOT lock accounts (which would let anyone lock out a user by name) — it throttles the
|
||||||
|
source of the guessing instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowLimiter:
|
||||||
|
def __init__(self, max_attempts: int, window_seconds: float):
|
||||||
|
self.max_attempts = max_attempts
|
||||||
|
self.window = window_seconds
|
||||||
|
self._hits: dict[str, deque] = defaultdict(deque)
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def _prune(self, key: str, now: float) -> deque:
|
||||||
|
dq = self._hits[key]
|
||||||
|
cutoff = now - self.window
|
||||||
|
while dq and dq[0] <= cutoff:
|
||||||
|
dq.popleft()
|
||||||
|
if not dq:
|
||||||
|
self._hits.pop(key, None)
|
||||||
|
return dq
|
||||||
|
|
||||||
|
def retry_after(self, key: str) -> float:
|
||||||
|
"""Seconds until `key` may try again, or 0.0 if it is under the limit right now."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
dq = self._prune(key, now)
|
||||||
|
if len(dq) < self.max_attempts:
|
||||||
|
return 0.0
|
||||||
|
return self.window - (now - dq[0])
|
||||||
|
|
||||||
|
def record_failure(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._hits[key].append(now)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._hits.pop(key, None)
|
||||||
@@ -1,27 +1,142 @@
|
|||||||
"""Authentication endpoints."""
|
"""Authentication endpoints."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal import config, totp
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, hash_password, verify_password
|
from ten31portal.auth import get_current_user, hash_password, verify_password
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import User
|
from ten31portal.models import User
|
||||||
from ten31portal.schemas import LoginRequest, UserResponse
|
from ten31portal.ratelimit import SlidingWindowLimiter
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginPending2FA,
|
||||||
|
LoginRequest,
|
||||||
|
TotpConfirmRequest,
|
||||||
|
TotpConfirmResponse,
|
||||||
|
TotpDisableRequest,
|
||||||
|
TotpSetupResponse,
|
||||||
|
TotpVerifyRequest,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes.
|
||||||
|
# Second-factor code guesses count against the same window.
|
||||||
|
_login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300)
|
||||||
|
|
||||||
|
# How long a password-accepted session may wait for its second factor.
|
||||||
|
_PENDING_2FA_MAX_AGE = 300 # seconds
|
||||||
|
|
||||||
|
# A throwaway hash verified when no user matches, so a missing account costs the same argon2
|
||||||
|
# time as a real one — otherwise the timing difference reveals which usernames exist.
|
||||||
|
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(
|
def login(
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> UserResponse:
|
) -> UserResponse | LoginPending2FA:
|
||||||
user = session.exec(select(User).where(User.email == body.email)).first()
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if user is None or not verify_password(body.password, user.password_hash):
|
retry_after = _login_limiter.retry_after(client_ip)
|
||||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
if retry_after > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many login attempts. Please wait a moment and try again.",
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accept either a username or an email in the login field.
|
||||||
|
handle = body.login.strip()
|
||||||
|
user = session.exec(select(User).where(User.username == handle)).first()
|
||||||
|
if user is None:
|
||||||
|
user = session.exec(select(User).where(User.email == handle)).first()
|
||||||
|
# Always run a verify (dummy hash when the user is unknown) so success/failure take the
|
||||||
|
# same time, and give one generic message so neither branch leaks whether the user exists.
|
||||||
|
if user is None:
|
||||||
|
verify_password(body.password, _DUMMY_HASH)
|
||||||
|
_login_limiter.record_failure(client_ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
if not verify_password(body.password, user.password_hash):
|
||||||
|
_login_limiter.record_failure(client_ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
# Past this point the password was correct, so these messages don't aid guessing.
|
||||||
|
if user.primary_account_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="This account signs in under another login. Use that account's credentials.",
|
||||||
|
)
|
||||||
|
if not user.login_enabled:
|
||||||
|
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=401, detail="Account disabled")
|
raise HTTPException(status_code=401, detail="Account disabled")
|
||||||
|
# Anyone still signing in with the shared default password gets flagged so the portal
|
||||||
|
# forces them to set their own before doing anything else (covers accounts created
|
||||||
|
# before the flag existed).
|
||||||
|
if body.password == config.DEFAULT_INVESTOR_PASSWORD and not user.must_change_password:
|
||||||
|
user.must_change_password = True
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
if user.totp_enabled:
|
||||||
|
# Password accepted, but don't sign the session in yet — park the login until
|
||||||
|
# /login/verify-totp confirms the second factor.
|
||||||
|
request.session.clear()
|
||||||
|
request.session["pending_2fa_user_id"] = user.id
|
||||||
|
request.session["pending_2fa_at"] = time.time()
|
||||||
|
return LoginPending2FA()
|
||||||
|
_login_limiter.reset(client_ip)
|
||||||
|
request.session["user_id"] = user.id
|
||||||
|
return UserResponse.model_validate(user, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login/verify-totp")
|
||||||
|
def verify_totp_login(
|
||||||
|
body: TotpVerifyRequest,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Second login step: accept an authenticator code or an unused recovery code."""
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
retry_after = _login_limiter.retry_after(client_ip)
|
||||||
|
if retry_after > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many attempts. Please wait a moment and try again.",
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = request.session.get("pending_2fa_user_id")
|
||||||
|
started = request.session.get("pending_2fa_at", 0)
|
||||||
|
if user_id is None or time.time() - started > _PENDING_2FA_MAX_AGE:
|
||||||
|
request.session.clear()
|
||||||
|
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None or not user.is_active or not user.totp_enabled or not user.totp_secret:
|
||||||
|
request.session.clear()
|
||||||
|
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
|
||||||
|
|
||||||
|
if not totp.verify_code(user.totp_secret, body.code):
|
||||||
|
# Not a current authenticator code — maybe a one-time recovery code.
|
||||||
|
remaining = totp.consume_recovery_code(user.totp_recovery_codes, body.code)
|
||||||
|
if remaining is None:
|
||||||
|
_login_limiter.record_failure(client_ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid code. Try again.")
|
||||||
|
user.totp_recovery_codes = remaining
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, user.id, "totp_recovery_used", "user", user.id)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
_login_limiter.reset(client_ip)
|
||||||
|
request.session.clear()
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
return UserResponse.model_validate(user, from_attributes=True)
|
return UserResponse.model_validate(user, from_attributes=True)
|
||||||
|
|
||||||
@@ -35,3 +150,102 @@ def logout(request: Request) -> dict[str, str]:
|
|||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
def me(user: User = Depends(get_current_user)) -> UserResponse:
|
def me(user: User = Depends(get_current_user)) -> UserResponse:
|
||||||
return UserResponse.model_validate(user, from_attributes=True)
|
return UserResponse.model_validate(user, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Let the signed-in user set their own password (after confirming the current one)."""
|
||||||
|
if not verify_password(body.current_password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||||
|
if body.new_password == config.DEFAULT_INVESTOR_PASSWORD:
|
||||||
|
raise HTTPException(status_code=400, detail="Please choose a password of your own.")
|
||||||
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
# Once the built-in admin sets their own password, the initial one from first boot is stale.
|
||||||
|
if user.is_service_admin:
|
||||||
|
try:
|
||||||
|
os.remove(config.ADMIN_PASSWORD_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/onboarded")
|
||||||
|
def mark_onboarded(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Stamp the first-login welcome flow as finished (or skipped) so it stops showing."""
|
||||||
|
if user.onboarded_at is None:
|
||||||
|
user.onboarded_at = datetime.utcnow()
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Two-factor enrollment (per-user opt-in) ---
|
||||||
|
|
||||||
|
@router.post("/totp/setup")
|
||||||
|
def totp_setup(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> TotpSetupResponse:
|
||||||
|
"""Start enrollment: mint a secret and return the QR. Not active until confirmed."""
|
||||||
|
if user.totp_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Two-factor is already enabled. Disable it first to re-enroll."
|
||||||
|
)
|
||||||
|
secret = totp.new_secret()
|
||||||
|
user.totp_secret = secret
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
uri = totp.otpauth_uri(secret, user.username)
|
||||||
|
return TotpSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=totp.qr_svg(uri))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/confirm")
|
||||||
|
def totp_confirm(
|
||||||
|
body: TotpConfirmRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> TotpConfirmResponse:
|
||||||
|
"""Finish enrollment: prove the authenticator works, then hand out recovery codes."""
|
||||||
|
if user.totp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Two-factor is already enabled.")
|
||||||
|
if not user.totp_secret:
|
||||||
|
raise HTTPException(status_code=400, detail="Start two-factor setup first.")
|
||||||
|
if not totp.verify_code(user.totp_secret, body.code):
|
||||||
|
raise HTTPException(status_code=400, detail="That code didn't match. Try again.")
|
||||||
|
codes, digests_json = totp.generate_recovery_codes()
|
||||||
|
user.totp_enabled = True
|
||||||
|
user.totp_recovery_codes = digests_json
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, user.id, "totp_enabled", "user", user.id)
|
||||||
|
session.commit()
|
||||||
|
return TotpConfirmResponse(recovery_codes=codes)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/disable")
|
||||||
|
def totp_disable(
|
||||||
|
body: TotpDisableRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Turn off two-factor (requires the account password, not just a live session)."""
|
||||||
|
if not verify_password(body.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Password is incorrect.")
|
||||||
|
user.totp_secret = None
|
||||||
|
user.totp_enabled = False
|
||||||
|
user.totp_recovery_codes = None
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, user.id, "totp_disabled", "user", user.id)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Capital account statements: admin entry, investor read of their own figures."""
|
||||||
|
|
||||||
|
from bisect import bisect_right
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
from ten31portal.auth import (
|
||||||
|
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 (
|
||||||
|
BtcPrice, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dollars_to_cents(dollars: float) -> int:
|
||||||
|
return round(dollars * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def exit_dates(session: Session, rows) -> dict:
|
||||||
|
"""(investor_user_id, entity_id) -> exited_on for every exited pair in these statements,
|
||||||
|
so a sold/transferred stake renders as "Exited" instead of a phantom -100% loss."""
|
||||||
|
if not rows:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
(a.user_id, a.entity_id): a.exited_on
|
||||||
|
for a in session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
col(EntityAccess.user_id).in_({r.investor_user_id for r in rows}),
|
||||||
|
col(EntityAccess.exited_on).is_not(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def btc_marks(session: Session, rows) -> tuple[dict, dict]:
|
||||||
|
"""BTC/USD marks for the bitcoin-denominated view.
|
||||||
|
|
||||||
|
Returns ({statement_id: price at its as-of date}, {entity_id: price at the fund's
|
||||||
|
close date}) — "price at" meaning the newest uploaded price on or before that date,
|
||||||
|
so a quarter-end-only CSV is enough. Empty when no prices are uploaded; a fund without
|
||||||
|
a close_date has no entry and the portal hides its BTC view.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return {}, {}
|
||||||
|
prices = session.exec(select(BtcPrice).order_by(col(BtcPrice.date))).all()
|
||||||
|
if not prices:
|
||||||
|
return {}, {}
|
||||||
|
dates = [p.date for p in prices]
|
||||||
|
|
||||||
|
def price_at(d):
|
||||||
|
i = bisect_right(dates, d)
|
||||||
|
return prices[i - 1].price_cents if i else None
|
||||||
|
|
||||||
|
asof = {r.id: price_at(r.as_of_date) for r in rows}
|
||||||
|
entities = session.exec(
|
||||||
|
select(Entity).where(col(Entity.id).in_({r.entity_id for r in rows}))
|
||||||
|
).all()
|
||||||
|
close = {e.id: price_at(e.close_date) for e in entities if e.close_date is not None}
|
||||||
|
return asof, close
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_statements(
|
||||||
|
entity_id: int | None = None,
|
||||||
|
investor_user_id: int | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[CapitalAccountResponse]:
|
||||||
|
query = select(CapitalAccountStatement)
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
|
||||||
|
if user.role in (UserRole.fund_administrator, UserRole.administrator_viewer):
|
||||||
|
# An Administrator (managing or view-only) sees every investor's statements,
|
||||||
|
# but only inside their funds.
|
||||||
|
if not allowed:
|
||||||
|
return []
|
||||||
|
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||||
|
if investor_user_id is not None:
|
||||||
|
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||||
|
elif allowed is not None:
|
||||||
|
# Investors see statements for every legal name linked to their login.
|
||||||
|
query = query.where(
|
||||||
|
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
|
||||||
|
)
|
||||||
|
if allowed:
|
||||||
|
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
if investor_user_id is not None:
|
||||||
|
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||||
|
|
||||||
|
if entity_id is not None:
|
||||||
|
query = query.where(CapitalAccountStatement.entity_id == entity_id)
|
||||||
|
|
||||||
|
rows = session.exec(
|
||||||
|
query.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||||
|
).all()
|
||||||
|
# Attach each statement's legal name so the portal can label/group accounts held under
|
||||||
|
# different names without an admin-only user lookup.
|
||||||
|
names = dict(session.exec(
|
||||||
|
select(User.id, User.name).where(
|
||||||
|
col(User.id).in_({r.investor_user_id for r in rows})
|
||||||
|
)
|
||||||
|
).all()) if rows else {}
|
||||||
|
exits = exit_dates(session, rows)
|
||||||
|
btc_asof, btc_close = btc_marks(session, rows)
|
||||||
|
out: list[CapitalAccountResponse] = []
|
||||||
|
for r in rows:
|
||||||
|
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
|
data.investor_name = names.get(r.investor_user_id)
|
||||||
|
data.exited_on = exits.get((r.investor_user_id, r.entity_id))
|
||||||
|
data.btc_price_cents = btc_asof.get(r.id)
|
||||||
|
data.btc_close_price_cents = btc_close.get(r.entity_id)
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_statement(
|
||||||
|
body: CapitalAccountCreate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CapitalAccountResponse:
|
||||||
|
check_administrator_scope(admin, body.entity_id, session)
|
||||||
|
if session.get(Entity, body.entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
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(
|
||||||
|
CapitalAccountStatement.entity_id == body.entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == body.investor_user_id,
|
||||||
|
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="A statement for this investor, fund, and date already exists.",
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = CapitalAccountStatement(
|
||||||
|
entity_id=body.entity_id,
|
||||||
|
investor_user_id=body.investor_user_id,
|
||||||
|
as_of_date=body.as_of_date,
|
||||||
|
commitment_cents=_dollars_to_cents(body.commitment_dollars),
|
||||||
|
beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars),
|
||||||
|
contributions_cents=_dollars_to_cents(body.contributions_dollars),
|
||||||
|
distributions_cents=_dollars_to_cents(body.distributions_dollars),
|
||||||
|
ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars),
|
||||||
|
document_id=body.document_id,
|
||||||
|
)
|
||||||
|
session.add(stmt)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, admin.id, "create", "capital_account", stmt.id, {
|
||||||
|
"entity_id": body.entity_id,
|
||||||
|
"investor_user_id": body.investor_user_id,
|
||||||
|
"as_of_date": str(body.as_of_date),
|
||||||
|
"ending_balance_cents": stmt.ending_balance_cents,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(stmt)
|
||||||
|
return CapitalAccountResponse.model_validate(stmt, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{statement_id}")
|
||||||
|
def delete_statement(
|
||||||
|
statement_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
stmt = session.get(CapitalAccountStatement, statement_id)
|
||||||
|
if stmt is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Statement not found")
|
||||||
|
check_administrator_scope(admin, stmt.entity_id, session)
|
||||||
|
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
|
||||||
|
session.delete(stmt)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
"""Import fund members and their capital figures from a fund-administrator workbook.
|
||||||
|
|
||||||
|
Primary path: an eNAV workbook's "ALLOC SI" tab (one row per investor with INVESTOR ID,
|
||||||
|
INVESTOR NAME, COMMITTED CAPITAL, CONTRIBUTIONS, (DISTRIBUTIONS), ENDING BALANCE).
|
||||||
|
Fallback: a subaccounts sheet (investor names across columns, per-vehicle value rows).
|
||||||
|
|
||||||
|
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
|
||||||
|
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
|
||||||
|
(on the shared default password unless one is given), grants entity access, and loads each
|
||||||
|
member's capital-account statement (commitment, contributions, distributions, current value).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal import config, storage
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from ten31portal.routers.import_router import (
|
||||||
|
_open_workbook, _enav_as_of, _parse_schedule_xlsx, upsert_history_round,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
BatchCapitalFileResult, BatchCapitalImportResult,
|
||||||
|
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/import/capital-accounts", tags=["import"])
|
||||||
|
|
||||||
|
MAX_ROWS = 400
|
||||||
|
MAX_COLS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]:
|
||||||
|
"""Remove every partner from one fund: delete its capital-account statements and the
|
||||||
|
investors' access grants to it. The investor *accounts* are kept — they usually also
|
||||||
|
belong to other funds — only their membership of THIS entity is cleared. Use to undo a
|
||||||
|
wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV
|
||||||
|
are untouched (see reset_entity_holdings for those). Caller commits.
|
||||||
|
"""
|
||||||
|
statements = 0
|
||||||
|
for s in session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
session.delete(s)
|
||||||
|
statements += 1
|
||||||
|
|
||||||
|
# Only drop investor memberships; a fund_administrator's access is not a "partner".
|
||||||
|
investor_ids = {
|
||||||
|
u.id for u in session.exec(
|
||||||
|
select(User).where(User.role == UserRole.investor)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
access = 0
|
||||||
|
for a in session.exec(
|
||||||
|
select(EntityAccess).where(EntityAccess.entity_id == entity_id)
|
||||||
|
).all():
|
||||||
|
if a.user_id in investor_ids:
|
||||||
|
session.delete(a)
|
||||||
|
access += 1
|
||||||
|
|
||||||
|
return {"statements": statements, "access_grants": access}
|
||||||
|
|
||||||
|
|
||||||
|
def _slug_username(name: str) -> str:
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||||
|
return base or "investor"
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(v) -> float | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).replace(",", "").replace("$", "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- ALLOC SI (eNAV investor roster) ---
|
||||||
|
|
||||||
|
def _parse_alloc_si(wb):
|
||||||
|
"""Return (as_of, investors) from an eNAV ALLOC SI sheet.
|
||||||
|
|
||||||
|
investors: [{name, external_id, commitment, contributions, distributions, ending}].
|
||||||
|
"""
|
||||||
|
ws = wb["ALLOC SI"]
|
||||||
|
rows = list(ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
header: list[str] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row]
|
||||||
|
if "INVESTOR ID" in cells and "INVESTOR NAME" in cells:
|
||||||
|
header_idx, header = i, cells
|
||||||
|
break
|
||||||
|
if header_idx is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Could not find the ALLOC SI header row.")
|
||||||
|
|
||||||
|
def col(*names: str) -> int | None:
|
||||||
|
for n in names:
|
||||||
|
if n in header:
|
||||||
|
return header.index(n)
|
||||||
|
return None
|
||||||
|
|
||||||
|
c_id = col("INVESTOR ID")
|
||||||
|
c_type = col("INVESTOR TYPE")
|
||||||
|
c_name = col("INVESTOR NAME")
|
||||||
|
c_commit = col("COMMITTED CAPITAL")
|
||||||
|
c_contrib = col("CONTRIBUTIONS")
|
||||||
|
c_distrib = col("(DISTRIBUTIONS)", "DISTRIBUTIONS")
|
||||||
|
c_ending = col("ENDING BALANCE")
|
||||||
|
if c_ending is None:
|
||||||
|
raise HTTPException(status_code=422, detail="ALLOC SI is missing an ENDING BALANCE column.")
|
||||||
|
|
||||||
|
def amount(row, ci):
|
||||||
|
if ci is None or ci >= len(row):
|
||||||
|
return 0.0
|
||||||
|
return _as_float(row[ci]) or 0.0
|
||||||
|
|
||||||
|
investors: list[dict] = []
|
||||||
|
for r in range(header_idx + 1, len(rows)):
|
||||||
|
row = rows[r]
|
||||||
|
name_raw = row[c_name] if c_name is not None and c_name < len(row) else None
|
||||||
|
if not (isinstance(name_raw, str) and name_raw.strip()):
|
||||||
|
continue
|
||||||
|
nm = name_raw.strip()
|
||||||
|
if nm.upper() in ("GP", "LP", "TOTAL"):
|
||||||
|
continue
|
||||||
|
itype = row[c_type] if c_type is not None and c_type < len(row) else None
|
||||||
|
if itype is not None and str(itype).strip().upper() not in ("LP", ""):
|
||||||
|
continue # investors only (skip the GP entity)
|
||||||
|
|
||||||
|
display = None
|
||||||
|
if c_name is not None and c_name + 1 < len(row) and isinstance(row[c_name + 1], str) and row[c_name + 1].strip():
|
||||||
|
display = row[c_name + 1].strip()
|
||||||
|
name = display or nm.title()
|
||||||
|
|
||||||
|
inv_id = row[c_id] if c_id is not None and c_id < len(row) else None
|
||||||
|
external_id = str(inv_id).strip() if inv_id not in (None, "") else None
|
||||||
|
|
||||||
|
investors.append({
|
||||||
|
"name": name,
|
||||||
|
"external_id": external_id,
|
||||||
|
"commitment": amount(row, c_commit),
|
||||||
|
"contributions": amount(row, c_contrib),
|
||||||
|
"distributions": abs(amount(row, c_distrib)), # sheet may show as a credit
|
||||||
|
"ending": amount(row, c_ending),
|
||||||
|
})
|
||||||
|
|
||||||
|
return _enav_as_of(wb), investors
|
||||||
|
|
||||||
|
|
||||||
|
# --- Subaccounts (names-across-columns) ---
|
||||||
|
|
||||||
|
def _parse_grid(wb):
|
||||||
|
ws = wb[wb.sheetnames[0]]
|
||||||
|
grid: list[list] = []
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True):
|
||||||
|
grid.append(list(row))
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
total_col = None
|
||||||
|
for i, row in enumerate(grid):
|
||||||
|
for j, cell in enumerate(row):
|
||||||
|
if isinstance(cell, str) and cell.strip().lower() == "total":
|
||||||
|
header_idx, total_col = i, j
|
||||||
|
break
|
||||||
|
if header_idx is not None:
|
||||||
|
break
|
||||||
|
if header_idx is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail='Could not find a header row with a "Total" column. Check the spreadsheet layout.',
|
||||||
|
)
|
||||||
|
|
||||||
|
header = grid[header_idx]
|
||||||
|
investor_cols = [
|
||||||
|
j for j in range(len(header))
|
||||||
|
if j < total_col
|
||||||
|
and isinstance(header[j], str)
|
||||||
|
and header[j].strip().lower() not in ("", "total")
|
||||||
|
]
|
||||||
|
if not investor_cols:
|
||||||
|
raise HTTPException(status_code=422, detail="No investor name columns found left of the Total column.")
|
||||||
|
|
||||||
|
as_of: date | None = None
|
||||||
|
for row in grid[: header_idx + 2]:
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, (datetime, date)):
|
||||||
|
as_of = cell.date() if isinstance(cell, datetime) else cell
|
||||||
|
break
|
||||||
|
if as_of:
|
||||||
|
break
|
||||||
|
|
||||||
|
value_rows: list[ImportValueRow] = []
|
||||||
|
for i in range(header_idx + 1, len(grid)):
|
||||||
|
row = grid[i]
|
||||||
|
label = row[0]
|
||||||
|
if not (isinstance(label, str) and label.strip()):
|
||||||
|
continue
|
||||||
|
if any(_as_float(row[j]) is not None for j in investor_cols if j < len(row)):
|
||||||
|
value_rows.append(ImportValueRow(row_index=i, label=label.strip()))
|
||||||
|
if not value_rows:
|
||||||
|
raise HTTPException(status_code=422, detail="No value rows found under the header.")
|
||||||
|
|
||||||
|
return header_idx, total_col, investor_cols, as_of, value_rows, grid
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
def preview_import(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_id: int | None = Form(None),
|
||||||
|
row_index: int | None = Form(None),
|
||||||
|
password: str | None = Form(None),
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CapitalImportPreview:
|
||||||
|
if entity_id is not None:
|
||||||
|
check_administrator_scope(admin, entity_id, session)
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=413, detail=str(exc))
|
||||||
|
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}
|
||||||
|
|
||||||
|
def match_for(name: str, external_id: str | None):
|
||||||
|
if external_id and external_id in by_extid:
|
||||||
|
return by_extid[external_id]
|
||||||
|
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||||
|
|
||||||
|
if "ALLOC SI" in wb.sheetnames:
|
||||||
|
as_of, roster = _parse_alloc_si(wb)
|
||||||
|
previews: list[ImportInvestorPreview] = []
|
||||||
|
for inv in roster:
|
||||||
|
m = match_for(inv["name"], inv["external_id"])
|
||||||
|
previews.append(ImportInvestorPreview(
|
||||||
|
source_name=inv["name"],
|
||||||
|
column_index=0,
|
||||||
|
value_dollars=inv["ending"],
|
||||||
|
commitment_dollars=inv["commitment"],
|
||||||
|
contributions_dollars=inv["contributions"],
|
||||||
|
distributions_dollars=inv["distributions"],
|
||||||
|
external_id=inv["external_id"],
|
||||||
|
matched_user_id=m.id if m else None,
|
||||||
|
matched_username=m.username if m else None,
|
||||||
|
suggested_username=None if m else _slug_username(inv["name"]),
|
||||||
|
))
|
||||||
|
# Roster mode: no per-row value picker needed.
|
||||||
|
return CapitalImportPreview(as_of_date=as_of, value_rows=[], chosen_row_index=0, investors=previews)
|
||||||
|
|
||||||
|
# Fallback: subaccounts layout (single value figure per investor).
|
||||||
|
header_idx, total_col, investor_cols, as_of, value_rows, grid = _parse_grid(wb)
|
||||||
|
valid_indexes = {vr.row_index for vr in value_rows}
|
||||||
|
chosen = row_index if row_index in valid_indexes else value_rows[0].row_index
|
||||||
|
chosen_row = grid[chosen]
|
||||||
|
|
||||||
|
previews = []
|
||||||
|
for col_i in investor_cols:
|
||||||
|
source_name = str(grid[header_idx][col_i]).strip()
|
||||||
|
value = _as_float(chosen_row[col_i]) if col_i < len(chosen_row) else None
|
||||||
|
m = match_for(source_name, None)
|
||||||
|
previews.append(ImportInvestorPreview(
|
||||||
|
source_name=source_name,
|
||||||
|
column_index=col_i,
|
||||||
|
value_dollars=value or 0.0,
|
||||||
|
matched_user_id=m.id if m else None,
|
||||||
|
matched_username=m.username if m else None,
|
||||||
|
suggested_username=None if m else _slug_username(source_name),
|
||||||
|
))
|
||||||
|
return CapitalImportPreview(as_of_date=as_of, value_rows=value_rows, chosen_row_index=chosen, investors=previews)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commit")
|
||||||
|
def commit_import(
|
||||||
|
body: CapitalImportCommit,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
check_administrator_scope(admin, body.entity_id, session)
|
||||||
|
entity = session.get(Entity, body.entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
created_accounts = 0
|
||||||
|
updated_accounts = 0
|
||||||
|
statements = 0
|
||||||
|
|
||||||
|
for inv in body.investors:
|
||||||
|
if inv.action == "skip":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if inv.action == "create":
|
||||||
|
if not inv.username or not inv.name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"New member '{inv.name or inv.username}' needs a name and username.",
|
||||||
|
)
|
||||||
|
if session.exec(select(User).where(User.username == inv.username)).first():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
|
||||||
|
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
|
||||||
|
# New members start on the shared default password (login enabled) so the admin
|
||||||
|
# can send credentials right away; each investor rotates it in the portal.
|
||||||
|
pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD
|
||||||
|
user = User(
|
||||||
|
name=inv.name,
|
||||||
|
username=inv.username,
|
||||||
|
email=inv.email or None,
|
||||||
|
password_hash=hash_password(pw),
|
||||||
|
role=UserRole.investor,
|
||||||
|
login_enabled=True,
|
||||||
|
external_investor_id=inv.external_id,
|
||||||
|
# First login forces a change while they're on the shared default.
|
||||||
|
must_change_password=(pw == config.DEFAULT_INVESTOR_PASSWORD),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
created_accounts += 1
|
||||||
|
elif inv.action == "match":
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown action '{inv.action}'.")
|
||||||
|
|
||||||
|
has_access = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user.id, EntityAccess.entity_id == body.entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if has_access is None:
|
||||||
|
session.add(EntityAccess(user_id=user.id, entity_id=body.entity_id))
|
||||||
|
|
||||||
|
cents = lambda d: round(d * 100)
|
||||||
|
existing = session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == body.entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == user.id,
|
||||||
|
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.commitment_cents = cents(inv.commitment_dollars)
|
||||||
|
existing.contributions_cents = cents(inv.contributions_dollars)
|
||||||
|
existing.distributions_cents = cents(inv.distributions_dollars)
|
||||||
|
existing.ending_balance_cents = cents(inv.value_dollars)
|
||||||
|
session.add(existing)
|
||||||
|
updated_accounts += 1
|
||||||
|
else:
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=body.entity_id,
|
||||||
|
investor_user_id=user.id,
|
||||||
|
as_of_date=body.as_of_date,
|
||||||
|
commitment_cents=cents(inv.commitment_dollars),
|
||||||
|
contributions_cents=cents(inv.contributions_dollars),
|
||||||
|
distributions_cents=cents(inv.distributions_dollars),
|
||||||
|
ending_balance_cents=cents(inv.value_dollars),
|
||||||
|
))
|
||||||
|
statements += 1
|
||||||
|
|
||||||
|
record_audit(session, admin.id, "import", "capital_account", body.entity_id, {
|
||||||
|
"as_of_date": str(body.as_of_date),
|
||||||
|
"created_accounts": created_accounts,
|
||||||
|
"statements": statements,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"created_accounts": created_accounts,
|
||||||
|
"matched_accounts_updated": updated_accounts,
|
||||||
|
"statements_written": statements,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch")
|
||||||
|
def batch_import(
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
entity_id: int = Form(...),
|
||||||
|
password: str | None = Form(None),
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> BatchCapitalImportResult:
|
||||||
|
"""Backfill several quarters of capital history for one fund from a batch of eNAV files.
|
||||||
|
|
||||||
|
Each file is a full eNAV workbook; its ALLOC SI roster is auto-matched (by fund-admin
|
||||||
|
investor ID, else name/username) against existing members and each member's capital
|
||||||
|
statement is upserted at the file's own as-of date — so older files add historical points
|
||||||
|
without touching the latest. Members not already in the system are skipped and reported
|
||||||
|
(no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is
|
||||||
|
reported per-file and does not abort the rest.
|
||||||
|
"""
|
||||||
|
check_administrator_scope(admin, entity_id, session)
|
||||||
|
entity = session.get(Entity, entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||||
|
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}
|
||||||
|
|
||||||
|
def match_for(name: str, external_id: str | None) -> User | None:
|
||||||
|
if external_id and external_id in by_extid:
|
||||||
|
return by_extid[external_id]
|
||||||
|
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||||
|
|
||||||
|
cents = lambda d: round((d or 0.0) * 100)
|
||||||
|
results: list[BatchCapitalFileResult] = []
|
||||||
|
total_statements = 0
|
||||||
|
|
||||||
|
for upload in files:
|
||||||
|
fname = upload.filename or "(unnamed)"
|
||||||
|
res = BatchCapitalFileResult(filename=fname)
|
||||||
|
try:
|
||||||
|
file_bytes = storage.read_capped(upload)
|
||||||
|
wb = _open_workbook(file_bytes, password)
|
||||||
|
if "ALLOC SI" not in wb.sheetnames:
|
||||||
|
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
|
||||||
|
as_of, roster = _parse_alloc_si(wb)
|
||||||
|
if as_of is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Could not determine the as-of date from the file.")
|
||||||
|
res.as_of_date = as_of
|
||||||
|
|
||||||
|
for inv in roster:
|
||||||
|
user = match_for(inv["name"], inv["external_id"])
|
||||||
|
if user is None:
|
||||||
|
res.skipped.append(inv["name"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Record the fund-admin ID on first sighting so later files match by ID too.
|
||||||
|
if inv["external_id"] and not user.external_investor_id:
|
||||||
|
user.external_investor_id = inv["external_id"]
|
||||||
|
by_extid[inv["external_id"]] = user
|
||||||
|
session.add(user)
|
||||||
|
|
||||||
|
has_access = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user.id, EntityAccess.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if has_access is None:
|
||||||
|
session.add(EntityAccess(user_id=user.id, entity_id=entity_id))
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == user.id,
|
||||||
|
CapitalAccountStatement.as_of_date == as_of,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.commitment_cents = cents(inv["commitment"])
|
||||||
|
existing.contributions_cents = cents(inv["contributions"])
|
||||||
|
existing.distributions_cents = cents(inv["distributions"])
|
||||||
|
existing.ending_balance_cents = cents(inv["ending"])
|
||||||
|
session.add(existing)
|
||||||
|
res.updated += 1
|
||||||
|
else:
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity_id,
|
||||||
|
investor_user_id=user.id,
|
||||||
|
as_of_date=as_of,
|
||||||
|
commitment_cents=cents(inv["commitment"]),
|
||||||
|
contributions_cents=cents(inv["contributions"]),
|
||||||
|
distributions_cents=cents(inv["distributions"]),
|
||||||
|
ending_balance_cents=cents(inv["ending"]),
|
||||||
|
))
|
||||||
|
res.matched += 1
|
||||||
|
res.statements_written += 1
|
||||||
|
|
||||||
|
# NAV history leg: record this quarter's valuation round from the file's HLD
|
||||||
|
# sheet, matched against today's book only (holdings are never modified). An
|
||||||
|
# HLD problem must not lose the member statements above, so it runs in a
|
||||||
|
# savepoint and reports per-file.
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
_, _, _, positions_prev, _ = _parse_schedule_xlsx(file_bytes, password)
|
||||||
|
if positions_prev:
|
||||||
|
hist = upsert_history_round(entity_id, as_of, positions_prev, admin, session)
|
||||||
|
res.nav_status = hist["status"]
|
||||||
|
res.nav_matched = hist["matched"]
|
||||||
|
res.nav_unmatched = hist["unmatched"]
|
||||||
|
res.nav_cents = hist["nav_cents"]
|
||||||
|
else:
|
||||||
|
res.nav_status = "no-hld"
|
||||||
|
except Exception: # noqa: BLE001 — the savepoint rolled back; members still land
|
||||||
|
res.nav_status = "error"
|
||||||
|
|
||||||
|
record_audit(session, admin.id, "import_batch", "capital_account", entity_id, {
|
||||||
|
"file": fname,
|
||||||
|
"as_of_date": str(as_of),
|
||||||
|
"matched": res.matched,
|
||||||
|
"skipped": len(res.skipped),
|
||||||
|
"nav_status": res.nav_status,
|
||||||
|
"nav_cents": res.nav_cents,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
total_statements += res.statements_written
|
||||||
|
except HTTPException as e:
|
||||||
|
# Our own controlled messages (bad password, no ALLOC SI, no date) are safe to show.
|
||||||
|
session.rollback()
|
||||||
|
res.error = e.detail
|
||||||
|
except storage.UploadTooLarge:
|
||||||
|
session.rollback()
|
||||||
|
res.error = "File is too large."
|
||||||
|
except Exception: # noqa: BLE001 — keep going on any parse failure; don't leak internals
|
||||||
|
session.rollback()
|
||||||
|
res.error = "Could not process this file (unexpected format or error)."
|
||||||
|
results.append(res)
|
||||||
|
|
||||||
|
return BatchCapitalImportResult(
|
||||||
|
entity_id=entity_id, files=results, total_statements=total_statements
|
||||||
|
)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Document upload, listing, download, and deletion with per-account access control."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
from ten31portal.auth import (
|
||||||
|
accessible_entity_ids, can_access_entity, get_current_user, household_user_ids,
|
||||||
|
)
|
||||||
|
from ten31portal.database import get_session
|
||||||
|
from ten31portal.models import (
|
||||||
|
Document, DocumentCategory, Entity, User, UserRole,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import DocumentResponse
|
||||||
|
from ten31portal import storage
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
|
def _can_upload(user: User, entity_id: int, session: Session) -> bool:
|
||||||
|
"""Internal admins upload anywhere; external fund admins upload for their entities."""
|
||||||
|
if user.role in (UserRole.approver, UserRole.cfo, UserRole.operations):
|
||||||
|
return True
|
||||||
|
if user.role == UserRole.fund_administrator:
|
||||||
|
return can_access_entity(user, entity_id, session)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view(user: User, doc: Document, session: Session) -> bool:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is None: # internal staff
|
||||||
|
return True
|
||||||
|
if doc.entity_id not in allowed:
|
||||||
|
return False
|
||||||
|
# Investors only see shared docs or those addressed to any of their linked names;
|
||||||
|
# fund admins see all docs for the entity.
|
||||||
|
if user.role == UserRole.investor:
|
||||||
|
return (
|
||||||
|
doc.investor_user_id is None
|
||||||
|
or doc.investor_user_id in household_user_ids(user, session)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_documents(
|
||||||
|
entity_id: int | None = None,
|
||||||
|
investor_user_id: int | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[DocumentResponse]:
|
||||||
|
query = select(Document)
|
||||||
|
if entity_id is not None:
|
||||||
|
query = query.where(Document.entity_id == entity_id)
|
||||||
|
if investor_user_id is not None:
|
||||||
|
query = query.where(Document.investor_user_id == investor_user_id)
|
||||||
|
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
|
||||||
|
visible = [d for d in rows if _can_view(user, d, session)]
|
||||||
|
|
||||||
|
# Badge docs that arrived since the investor's previous visit. Badges show on the first
|
||||||
|
# page-load after new documents arrive; the watermark advances at most once per 30 minutes
|
||||||
|
# so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing —
|
||||||
|
# everything would be "new".
|
||||||
|
seen_before = user.docs_seen_at if user.role == UserRole.investor else None
|
||||||
|
if user.role == UserRole.investor:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30):
|
||||||
|
user.docs_seen_at = now
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DocumentResponse.model_validate(d, from_attributes=True).model_copy(
|
||||||
|
update={"is_new": seen_before is not None and d.created_at > seen_before}
|
||||||
|
)
|
||||||
|
for d in visible
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def upload_document(
|
||||||
|
entity_id: int = Form(...),
|
||||||
|
category: DocumentCategory = Form(DocumentCategory.other),
|
||||||
|
title: str | None = Form(None),
|
||||||
|
investor_user_id: int | None = Form(None),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> DocumentResponse:
|
||||||
|
entity = session.get(Entity, entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if not _can_upload(user, entity_id, session):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
# If targeting a specific investor, they must have access to this entity.
|
||||||
|
if investor_user_id is not None:
|
||||||
|
target = session.get(User, investor_user_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Investor not found")
|
||||||
|
if not can_access_entity(target, entity_id, session):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="That investor does not have access to this entity.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_name, size = storage.save_upload(file)
|
||||||
|
except storage.UploadTooLarge as exc:
|
||||||
|
raise HTTPException(status_code=413, detail=str(exc))
|
||||||
|
doc = Document(
|
||||||
|
entity_id=entity_id,
|
||||||
|
investor_user_id=investor_user_id,
|
||||||
|
category=category,
|
||||||
|
title=title or (file.filename or "Untitled"),
|
||||||
|
original_filename=file.filename or storage_name,
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
size_bytes=size,
|
||||||
|
storage_path=storage_name,
|
||||||
|
uploaded_by=user.id,
|
||||||
|
)
|
||||||
|
session.add(doc)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, user.id, "upload", "document", doc.id, {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"investor_user_id": investor_user_id,
|
||||||
|
"category": category.value,
|
||||||
|
"filename": doc.original_filename,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(doc)
|
||||||
|
return DocumentResponse.model_validate(doc, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/download")
|
||||||
|
def download_document(
|
||||||
|
document_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
doc = session.get(Document, document_id)
|
||||||
|
if doc is None or not _can_view(user, doc, session):
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
try:
|
||||||
|
path = storage.full_path(doc.storage_path)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="File missing on disk")
|
||||||
|
return FileResponse(
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{document_id}")
|
||||||
|
def delete_document(
|
||||||
|
document_id: int,
|
||||||
|
admin: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
doc = session.get(Document, document_id)
|
||||||
|
if doc is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
# Same reach as upload: internal admins anywhere, an Administrator on their entities.
|
||||||
|
if not _can_upload(admin, doc.entity_id, session):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
storage.delete_file(doc.storage_path)
|
||||||
|
record_audit(session, admin.id, "delete", "document", document_id, {
|
||||||
|
"filename": doc.original_filename,
|
||||||
|
})
|
||||||
|
session.delete(doc)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -3,16 +3,24 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, literal
|
from sqlalchemy import func, literal
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer
|
from ten31portal.auth import (
|
||||||
|
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.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, EntityStatus, Holding, Position,
|
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
|
||||||
Valuation, ValuationRound, RoundStatus, User,
|
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse,
|
||||||
|
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerExitUpdate,
|
||||||
|
PartnerResponse,
|
||||||
)
|
)
|
||||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||||
|
|
||||||
@@ -24,6 +32,7 @@ class EntityRollupItem(BaseModel):
|
|||||||
vintage_year: int | None
|
vintage_year: int | None
|
||||||
fund_size_cents: int | None
|
fund_size_cents: int | None
|
||||||
status: str
|
status: str
|
||||||
|
committed_cents: int # total LP commitments (latest per investor)
|
||||||
invested_cents: int
|
invested_cents: int
|
||||||
last_signed_value_cents: int
|
last_signed_value_cents: int
|
||||||
|
|
||||||
@@ -34,7 +43,10 @@ def entity_rollup(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[EntityRollupItem]:
|
) -> list[EntityRollupItem]:
|
||||||
"""Per-entity invested and last-signed-value in a single pass."""
|
"""Per-entity invested and last-signed-value in a single pass."""
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
entities = session.exec(select(Entity)).all()
|
entities = session.exec(select(Entity)).all()
|
||||||
|
if allowed is not None:
|
||||||
|
entities = [e for e in entities if e.id in allowed]
|
||||||
result: list[EntityRollupItem] = []
|
result: list[EntityRollupItem] = []
|
||||||
|
|
||||||
for ent in entities:
|
for ent in entities:
|
||||||
@@ -64,6 +76,28 @@ def entity_rollup(
|
|||||||
).one()
|
).one()
|
||||||
last_signed_value_cents = int(val_sum)
|
last_signed_value_cents = int(val_sum)
|
||||||
|
|
||||||
|
# Total committed capital = each investor's most recent commitment for this entity.
|
||||||
|
# Exited members (stake sold/transferred) are skipped — their buyer's commitment now
|
||||||
|
# appears on the roster, so counting both would double the fund's committed total.
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(CapitalAccountStatement.entity_id == ent.id)
|
||||||
|
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||||
|
).all()
|
||||||
|
exited_ids = set(session.exec(
|
||||||
|
select(EntityAccess.user_id).where(
|
||||||
|
EntityAccess.entity_id == ent.id,
|
||||||
|
col(EntityAccess.exited_on).is_not(None),
|
||||||
|
)
|
||||||
|
).all())
|
||||||
|
committed_cents = 0
|
||||||
|
seen_investors: set[int] = set(exited_ids)
|
||||||
|
for st in stmts:
|
||||||
|
if st.investor_user_id in seen_investors:
|
||||||
|
continue
|
||||||
|
seen_investors.add(st.investor_user_id)
|
||||||
|
committed_cents += st.commitment_cents
|
||||||
|
|
||||||
result.append(EntityRollupItem(
|
result.append(EntityRollupItem(
|
||||||
id=ent.id,
|
id=ent.id,
|
||||||
name=ent.name,
|
name=ent.name,
|
||||||
@@ -71,6 +105,7 @@ def entity_rollup(
|
|||||||
vintage_year=ent.vintage_year,
|
vintage_year=ent.vintage_year,
|
||||||
fund_size_cents=ent.fund_size_cents,
|
fund_size_cents=ent.fund_size_cents,
|
||||||
status=ent.status.value,
|
status=ent.status.value,
|
||||||
|
committed_cents=committed_cents,
|
||||||
invested_cents=invested_cents,
|
invested_cents=invested_cents,
|
||||||
last_signed_value_cents=last_signed_value_cents,
|
last_signed_value_cents=last_signed_value_cents,
|
||||||
))
|
))
|
||||||
@@ -78,12 +113,134 @@ def entity_rollup(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_id}/partners")
|
||||||
|
def list_partners(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(require_internal_or_administrator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[PartnerResponse]:
|
||||||
|
"""Members (investors) granted access to this entity, with their latest capital value."""
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
members = session.exec(
|
||||||
|
select(User, EntityAccess)
|
||||||
|
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||||
|
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||||
|
.order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result: list[PartnerResponse] = []
|
||||||
|
for m, access in members:
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(
|
||||||
|
CapitalAccountStatement.entity_id == entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == m.id,
|
||||||
|
)
|
||||||
|
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||||
|
).all()
|
||||||
|
latest = stmts[0] if stmts else None
|
||||||
|
result.append(PartnerResponse(
|
||||||
|
user_id=m.id,
|
||||||
|
name=m.name,
|
||||||
|
username=m.username,
|
||||||
|
external_investor_id=m.external_investor_id,
|
||||||
|
is_active=m.is_active,
|
||||||
|
login_enabled=m.login_enabled,
|
||||||
|
latest_commitment_cents=latest.commitment_cents if latest else None,
|
||||||
|
latest_contributions_cents=latest.contributions_cents if latest else None,
|
||||||
|
latest_distributions_cents=latest.distributions_cents if latest else None,
|
||||||
|
latest_value_cents=latest.ending_balance_cents if latest else None,
|
||||||
|
latest_as_of=latest.as_of_date if latest else None,
|
||||||
|
statements_count=len(stmts),
|
||||||
|
exited_on=access.exited_on,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{entity_id}/partners/{user_id}/exited")
|
||||||
|
def set_partner_exited(
|
||||||
|
entity_id: int,
|
||||||
|
user_id: int,
|
||||||
|
body: PartnerExitUpdate,
|
||||||
|
admin: User = Depends(require_entity_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PartnerResponse:
|
||||||
|
"""Mark a member as exited from this fund (stake sold/transferred), or clear it.
|
||||||
|
|
||||||
|
Their statements and documents stay; the portal shows an Exited badge instead of a
|
||||||
|
phantom -100% and drops the position from portfolio and fund committed totals.
|
||||||
|
"""
|
||||||
|
check_administrator_scope(admin, entity_id, session)
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
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(
|
||||||
|
EntityAccess.entity_id == entity_id, EntityAccess.user_id == user_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if access is None:
|
||||||
|
# A manually-entered investor may have statements without an access grant yet
|
||||||
|
# (e.g. from the Capital Accounts screen). Marking them exited creates the roster
|
||||||
|
# row with the flag set; clearing an exit that doesn't exist stays a 404.
|
||||||
|
if body.exited_on is None:
|
||||||
|
raise HTTPException(status_code=404, detail="That member has no access to this fund")
|
||||||
|
access = EntityAccess(user_id=user_id, entity_id=entity_id)
|
||||||
|
session.add(access)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
access.exited_on = body.exited_on
|
||||||
|
session.add(access)
|
||||||
|
record_audit(session, admin.id, "set_exited", "entity_access", access.id, {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"exited_on": str(body.exited_on) if body.exited_on else None,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Return the member's refreshed partner row for easy UI updates.
|
||||||
|
return next(p for p in list_partners(entity_id, admin, session) if p.user_id == user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_id}/partners")
|
||||||
|
def clear_partners(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(require_entity_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Remove all partners from this fund — deletes its capital-account statements and the
|
||||||
|
investors' access grants, but keeps the investor accounts (they belong to other funds).
|
||||||
|
For undoing a wrong members import. Holdings/NAV are not affected."""
|
||||||
|
# Local import avoids a module-load cycle (capital_import_router imports import_router).
|
||||||
|
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||||
|
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
res = reset_entity_partners(entity_id, session)
|
||||||
|
record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
|
||||||
|
session.commit()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_entities(
|
def list_entities(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[EntityResponse]:
|
) -> list[EntityResponse]:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
rows = session.exec(select(Entity)).all()
|
rows = session.exec(select(Entity)).all()
|
||||||
|
if allowed is not None:
|
||||||
|
rows = [r for r in rows if r.id in allowed]
|
||||||
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
|
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -93,18 +250,31 @@ def get_entity(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> EntityResponse:
|
) -> EntityResponse:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is not None and entity_id not in allowed:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_linked_user(linked_user_id: int | None, session: Session) -> None:
|
||||||
|
"""A linked account (for a GP entity that is also an LP) must be an investor account."""
|
||||||
|
if linked_user_id is None:
|
||||||
|
return
|
||||||
|
linked = session.get(User, linked_user_id)
|
||||||
|
if linked is None or linked.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Linked account must be an investor account.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_entity(
|
def create_entity(
|
||||||
body: EntityCreate,
|
body: EntityCreate,
|
||||||
user: User = Depends(require_writer),
|
user: User = Depends(require_writer),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> EntityResponse:
|
) -> EntityResponse:
|
||||||
|
_validate_linked_user(body.linked_user_id, session)
|
||||||
entity = Entity(**body.model_dump())
|
entity = Entity(**body.model_dump())
|
||||||
session.add(entity)
|
session.add(entity)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -118,18 +288,161 @@ def create_entity(
|
|||||||
def update_entity(
|
def update_entity(
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
body: EntityUpdate,
|
body: EntityUpdate,
|
||||||
user: User = Depends(require_writer),
|
user: User = Depends(require_entity_writer),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> EntityResponse:
|
) -> EntityResponse:
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
changes = body.model_dump(exclude_unset=True)
|
changes = body.model_dump(exclude_unset=True)
|
||||||
|
if "linked_user_id" in changes:
|
||||||
|
_validate_linked_user(changes["linked_user_id"], session)
|
||||||
for key, val in changes.items():
|
for key, val in changes.items():
|
||||||
setattr(entity, key, val)
|
setattr(entity, key, val)
|
||||||
session.add(entity)
|
session.add(entity)
|
||||||
session.flush()
|
session.flush()
|
||||||
record_audit(session, user.id, "update", "entity", entity.id, changes)
|
# mode="json" so date fields (close_date) serialize into the audit JSON column.
|
||||||
|
record_audit(session, user.id, "update", "entity", entity.id,
|
||||||
|
body.model_dump(exclude_unset=True, mode="json"))
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(entity)
|
session.refresh(entity)
|
||||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_id}/asset-balances")
|
||||||
|
def asset_balances(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(require_internal_or_administrator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AssetBalancesResponse:
|
||||||
|
"""A GP/mgmt entity's assets = the linked account's capital balances across the funds.
|
||||||
|
|
||||||
|
Household-aware: if the linked account has other legal names linked to it (as the eNAV
|
||||||
|
often splits one LLC across names), their balances are included too.
|
||||||
|
"""
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
|
entity = session.get(Entity, entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if entity.linked_user_id is None:
|
||||||
|
return AssetBalancesResponse()
|
||||||
|
linked = session.get(User, entity.linked_user_id)
|
||||||
|
if linked is None:
|
||||||
|
return AssetBalancesResponse(linked_user_id=entity.linked_user_id)
|
||||||
|
|
||||||
|
household = household_user_ids(linked, session)
|
||||||
|
rows = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(col(CapitalAccountStatement.investor_user_id).in_(household))
|
||||||
|
.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||||
|
).all()
|
||||||
|
# An Administrator only sees the slice of the linked account's balances that sits
|
||||||
|
# in funds granted to them — the linked account may also invest elsewhere.
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is not None:
|
||||||
|
rows = [r for r in rows if r.entity_id in allowed]
|
||||||
|
names = dict(session.exec(
|
||||||
|
select(User.id, User.name).where(col(User.id).in_({r.investor_user_id for r in rows}))
|
||||||
|
).all()) if rows else {}
|
||||||
|
balances: list[CapitalAccountResponse] = []
|
||||||
|
for r in rows:
|
||||||
|
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
|
d.investor_name = names.get(r.investor_user_id)
|
||||||
|
balances.append(d)
|
||||||
|
return AssetBalancesResponse(
|
||||||
|
linked_user_id=linked.id, linked_name=linked.name, balances=balances,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages ---
|
||||||
|
|
||||||
|
def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse:
|
||||||
|
data = EntityStakeResponse.model_validate(stake, from_attributes=True)
|
||||||
|
fund = funds.get(stake.fund_entity_id)
|
||||||
|
if fund is not None:
|
||||||
|
data.fund_name = fund.name
|
||||||
|
data.fund_type = fund.type
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_id}/stakes")
|
||||||
|
def list_stakes(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[EntityStakeResponse]:
|
||||||
|
"""The funds this entity holds a stake in (e.g. a GP's interest in its funds)."""
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is not None and entity_id not in allowed:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
rows = session.exec(
|
||||||
|
select(EntityStake).where(EntityStake.holder_entity_id == entity_id)
|
||||||
|
).all()
|
||||||
|
funds = {
|
||||||
|
f.id: f for f in session.exec(
|
||||||
|
select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows}))
|
||||||
|
).all()
|
||||||
|
} if rows else {}
|
||||||
|
return [_stake_response(r, funds) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{entity_id}/stakes", status_code=201)
|
||||||
|
def create_stake(
|
||||||
|
entity_id: int,
|
||||||
|
body: EntityStakeCreate,
|
||||||
|
user: User = Depends(require_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> EntityStakeResponse:
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if body.fund_entity_id == entity_id:
|
||||||
|
raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.")
|
||||||
|
fund = session.get(Entity, body.fund_entity_id)
|
||||||
|
if fund is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Fund not found")
|
||||||
|
if session.exec(
|
||||||
|
select(EntityStake).where(
|
||||||
|
EntityStake.holder_entity_id == entity_id,
|
||||||
|
EntityStake.fund_entity_id == body.fund_entity_id,
|
||||||
|
)
|
||||||
|
).first():
|
||||||
|
raise HTTPException(status_code=409, detail="A stake in this fund already exists.")
|
||||||
|
|
||||||
|
stake = EntityStake(
|
||||||
|
holder_entity_id=entity_id,
|
||||||
|
fund_entity_id=body.fund_entity_id,
|
||||||
|
ownership_pct=body.ownership_pct,
|
||||||
|
value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None,
|
||||||
|
note=body.note,
|
||||||
|
)
|
||||||
|
session.add(stake)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, user.id, "create", "entity_stake", stake.id, {
|
||||||
|
"holder_entity_id": entity_id,
|
||||||
|
"fund_entity_id": body.fund_entity_id,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(stake)
|
||||||
|
return _stake_response(stake, {fund.id: fund})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_id}/stakes/{stake_id}")
|
||||||
|
def delete_stake(
|
||||||
|
entity_id: int,
|
||||||
|
stake_id: int,
|
||||||
|
user: User = Depends(require_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
stake = session.get(EntityStake, stake_id)
|
||||||
|
if stake is None or stake.holder_entity_id != entity_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Stake not found")
|
||||||
|
record_audit(session, user.id, "delete", "entity_stake", stake_id, {
|
||||||
|
"holder_entity_id": entity_id,
|
||||||
|
"fund_entity_id": stake.fund_entity_id,
|
||||||
|
})
|
||||||
|
session.delete(stake)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer
|
from ten31portal.auth import (
|
||||||
|
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||||
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import Entity, Holding, Position, User
|
from ten31portal.models import Entity, Holding, Position, User
|
||||||
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
|
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
|
||||||
@@ -15,9 +17,10 @@ router = APIRouter(tags=["holdings"])
|
|||||||
@router.get("/api/entities/{entity_id}/holdings")
|
@router.get("/api/entities/{entity_id}/holdings")
|
||||||
def list_holdings(
|
def list_holdings(
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal_or_administrator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[HoldingResponse]:
|
) -> list[HoldingResponse]:
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer
|
from ten31portal.auth import (
|
||||||
|
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||||
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
|
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
|
||||||
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
|
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
|
||||||
@@ -22,12 +24,13 @@ def _dollars_to_cents(dollars: float) -> int:
|
|||||||
@router.get("/api/holdings/{holding_id}/positions")
|
@router.get("/api/holdings/{holding_id}/positions")
|
||||||
def list_positions(
|
def list_positions(
|
||||||
holding_id: int,
|
holding_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal_or_administrator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[PositionResponse]:
|
) -> list[PositionResponse]:
|
||||||
holding = session.get(Holding, holding_id)
|
holding = session.get(Holding, holding_id)
|
||||||
if holding is None:
|
if holding is None:
|
||||||
raise HTTPException(status_code=404, detail="Holding not found")
|
raise HTTPException(status_code=404, detail="Holding not found")
|
||||||
|
check_administrator_scope(user, holding.entity_id, session)
|
||||||
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
|
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
|
||||||
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
|
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlmodel import Session, select, col
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer, require_approver
|
from ten31portal.auth import (
|
||||||
|
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||||
|
require_approver,
|
||||||
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, Holding, Position, Valuation, ValuationRound,
|
Entity, Holding, Position, Valuation, ValuationRound,
|
||||||
@@ -30,9 +33,10 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
|
|||||||
@router.get("/api/entities/{entity_id}/rounds")
|
@router.get("/api/entities/{entity_id}/rounds")
|
||||||
def list_rounds(
|
def list_rounds(
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal_or_administrator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[RoundResponse]:
|
) -> list[RoundResponse]:
|
||||||
|
check_administrator_scope(user, entity_id, session)
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
@@ -47,12 +51,13 @@ def list_rounds(
|
|||||||
@router.get("/api/rounds/{round_id}")
|
@router.get("/api/rounds/{round_id}")
|
||||||
def get_round(
|
def get_round(
|
||||||
round_id: int,
|
round_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal_or_administrator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> RoundResponse:
|
) -> RoundResponse:
|
||||||
round = session.get(ValuationRound, round_id)
|
round = session.get(ValuationRound, round_id)
|
||||||
if round is None:
|
if round is None:
|
||||||
raise HTTPException(status_code=404, detail="Round not found")
|
raise HTTPException(status_code=404, detail="Round not found")
|
||||||
|
check_administrator_scope(user, round.entity_id, session)
|
||||||
return _round_response(round, session)
|
return _round_response(round, session)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
"""User administration: create and manage accounts and their entity access."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
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,
|
||||||
|
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 (
|
||||||
|
AuditLog, CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES,
|
||||||
|
User, UserRole, ValuationRound,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
|
||||||
|
DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset,
|
||||||
|
UserCreate, UserDetailResponse, UserResponse, UserUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Administrator (external, entity-scoped) visibility helpers ---
|
||||||
|
|
||||||
|
def _visible_investor_ids(scope: set[int], session: Session) -> set[int]:
|
||||||
|
"""Investors an Administrator may see and manage: anyone with an access grant or a
|
||||||
|
capital-account statement in one of the Administrator's entities."""
|
||||||
|
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_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. 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
|
||||||
|
check_target_investor_scope(admin, target.id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_cascade(session: Session, user: User) -> list[str]:
|
||||||
|
"""Delete a user and its dependent rows; returns warnings for files that would not delete.
|
||||||
|
|
||||||
|
Shared by the API endpoint and the CLI. The caller is responsible for the guards
|
||||||
|
(Service Admin, self-deletion, scope) and for committing the session.
|
||||||
|
"""
|
||||||
|
from ten31portal import storage
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
uid = user.id
|
||||||
|
|
||||||
|
# Entity-access grants and capital-account statements are this user's own data.
|
||||||
|
for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all():
|
||||||
|
session.delete(acc)
|
||||||
|
for stmt in session.exec(
|
||||||
|
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid)
|
||||||
|
).all():
|
||||||
|
session.delete(stmt)
|
||||||
|
|
||||||
|
# Documents addressed privately to this investor are removed (file + row); documents
|
||||||
|
# they uploaded stay, with the uploader cleared.
|
||||||
|
for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all():
|
||||||
|
try:
|
||||||
|
storage.delete_file(doc.storage_path)
|
||||||
|
except OSError as exc:
|
||||||
|
warnings.append(f"could not delete file {doc.storage_path}: {exc}")
|
||||||
|
session.delete(doc)
|
||||||
|
for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all():
|
||||||
|
doc.uploaded_by = None
|
||||||
|
session.add(doc)
|
||||||
|
|
||||||
|
# Preserve history/rounds by clearing the references to this user.
|
||||||
|
for rnd in session.exec(
|
||||||
|
select(ValuationRound).where(
|
||||||
|
(ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid)
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
if rnd.submitted_by == uid:
|
||||||
|
rnd.submitted_by = None
|
||||||
|
if rnd.approved_by == uid:
|
||||||
|
rnd.approved_by = None
|
||||||
|
session.add(rnd)
|
||||||
|
for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all():
|
||||||
|
log.actor_user_id = None
|
||||||
|
session.add(log)
|
||||||
|
|
||||||
|
# Detach any linked sub-accounts so they log in on their own again.
|
||||||
|
for sub in session.exec(select(User).where(User.primary_account_id == uid)).all():
|
||||||
|
sub.primary_account_id = None
|
||||||
|
session.add(sub)
|
||||||
|
|
||||||
|
session.delete(user)
|
||||||
|
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),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccessMatrixResponse:
|
||||||
|
"""External accounts, all entities, and the grants linking them.
|
||||||
|
|
||||||
|
For an external Administrator the matrix is their slice of the world: only their
|
||||||
|
granted entities, and only the investors tied to those entities.
|
||||||
|
"""
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||||
|
grants = session.exec(select(EntityAccess)).all()
|
||||||
|
scope = accessible_entity_ids(admin, session)
|
||||||
|
if scope is not None:
|
||||||
|
visible = _visible_investor_ids(scope, session)
|
||||||
|
users = [u for u in users if u.id in visible]
|
||||||
|
entities = [e for e in entities if e.id in scope]
|
||||||
|
grants = [g for g in grants if g.entity_id in scope and g.user_id in visible]
|
||||||
|
return AccessMatrixResponse(
|
||||||
|
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
|
||||||
|
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||||
|
grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/access/{entity_id}", status_code=200)
|
||||||
|
def grant_access(
|
||||||
|
user_id: int,
|
||||||
|
entity_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
check_administrator_scope(admin, entity_id, session)
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
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(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is None:
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||||
|
record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id})
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}/access/{entity_id}")
|
||||||
|
def revoke_access(
|
||||||
|
user_id: int,
|
||||||
|
entity_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
check_administrator_scope(admin, entity_id, session)
|
||||||
|
if admin.role == UserRole.fund_administrator:
|
||||||
|
target = session.get(User, user_id)
|
||||||
|
if target is not None and target.role != UserRole.investor:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Administrators manage investor accounts only"
|
||||||
|
)
|
||||||
|
existing = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
session.delete(existing)
|
||||||
|
record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id})
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/investors-for-entity/{entity_id}")
|
||||||
|
def investors_for_entity(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[UserResponse]:
|
||||||
|
"""Investor accounts with access to an entity. For internal staff and the entity's fund admins."""
|
||||||
|
if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not (
|
||||||
|
user.role == UserRole.fund_administrator
|
||||||
|
and can_access_entity(user, entity_id, session)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
rows = session.exec(
|
||||||
|
select(User)
|
||||||
|
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||||
|
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||||
|
.order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/investor-view")
|
||||||
|
def investor_view(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> InvestorViewResponse:
|
||||||
|
"""Reconstruct exactly what an investor sees in their portal — read-only, for admins.
|
||||||
|
|
||||||
|
No session impersonation: this returns the same data the investor's own portal would load
|
||||||
|
(their accessible entities, their capital statements, and the documents visible to them),
|
||||||
|
scoped with the same access helpers.
|
||||||
|
"""
|
||||||
|
target = session.get(User, user_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if target.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Investor View is for investor accounts.")
|
||||||
|
|
||||||
|
allowed = accessible_entity_ids(target, session) or set()
|
||||||
|
household = household_user_ids(target, session)
|
||||||
|
|
||||||
|
entities = session.exec(
|
||||||
|
select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type]
|
||||||
|
).all() if allowed else []
|
||||||
|
|
||||||
|
caps: list[CapitalAccountResponse] = []
|
||||||
|
docs: list[DocumentResponse] = []
|
||||||
|
if allowed:
|
||||||
|
cap_rows = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(
|
||||||
|
col(CapitalAccountStatement.investor_user_id).in_(household),
|
||||||
|
col(CapitalAccountStatement.entity_id).in_(allowed),
|
||||||
|
)
|
||||||
|
.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||||
|
).all()
|
||||||
|
names = dict(session.exec(
|
||||||
|
select(User.id, User.name).where(
|
||||||
|
col(User.id).in_({r.investor_user_id for r in cap_rows})
|
||||||
|
)
|
||||||
|
).all()) if cap_rows else {}
|
||||||
|
# Mirror the investor's own portal exactly — including exit status and BTC marks,
|
||||||
|
# so this view never drifts from what the LP actually sees.
|
||||||
|
from ten31portal.routers.capital_account_router import btc_marks, exit_dates
|
||||||
|
exits = exit_dates(session, cap_rows)
|
||||||
|
btc_asof, btc_close = btc_marks(session, cap_rows)
|
||||||
|
for r in cap_rows:
|
||||||
|
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
|
d.investor_name = names.get(r.investor_user_id)
|
||||||
|
d.exited_on = exits.get((r.investor_user_id, r.entity_id))
|
||||||
|
d.btc_price_cents = btc_asof.get(r.id)
|
||||||
|
d.btc_close_price_cents = btc_close.get(r.entity_id)
|
||||||
|
caps.append(d)
|
||||||
|
|
||||||
|
doc_rows = session.exec(
|
||||||
|
select(Document)
|
||||||
|
.where(col(Document.entity_id).in_(allowed))
|
||||||
|
.order_by(col(Document.created_at).desc())
|
||||||
|
).all()
|
||||||
|
# Investor sees shared docs and those addressed to any of their linked names.
|
||||||
|
docs = [
|
||||||
|
DocumentResponse.model_validate(d, from_attributes=True)
|
||||||
|
for d in doc_rows
|
||||||
|
if d.investor_user_id is None or d.investor_user_id in household
|
||||||
|
]
|
||||||
|
|
||||||
|
return InvestorViewResponse(
|
||||||
|
user=UserResponse.model_validate(target, from_attributes=True),
|
||||||
|
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||||
|
capital_accounts=caps,
|
||||||
|
documents=docs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_ids_for(user_id: int, session: Session) -> list[int]:
|
||||||
|
return list(session.exec(
|
||||||
|
select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id)
|
||||||
|
).all())
|
||||||
|
|
||||||
|
|
||||||
|
def _user_detail(user: User, session: Session) -> UserDetailResponse:
|
||||||
|
"""Build a full user detail, including the linked-account relationships."""
|
||||||
|
data = UserResponse.model_validate(user, from_attributes=True).model_dump()
|
||||||
|
primary_name = None
|
||||||
|
if user.primary_account_id:
|
||||||
|
primary = session.get(User, user.primary_account_id)
|
||||||
|
primary_name = primary.name if primary else None
|
||||||
|
linked = session.exec(
|
||||||
|
select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
return UserDetailResponse(
|
||||||
|
**data,
|
||||||
|
primary_account_name=primary_name,
|
||||||
|
linked_accounts=[
|
||||||
|
LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked
|
||||||
|
],
|
||||||
|
entity_ids=_entity_ids_for(user.id, session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None:
|
||||||
|
"""Replace a user's entity grants with the given set, ignoring unknown ids."""
|
||||||
|
valid = set(session.exec(
|
||||||
|
select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr]
|
||||||
|
).all()) if entity_ids else set()
|
||||||
|
existing = session.exec(
|
||||||
|
select(EntityAccess).where(EntityAccess.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
current = {a.entity_id: a for a in existing}
|
||||||
|
# Remove grants no longer wanted.
|
||||||
|
for eid, access in current.items():
|
||||||
|
if eid not in valid:
|
||||||
|
session.delete(access)
|
||||||
|
# Add new grants.
|
||||||
|
for eid in valid:
|
||||||
|
if eid not in current:
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=eid))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_users(
|
||||||
|
# Read-widened: a view-only Administrator may list the investors of its funds (it needs
|
||||||
|
# their names on the capital-accounts and documents screens); every mutating endpoint
|
||||||
|
# below keeps the stricter require_admin gate.
|
||||||
|
admin: User = Depends(require_role(
|
||||||
|
UserRole.approver, UserRole.cfo, UserRole.operations,
|
||||||
|
UserRole.fund_administrator, UserRole.administrator_viewer,
|
||||||
|
)),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[UserResponse]:
|
||||||
|
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
||||||
|
scope = accessible_entity_ids(admin, session)
|
||||||
|
if scope is not None:
|
||||||
|
visible = _visible_investor_ids(scope, session)
|
||||||
|
rows = [r for r in rows if r.role == UserRole.investor and r.id in visible]
|
||||||
|
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}")
|
||||||
|
def get_user(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
_check_target_in_scope(admin, user, session)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
entity_ids = body.entity_ids
|
||||||
|
scope = accessible_entity_ids(admin, session)
|
||||||
|
if scope is not None:
|
||||||
|
# An Administrator creates investor accounts only, and only on their own funds —
|
||||||
|
# anything else would be privilege escalation or an account they can't see again.
|
||||||
|
if body.role != UserRole.investor:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Administrators can only create investor accounts"
|
||||||
|
)
|
||||||
|
entity_ids = [e for e in entity_ids if e in scope]
|
||||||
|
if not entity_ids:
|
||||||
|
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():
|
||||||
|
raise HTTPException(status_code=409, detail="Email already in use")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
name=body.name,
|
||||||
|
username=body.username,
|
||||||
|
email=body.email or None,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
role=body.role,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
_set_entity_access(user.id, entity_ids, session)
|
||||||
|
record_audit(session, admin.id, "create", "user", user.id,
|
||||||
|
{"username": body.username, "role": body.role.value,
|
||||||
|
"entity_ids": entity_ids})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UserUpdate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
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)
|
||||||
|
|
||||||
|
scope = accessible_entity_ids(admin, session)
|
||||||
|
if scope is not None:
|
||||||
|
if "role" in changes and changes["role"] != user.role:
|
||||||
|
raise HTTPException(status_code=403, detail="Administrators cannot change roles")
|
||||||
|
changes.pop("role", None)
|
||||||
|
if entity_ids is not None:
|
||||||
|
# Only this Administrator's funds are theirs to grant or revoke; the investor's
|
||||||
|
# access to any other fund is preserved untouched.
|
||||||
|
kept_elsewhere = set(_entity_ids_for(user_id, session)) - scope
|
||||||
|
entity_ids = list((set(entity_ids) & scope) | kept_elsewhere)
|
||||||
|
|
||||||
|
if "username" in changes:
|
||||||
|
new_username = (changes["username"] or "").strip()
|
||||||
|
if not new_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username cannot be blank")
|
||||||
|
clash = session.exec(select(User).where(User.username == new_username)).first()
|
||||||
|
if clash and clash.id != user_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already taken")
|
||||||
|
changes["username"] = new_username
|
||||||
|
|
||||||
|
if "email" in changes and changes["email"]:
|
||||||
|
clash = session.exec(select(User).where(User.email == changes["email"])).first()
|
||||||
|
if clash and clash.id != user_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Email already in use")
|
||||||
|
|
||||||
|
for key, val in changes.items():
|
||||||
|
setattr(user, key, val)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if entity_ids is not None:
|
||||||
|
_set_entity_access(user_id, entity_ids, session)
|
||||||
|
|
||||||
|
record_audit(session, admin.id, "update", "user", user_id,
|
||||||
|
{**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/primary-account")
|
||||||
|
def link_account(
|
||||||
|
user_id: int,
|
||||||
|
body: AccountLink,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
"""Link an investor account to a primary login (or detach it when null).
|
||||||
|
|
||||||
|
The primary becomes the single sign-on that sees every linked name's investments. The
|
||||||
|
linked account's own login is disabled so there is one set of credentials per person.
|
||||||
|
"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
_check_target_in_scope(admin, user, session)
|
||||||
|
if user.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
|
||||||
|
|
||||||
|
primary_id = body.primary_account_id
|
||||||
|
if primary_id is not None:
|
||||||
|
if primary_id == user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="An account cannot link to itself.")
|
||||||
|
primary = session.get(User, primary_id)
|
||||||
|
if primary is None or primary.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
|
||||||
|
_check_target_in_scope(admin, primary, session)
|
||||||
|
if primary.primary_account_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="That account is itself linked to another login. Link to a primary instead.",
|
||||||
|
)
|
||||||
|
# Prevent chains: an account that other names log in under can't become a secondary.
|
||||||
|
if session.exec(select(User).where(User.primary_account_id == user_id)).first():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This account is a primary for other names. Detach those first.",
|
||||||
|
)
|
||||||
|
# Login is blocked while primary_account_id is set (see auth_router.login), so we leave
|
||||||
|
# login_enabled untouched — unlinking then restores the account's own sign-in cleanly.
|
||||||
|
user.primary_account_id = primary_id
|
||||||
|
else:
|
||||||
|
user.primary_account_id = None
|
||||||
|
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, admin.id, "link_account", "user", user_id,
|
||||||
|
{"primary_account_id": primary_id})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reset-password")
|
||||||
|
def reset_password(
|
||||||
|
user_id: int,
|
||||||
|
body: PasswordReset,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
_check_target_in_scope(admin, user, session)
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
# Admin handed them a real password — no forced change on next login.
|
||||||
|
user.must_change_password = False
|
||||||
|
user.login_enabled = True # setting a password enables login
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, admin.id, "reset_password", "user", user_id, None)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete an account and its dependent data (grants, statements, private documents).
|
||||||
|
|
||||||
|
The Service Admin can never be deleted, nor can you delete yourself. An external
|
||||||
|
Administrator may only delete an investor who belongs solely to their own funds —
|
||||||
|
an investor who also sits in another fund is another manager's problem too.
|
||||||
|
"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if user.is_service_admin:
|
||||||
|
raise HTTPException(status_code=400, detail="The Service Admin cannot be deleted")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
|
_check_target_in_scope(admin, user, session)
|
||||||
|
|
||||||
|
scope = accessible_entity_ids(admin, session)
|
||||||
|
if scope is not None:
|
||||||
|
footprint = set(_entity_ids_for(user_id, session))
|
||||||
|
footprint |= set(session.exec(
|
||||||
|
select(CapitalAccountStatement.entity_id).where(
|
||||||
|
CapitalAccountStatement.investor_user_id == user_id
|
||||||
|
)
|
||||||
|
).all())
|
||||||
|
if not footprint <= scope:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="This investor also belongs to funds outside your access; "
|
||||||
|
"remove them from your funds instead of deleting the account.",
|
||||||
|
)
|
||||||
|
|
||||||
|
name, username = user.name, user.username
|
||||||
|
delete_user_cascade(session, user)
|
||||||
|
record_audit(session, admin.id, "delete", "user", user_id,
|
||||||
|
{"username": username, "name": name})
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -5,25 +5,117 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus
|
from ten31portal.models import (
|
||||||
|
UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Auth ---
|
# --- Auth ---
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: str
|
login: str # username or email
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
email: str
|
username: str
|
||||||
|
email: str | None
|
||||||
role: UserRole
|
role: UserRole
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_service_admin: bool = False
|
||||||
|
primary_account_id: int | None = None # set when this account logs in under another
|
||||||
|
totp_enabled: bool = False
|
||||||
|
# First-login flow: force a password change while on the shared default, then show the
|
||||||
|
# welcome step (2FA offer) until onboarded_at is stamped.
|
||||||
|
must_change_password: bool = False
|
||||||
|
onboarded_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LoginPending2FA(BaseModel):
|
||||||
|
"""Password accepted, waiting on the second factor before the session is signed in."""
|
||||||
|
requires_2fa: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TotpVerifyRequest(BaseModel):
|
||||||
|
code: str # 6-digit authenticator code, or a one-time recovery code
|
||||||
|
|
||||||
|
|
||||||
|
class TotpSetupResponse(BaseModel):
|
||||||
|
secret: str
|
||||||
|
otpauth_uri: str
|
||||||
|
qr_svg: str
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirmResponse(BaseModel):
|
||||||
|
recovery_codes: list[str] # shown exactly once
|
||||||
|
|
||||||
|
|
||||||
|
class TotpDisableRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- User administration ---
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
role: UserRole
|
||||||
|
email: str | None = None
|
||||||
|
entity_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
role: UserRole | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
entity_ids: list[int] | None = None # full replacement of grants when provided
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedAccount(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserDetailResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
is_service_admin: bool = False
|
||||||
|
primary_account_id: int | None = None
|
||||||
|
primary_account_name: str | None = None # the login this account is linked under, if any
|
||||||
|
linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account
|
||||||
|
created_at: datetime
|
||||||
|
entity_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AccountLink(BaseModel):
|
||||||
|
# null detaches the account so it logs in on its own again
|
||||||
|
primary_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Entity ---
|
# --- Entity ---
|
||||||
|
|
||||||
class EntityCreate(BaseModel):
|
class EntityCreate(BaseModel):
|
||||||
@@ -31,6 +123,7 @@ class EntityCreate(BaseModel):
|
|||||||
type: EntityType
|
type: EntityType
|
||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class EntityUpdate(BaseModel):
|
class EntityUpdate(BaseModel):
|
||||||
@@ -39,6 +132,8 @@ class EntityUpdate(BaseModel):
|
|||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
status: EntityStatus | None = None
|
status: EntityStatus | None = None
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
close_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class EntityResponse(BaseModel):
|
class EntityResponse(BaseModel):
|
||||||
@@ -48,6 +143,8 @@ class EntityResponse(BaseModel):
|
|||||||
vintage_year: int | None
|
vintage_year: int | None
|
||||||
fund_size_cents: int | None
|
fund_size_cents: int | None
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
close_date: date | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +233,216 @@ class RoundResponse(BaseModel):
|
|||||||
valuations: list[ValuationResponse] = []
|
valuations: list[ValuationResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Document ---
|
||||||
|
|
||||||
|
class DocumentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int | None
|
||||||
|
category: DocumentCategory
|
||||||
|
title: str
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
uploaded_by: int | None
|
||||||
|
created_at: datetime
|
||||||
|
# True for an investor when the doc arrived since their previous portal visit.
|
||||||
|
is_new: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Capital account ---
|
||||||
|
|
||||||
|
class CapitalAccountCreate(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int
|
||||||
|
as_of_date: date
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
beginning_balance_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
ending_balance_dollars: float = 0
|
||||||
|
document_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalAccountResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int
|
||||||
|
investor_name: str | None = None # legal name of the account this statement belongs to
|
||||||
|
as_of_date: date
|
||||||
|
commitment_cents: int
|
||||||
|
beginning_balance_cents: int
|
||||||
|
contributions_cents: int
|
||||||
|
distributions_cents: int
|
||||||
|
ending_balance_cents: int
|
||||||
|
document_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
|
||||||
|
# "Exited" badge instead of a phantom -100% and drops the position from totals.
|
||||||
|
exited_on: date | None = None
|
||||||
|
# Bitcoin-denominated view: BTC/USD at this statement's as-of date (newest uploaded price
|
||||||
|
# on or before it) and at the fund's close date (the entry mark). Null when no price or
|
||||||
|
# no close date is set — the portal simply hides the BTC view then.
|
||||||
|
btc_price_cents: int | None = None
|
||||||
|
btc_close_price_cents: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- BTC prices (bitcoin-denominated view) ---
|
||||||
|
|
||||||
|
class BtcPricesStatus(BaseModel):
|
||||||
|
count: int
|
||||||
|
first_date: date | None = None
|
||||||
|
last_date: date | None = None
|
||||||
|
latest_price_cents: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BtcPricesImportResult(BtcPricesStatus):
|
||||||
|
imported: int # rows upserted from this file (new + updated)
|
||||||
|
skipped_rows: int # unparseable lines ignored
|
||||||
|
|
||||||
|
|
||||||
|
# --- Partners (members of an entity) ---
|
||||||
|
|
||||||
|
class PartnerResponse(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
external_investor_id: str | None
|
||||||
|
is_active: bool
|
||||||
|
login_enabled: bool
|
||||||
|
latest_commitment_cents: int | None = None
|
||||||
|
latest_contributions_cents: int | None = None
|
||||||
|
latest_distributions_cents: int | None = None
|
||||||
|
latest_value_cents: int | None
|
||||||
|
latest_as_of: date | None
|
||||||
|
statements_count: int
|
||||||
|
exited_on: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerExitUpdate(BaseModel):
|
||||||
|
exited_on: date | None # null clears the exit (marks the member active again)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Access matrix ---
|
||||||
|
|
||||||
|
class AccessGrant(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
entity_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class AccessMatrixResponse(BaseModel):
|
||||||
|
users: list[UserResponse]
|
||||||
|
entities: list[EntityResponse]
|
||||||
|
grants: list[AccessGrant]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Capital account import (review-and-confirm) ---
|
||||||
|
|
||||||
|
class ImportInvestorPreview(BaseModel):
|
||||||
|
source_name: str # name as it appears in the spreadsheet
|
||||||
|
column_index: int # 0-based column it was read from
|
||||||
|
value_dollars: float # current capital value (ending balance)
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
external_id: str | None = None # fund-admin INVESTOR ID, when present
|
||||||
|
matched_user_id: int | None = None
|
||||||
|
matched_username: str | None = None
|
||||||
|
suggested_username: str | None = None # for unmatched: a safe default
|
||||||
|
|
||||||
|
|
||||||
|
class ImportValueRow(BaseModel):
|
||||||
|
row_index: int # 0-based sheet row
|
||||||
|
label: str # col A label (e.g. "LTPF1")
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalImportPreview(BaseModel):
|
||||||
|
as_of_date: date | None
|
||||||
|
value_rows: list[ImportValueRow] # candidate rows holding per-investor balances
|
||||||
|
chosen_row_index: int # the row used for the values below
|
||||||
|
investors: list[ImportInvestorPreview]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportCommitInvestor(BaseModel):
|
||||||
|
action: str # "match" | "create" | "skip"
|
||||||
|
value_dollars: float # current capital value (ending balance)
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
user_id: int | None = None # for action=match
|
||||||
|
name: str | None = None # for action=create
|
||||||
|
username: str | None = None # for action=create
|
||||||
|
email: str | None = None
|
||||||
|
password: str | None = None # for action=create; omit for the shared default password
|
||||||
|
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalImportCommit(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
as_of_date: date
|
||||||
|
investors: list[ImportCommitInvestor]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Batch historical capital backfill (one eNAV file per quarter, auto-matched) ---
|
||||||
|
|
||||||
|
class BatchCapitalFileResult(BaseModel):
|
||||||
|
filename: str
|
||||||
|
as_of_date: date | None = None
|
||||||
|
matched: int = 0 # existing members whose statement was written
|
||||||
|
statements_written: int = 0 # created + updated
|
||||||
|
updated: int = 0 # matched a statement already at this as-of date
|
||||||
|
skipped: list[str] = [] # roster names with no existing member (not created)
|
||||||
|
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
|
||||||
|
# NAV history leg: the quarter's valuation round written from the file's HLD sheet.
|
||||||
|
nav_status: str | None = None # added | updated | kept-signed | no-match | no-hld | error
|
||||||
|
nav_matched: int = 0 # HLD rows matched to positions in today's book
|
||||||
|
nav_unmatched: int = 0 # HLD rows with no current position (sold/renamed since)
|
||||||
|
nav_cents: int = 0 # the quarter's NAV as recorded (matched rows only)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCapitalImportResult(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
files: list[BatchCapitalFileResult]
|
||||||
|
total_statements: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
||||||
|
|
||||||
|
class EntityStakeCreate(BaseModel):
|
||||||
|
fund_entity_id: int
|
||||||
|
ownership_pct: float | None = None
|
||||||
|
value_dollars: float | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntityStakeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
holder_entity_id: int
|
||||||
|
fund_entity_id: int
|
||||||
|
fund_name: str | None = None
|
||||||
|
fund_type: EntityType | None = None
|
||||||
|
ownership_pct: float | None
|
||||||
|
value_cents: int | None
|
||||||
|
note: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Investor View (admin reconstruction of what one investor sees) ---
|
||||||
|
|
||||||
|
class InvestorViewResponse(BaseModel):
|
||||||
|
user: UserResponse
|
||||||
|
entities: list[EntityResponse] = []
|
||||||
|
capital_accounts: list[CapitalAccountResponse] = []
|
||||||
|
documents: list[DocumentResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AssetBalancesResponse(BaseModel):
|
||||||
|
"""A GP/mgmt entity's assets: the linked account's capital balances across the funds."""
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
linked_name: str | None = None
|
||||||
|
balances: list[CapitalAccountResponse] = []
|
||||||
|
|
||||||
|
|
||||||
# --- Audit ---
|
# --- Audit ---
|
||||||
|
|
||||||
class AuditLogResponse(BaseModel):
|
class AuditLogResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""File storage on the data volume for uploaded documents."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from ten31portal.config import DOCS_DIR, MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
class UploadTooLarge(Exception):
|
||||||
|
"""Raised when an upload exceeds MAX_UPLOAD_SIZE. The partial file is removed first."""
|
||||||
|
|
||||||
|
|
||||||
|
def read_capped(file: UploadFile, limit: int = MAX_UPLOAD_SIZE) -> bytes:
|
||||||
|
"""Read an upload fully into memory, but abort past `limit` instead of reading unbounded.
|
||||||
|
|
||||||
|
The spreadsheet importers must parse the whole workbook in memory; without a cap an
|
||||||
|
authenticated writer could POST a huge file and exhaust the process. Reads in 1 MB chunks
|
||||||
|
and raises UploadTooLarge (→ 413) once the total would exceed the limit.
|
||||||
|
"""
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
size = 0
|
||||||
|
while chunk := file.file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > limit:
|
||||||
|
raise UploadTooLarge(f"Upload exceeds the {limit}-byte limit.")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_docs_dir() -> Path:
|
||||||
|
path = Path(DOCS_DIR)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(file: UploadFile) -> tuple[str, int]:
|
||||||
|
"""Stream an upload to disk under an opaque name. Returns (storage_path, size_bytes).
|
||||||
|
|
||||||
|
Enforces MAX_UPLOAD_SIZE as it streams so a runaway upload can't fill the data volume;
|
||||||
|
the partial file is deleted before UploadTooLarge propagates.
|
||||||
|
"""
|
||||||
|
docs = ensure_docs_dir()
|
||||||
|
suffix = Path(file.filename or "").suffix
|
||||||
|
storage_name = f"{uuid4().hex}{suffix}"
|
||||||
|
dest = docs / storage_name
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
with dest.open("wb") as out:
|
||||||
|
while chunk := file.file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_UPLOAD_SIZE:
|
||||||
|
raise UploadTooLarge(
|
||||||
|
f"Upload exceeds the {MAX_UPLOAD_SIZE}-byte limit."
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
except UploadTooLarge:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return storage_name, size
|
||||||
|
|
||||||
|
|
||||||
|
def full_path(storage_path: str) -> Path:
|
||||||
|
"""Resolve a stored file, guarding against path traversal."""
|
||||||
|
docs = ensure_docs_dir().resolve()
|
||||||
|
candidate = (docs / storage_path).resolve()
|
||||||
|
if not str(candidate).startswith(str(docs) + os.sep):
|
||||||
|
raise ValueError("Invalid storage path")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(storage_path: str) -> None:
|
||||||
|
try:
|
||||||
|
full_path(storage_path).unlink(missing_ok=True)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes.
|
||||||
|
|
||||||
|
Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest —
|
||||||
|
unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful
|
||||||
|
match removes its hash from the stored list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
ISSUER = "Ten31 Portal"
|
||||||
|
RECOVERY_CODE_COUNT = 8
|
||||||
|
|
||||||
|
|
||||||
|
def new_secret() -> str:
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def otpauth_uri(secret: str, account_name: str) -> str:
|
||||||
|
return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER)
|
||||||
|
|
||||||
|
|
||||||
|
def qr_svg(uri: str) -> str:
|
||||||
|
"""The enrollment QR as a standalone SVG document (no raster deps needed)."""
|
||||||
|
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
return buf.getvalue().decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(secret: str, code: str) -> bool:
|
||||||
|
# valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift.
|
||||||
|
return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_recovery(code: str) -> str:
|
||||||
|
return code.strip().replace("-", "").replace(" ", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(code: str) -> str:
|
||||||
|
return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes() -> tuple[list[str], str]:
|
||||||
|
"""Return (plaintext codes to show once, JSON of their digests to store)."""
|
||||||
|
codes = []
|
||||||
|
for _ in range(RECOVERY_CODE_COUNT):
|
||||||
|
raw = secrets.token_hex(10) # 20 hex chars, 80 bits
|
||||||
|
codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}")
|
||||||
|
return codes, json.dumps([_digest(c) for c in codes])
|
||||||
|
|
||||||
|
|
||||||
|
def consume_recovery_code(stored_json: str | None, code: str) -> str | None:
|
||||||
|
"""If ``code`` matches an unused recovery code, return the updated JSON without it.
|
||||||
|
|
||||||
|
Returns None when the code doesn't match (or none are stored).
|
||||||
|
"""
|
||||||
|
if not stored_json:
|
||||||
|
return None
|
||||||
|
digests: list[str] = json.loads(stored_json)
|
||||||
|
d = _digest(code)
|
||||||
|
if d not in digests:
|
||||||
|
return None
|
||||||
|
digests.remove(d)
|
||||||
|
return json.dumps(digests)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared pytest fixtures: an in-memory DB and a FastAPI TestClient.
|
||||||
|
|
||||||
|
The client is created WITHOUT the context-manager form on purpose, so the app lifespan (which
|
||||||
|
runs real Alembic migrations against the configured DB_PATH) never fires. Tables come from
|
||||||
|
SQLModel.metadata.create_all against a throwaway in-memory database instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
import ten31portal.models # noqa: F401 — importing registers every table on SQLModel.metadata
|
||||||
|
from ten31portal.auth import hash_password
|
||||||
|
from ten31portal.database import get_session
|
||||||
|
from ten31portal.main import app
|
||||||
|
from ten31portal.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine():
|
||||||
|
eng = create_engine(
|
||||||
|
"sqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool, # one shared in-memory connection across sessions
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(eng)
|
||||||
|
yield eng
|
||||||
|
SQLModel.metadata.drop_all(eng)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session(engine):
|
||||||
|
with Session(engine) as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(engine):
|
||||||
|
def override_get_session():
|
||||||
|
with Session(engine) as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = override_get_session
|
||||||
|
yield TestClient(app)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(session, *, username="admin", password="password123",
|
||||||
|
role=UserRole.approver, name="Test User", email=None, **kwargs):
|
||||||
|
user = User(
|
||||||
|
name=name,
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def approver(session):
|
||||||
|
return make_user(session, username="approver", role=UserRole.approver)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client, approver):
|
||||||
|
"""A TestClient already logged in as an approver (a writer)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_login_limiter():
|
||||||
|
"""The login rate-limiter is a process-global; clear the TestClient's key around each test
|
||||||
|
so failed-login tests can't throttle unrelated ones."""
|
||||||
|
from ten31portal.routers.auth_router import _login_limiter
|
||||||
|
_login_limiter.reset("testclient")
|
||||||
|
yield
|
||||||
|
_login_limiter.reset("testclient")
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""External Administrator role (0.2.42): full management, fenced to granted entities.
|
||||||
|
|
||||||
|
An Administrator (UserRole.fund_administrator) runs the same admin screens as internal
|
||||||
|
staff — users, partners, documents, capital accounts, imports — but only for the funds
|
||||||
|
granted to them via EntityAccess. These tests pin the fence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Document, 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 _setup(session):
|
||||||
|
"""Two funds; the Administrator manages fund A only. One LP in each fund."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _stmt(entity_id, investor_id, as_of=date(2026, 3, 31), balance=1_000_000_00):
|
||||||
|
return CapitalAccountStatement(
|
||||||
|
entity_id=entity_id, investor_user_id=investor_id, as_of_date=as_of,
|
||||||
|
commitment_cents=balance, beginning_balance_cents=0,
|
||||||
|
contributions_cents=balance, distributions_cents=0,
|
||||||
|
ending_balance_cents=balance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_sees_only_their_funds_users(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
|
||||||
|
users = client.get("/api/users").json()
|
||||||
|
assert {u["username"] for u in users} == {"lp-a"}
|
||||||
|
|
||||||
|
# Their fund's investor is reachable; the other fund's — and staff — are not.
|
||||||
|
assert client.get(f"/api/users/{lp_a.id}").status_code == 200
|
||||||
|
assert client.get(f"/api/users/{lp_b.id}").status_code == 403
|
||||||
|
assert client.get(f"/api/users/{approver.id}").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_creates_investors_only_on_their_funds(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
|
||||||
|
# Investor on fund A: allowed; out-of-scope fund ids are silently dropped.
|
||||||
|
resp = client.post("/api/users", json={
|
||||||
|
"name": "New LP", "username": "new-lp", "password": "secretpw",
|
||||||
|
"role": "investor", "entity_ids": [fund_a.id, fund_b.id],
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["entity_ids"] == [fund_a.id]
|
||||||
|
|
||||||
|
# Staff roles are privilege escalation.
|
||||||
|
resp = client.post("/api/users", json={
|
||||||
|
"name": "Sneaky", "username": "sneaky", "password": "secretpw",
|
||||||
|
"role": "operations", "entity_ids": [],
|
||||||
|
})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# An investor with no in-scope fund would be invisible to its creator.
|
||||||
|
resp = client.post("/api/users", json={
|
||||||
|
"name": "Orphan", "username": "orphan", "password": "secretpw",
|
||||||
|
"role": "investor", "entity_ids": [fund_b.id],
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_partner_management_is_scoped(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/entities/{fund_a.id}/partners/{lp_a.id}/exited",
|
||||||
|
json={"exited_on": "2026-06-30"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/entities/{fund_b.id}/partners/{lp_b.id}/exited",
|
||||||
|
json={"exited_on": "2026-06-30"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_documents_upload_and_delete(client, session, approver, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("ten31portal.storage.DOCS_DIR", str(tmp_path))
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
|
||||||
|
doc_b = Document(
|
||||||
|
entity_id=fund_b.id, category="statement", title="B statement",
|
||||||
|
original_filename="b.pdf", content_type="application/pdf", size_bytes=1,
|
||||||
|
storage_path="x-b",
|
||||||
|
)
|
||||||
|
session.add(doc_b)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/documents",
|
||||||
|
data={"entity_id": str(fund_a.id), "category": "statement", "title": "Q1 statement"},
|
||||||
|
files={"file": ("q1.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
doc_a_id = resp.json()["id"]
|
||||||
|
|
||||||
|
assert client.delete(f"/api/documents/{doc_a_id}").json() == {"status": "deleted"}
|
||||||
|
assert client.delete(f"/api/documents/{doc_b.id}").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_capital_accounts_are_scoped(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
session.add(_stmt(fund_a.id, lp_a.id))
|
||||||
|
stmt_b = _stmt(fund_b.id, lp_b.id)
|
||||||
|
session.add(stmt_b)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
rows = client.get("/api/capital-accounts").json()
|
||||||
|
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||||
|
|
||||||
|
resp = client.post("/api/capital-accounts", json={
|
||||||
|
"entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-06-30",
|
||||||
|
"commitment_dollars": 100, "beginning_balance_dollars": 0,
|
||||||
|
"contributions_dollars": 100, "distributions_dollars": 0,
|
||||||
|
"ending_balance_dollars": 110,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
resp = client.post("/api/capital-accounts", json={
|
||||||
|
"entity_id": fund_b.id, "investor_user_id": lp_b.id, "as_of_date": "2026-06-30",
|
||||||
|
"commitment_dollars": 100, "beginning_balance_dollars": 0,
|
||||||
|
"contributions_dollars": 100, "distributions_dollars": 0,
|
||||||
|
"ending_balance_dollars": 110,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert client.delete(f"/api/capital-accounts/{stmt_b.id}").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_deletes_only_investors_solely_in_their_funds(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
# lp_a also joins fund B — deleting them would reach beyond the Administrator's fence.
|
||||||
|
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
|
||||||
|
solo = make_user(session, username="solo", role=UserRole.investor, name="Solo LP")
|
||||||
|
session.add(EntityAccess(user_id=solo.id, entity_id=fund_a.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
solo_id = solo.id
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
assert client.delete(f"/api/users/{lp_a.id}").status_code == 403
|
||||||
|
assert client.delete(f"/api/users/{approver.id}").status_code == 403
|
||||||
|
assert client.delete(f"/api/users/{solo_id}").json() == {"status": "deleted"}
|
||||||
|
session.expire_all() # the API deleted through its own session; drop our cached copy
|
||||||
|
assert session.exec(select(User).where(User.id == solo_id)).first() is None
|
||||||
|
assert session.exec(
|
||||||
|
select(EntityAccess).where(EntityAccess.user_id == solo_id)
|
||||||
|
).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_admin_delete_guards(auth_client, session, approver):
|
||||||
|
service = make_user(session, username="svc", role=UserRole.operations,
|
||||||
|
is_service_admin=True)
|
||||||
|
victim = make_user(session, username="victim", role=UserRole.investor)
|
||||||
|
|
||||||
|
assert auth_client.delete(f"/api/users/{service.id}").status_code == 400
|
||||||
|
assert auth_client.delete(f"/api/users/{approver.id}").status_code == 400 # self
|
||||||
|
assert auth_client.delete(f"/api/users/{victim.id}").json() == {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_update_preserves_other_funds_grants(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
# Submitting only in-scope grants must not strip the investor's fund B access.
|
||||||
|
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": [fund_a.id]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert set(resp.json()["entity_ids"]) == {fund_a.id, fund_b.id}
|
||||||
|
|
||||||
|
# Revoking their own fund keeps fund B untouched.
|
||||||
|
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["entity_ids"] == [fund_b.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_blocked_from_internal_surfaces(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
|
||||||
|
assert client.get("/api/audit").status_code == 403
|
||||||
|
assert client.post("/api/entities", json={"name": "New Fund", "type": "fund"}).status_code == 403
|
||||||
|
# eNAV import into a fund outside their grants (or with no fund chosen) is refused.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/import/schedule",
|
||||||
|
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/import/schedule?entity_id={fund_b.id}",
|
||||||
|
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_reads_rounds_and_holdings_in_scope(client, session, approver):
|
||||||
|
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||||
|
_login(client, "fundadmin")
|
||||||
|
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_b.id}/rounds").status_code == 403
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_b.id}/holdings").status_code == 403
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Administrator (view only) role (0.2.45): reads everything on its granted entities,
|
||||||
|
changes nothing anywhere."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, 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 _setup(session):
|
||||||
|
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()
|
||||||
|
|
||||||
|
viewer = make_user(session, username="viewonly", role=UserRole.administrator_viewer,
|
||||||
|
name="Read Only Admin")
|
||||||
|
session.add(EntityAccess(user_id=viewer.id, entity_id=fund_a.id))
|
||||||
|
|
||||||
|
lp = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=fund_a.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=fund_a.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=100_00, beginning_balance_cents=0, contributions_cents=100_00,
|
||||||
|
distributions_cents=0, ending_balance_cents=110_00,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
return fund_a, fund_b, viewer, lp
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_reads_only_their_fund(client, session, approver):
|
||||||
|
fund_a, fund_b, viewer, lp = _setup(session)
|
||||||
|
_login(client, "viewonly")
|
||||||
|
|
||||||
|
entities = client.get("/api/entities").json()
|
||||||
|
assert [e["name"] for e in entities] == ["Fund A"]
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||||
|
|
||||||
|
# Sees every investor's statements within the fund, plus their names.
|
||||||
|
rows = client.get("/api/capital-accounts").json()
|
||||||
|
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||||
|
users = client.get("/api/users").json()
|
||||||
|
assert {u["username"] for u in users} == {"lp-a"}
|
||||||
|
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert isinstance(docs, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_cannot_change_anything(client, session, approver):
|
||||||
|
fund_a, fund_b, viewer, lp = _setup(session)
|
||||||
|
_login(client, "viewonly")
|
||||||
|
|
||||||
|
# Entity records
|
||||||
|
assert client.patch(f"/api/entities/{fund_a.id}", json={"name": "X"}).status_code == 403
|
||||||
|
assert client.put(
|
||||||
|
f"/api/entities/{fund_a.id}/partners/{lp.id}/exited", json={"exited_on": "2026-06-30"}
|
||||||
|
).status_code == 403
|
||||||
|
assert client.delete(f"/api/entities/{fund_a.id}/partners").status_code == 403
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
resp = client.post(
|
||||||
|
"/api/documents",
|
||||||
|
data={"entity_id": str(fund_a.id), "category": "statement"},
|
||||||
|
files={"file": ("x.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# Capital accounts
|
||||||
|
assert client.post("/api/capital-accounts", json={
|
||||||
|
"entity_id": fund_a.id, "investor_user_id": lp.id, "as_of_date": "2026-06-30",
|
||||||
|
"commitment_dollars": 1, "beginning_balance_dollars": 0,
|
||||||
|
"contributions_dollars": 1, "distributions_dollars": 0, "ending_balance_dollars": 1,
|
||||||
|
}).status_code == 403
|
||||||
|
|
||||||
|
# Imports
|
||||||
|
assert client.post(
|
||||||
|
f"/api/import/schedule?entity_id={fund_a.id}",
|
||||||
|
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||||
|
).status_code == 403
|
||||||
|
assert client.post(
|
||||||
|
"/api/import/capital-accounts/batch",
|
||||||
|
data={"entity_id": str(fund_a.id)},
|
||||||
|
files=[("files", ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream"))],
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
# User management
|
||||||
|
assert client.post("/api/users", json={
|
||||||
|
"name": "N", "username": "n", "password": "secretpw",
|
||||||
|
"role": "investor", "entity_ids": [fund_a.id],
|
||||||
|
}).status_code == 403
|
||||||
|
assert client.patch(f"/api/users/{lp.id}", json={"is_active": False}).status_code == 403
|
||||||
|
assert client.delete(f"/api/users/{lp.id}").status_code == 403
|
||||||
|
assert client.get("/api/users/access-matrix").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_admin_toggles_administrator_level(auth_client, session):
|
||||||
|
admin_acct = make_user(session, username="mgr", role=UserRole.fund_administrator,
|
||||||
|
name="Managing Admin")
|
||||||
|
|
||||||
|
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "administrator_viewer"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["role"] == "administrator_viewer"
|
||||||
|
|
||||||
|
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "fund_administrator"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "fund_administrator"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Auth: login works, and protected/writer endpoints reject the wrong caller."""
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success(client, approver):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["username"] == "approver"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password(client, approver):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"login": "approver", "password": "nope"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_authentication(client):
|
||||||
|
assert client.get("/api/auth/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(auth_client):
|
||||||
|
resp = auth_client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "approver"
|
||||||
|
|
||||||
|
|
||||||
|
def test_writer_endpoint_rejects_viewer(client, session):
|
||||||
|
"""A viewer is authenticated but not a writer — create-entity must 403, not 401."""
|
||||||
|
make_user(session, username="viewer", role=UserRole.viewer)
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login", json={"login": "viewer", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
resp = client.post("/api/entities", json={"name": "X", "type": "fund"})
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Login hardening: generic failure message, timing-safe unknown-user path, rate limiting,
|
||||||
|
and the import size cap."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ten31portal import storage
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_failure_is_generic_for_unknown_and_wrong_password(client, session):
|
||||||
|
make_user(session, username="alice", password="correct-horse")
|
||||||
|
|
||||||
|
unknown = client.post("/api/auth/login", json={"login": "nobody", "password": "x"})
|
||||||
|
wrong = client.post("/api/auth/login", json={"login": "alice", "password": "nope"})
|
||||||
|
|
||||||
|
assert unknown.status_code == 401
|
||||||
|
assert wrong.status_code == 401
|
||||||
|
# Same message either way, so it doesn't reveal whether the username exists.
|
||||||
|
assert unknown.json()["detail"] == wrong.json()["detail"] == "Invalid username or password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_is_rate_limited_after_repeated_failures(client, session):
|
||||||
|
make_user(session, username="bob", password="s3cret-pass")
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
r = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
blocked = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"})
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
assert "Retry-After" in blocked.headers
|
||||||
|
# Even the correct password is refused while the source IP is throttled.
|
||||||
|
correct = client.post("/api/auth/login", json={"login": "bob", "password": "s3cret-pass"})
|
||||||
|
assert correct.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_login_clears_the_failure_counter(client, session):
|
||||||
|
make_user(session, username="carol", password="right-pass")
|
||||||
|
|
||||||
|
for _ in range(9): # one shy of the limit
|
||||||
|
assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401
|
||||||
|
assert client.post("/api/auth/login", json={"login": "carol", "password": "right-pass"}).status_code == 200
|
||||||
|
|
||||||
|
# Counter reset — a fresh run of failures doesn't immediately trip the limit.
|
||||||
|
assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class _StubUpload:
|
||||||
|
"""Minimal stand-in for UploadFile: read_capped only touches `.file.read`."""
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.file = io.BytesIO(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_capped_enforces_the_limit():
|
||||||
|
assert storage.read_capped(_StubUpload(b"x" * 100), limit=1000) == b"x" * 100
|
||||||
|
with pytest.raises(storage.UploadTooLarge):
|
||||||
|
storage.read_capped(_StubUpload(b"x" * 2000), limit=1000)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""0.2.39: BTC price CSV + bitcoin-denominated marks, forced default-password change,
|
||||||
|
and the first-login onboarded watermark."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal import config
|
||||||
|
from ten31portal.models import CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _fund_with_lp(session, *, close_date=None):
|
||||||
|
entity = Entity(name="LTPF X", type=EntityType.fund, close_date=close_date)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
lp = make_user(session, username="lp", role=UserRole.investor, name="An LP")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=1_000_000_00, beginning_balance_cents=0,
|
||||||
|
contributions_cents=500_000_00, distributions_cents=0,
|
||||||
|
ending_balance_cents=600_000_00,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
return entity, lp
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_prices(client, csv_text):
|
||||||
|
return client.post(
|
||||||
|
"/api/import/btc-prices",
|
||||||
|
files={"file": ("prices.csv", io.BytesIO(csv_text.encode()), "text/csv")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_btc_csv_import_and_marks(auth_client, session):
|
||||||
|
_fund_with_lp(session, close_date=date(2025, 6, 30))
|
||||||
|
|
||||||
|
resp = _upload_prices(
|
||||||
|
auth_client,
|
||||||
|
"Date,Close\n2025-06-30,60000\n2025-12-31,80000\n2026-03-31,100000.50\n",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["imported"] == 3
|
||||||
|
assert body["latest_price_cents"] == 10_000_050
|
||||||
|
|
||||||
|
# The LP's statements carry both marks: as-of price and close-date price.
|
||||||
|
auth_client.post("/api/auth/logout")
|
||||||
|
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
|
||||||
|
acct = auth_client.get("/api/capital-accounts").json()[0]
|
||||||
|
assert acct["btc_price_cents"] == 10_000_050 # exact as-of match
|
||||||
|
assert acct["btc_close_price_cents"] == 6_000_000 # fund close 2025-06-30
|
||||||
|
|
||||||
|
|
||||||
|
def test_btc_price_nearest_on_or_before(auth_client, session):
|
||||||
|
# Prices only exist BEFORE the statement date → the newest one on-or-before is used.
|
||||||
|
_fund_with_lp(session, close_date=date(2025, 6, 30))
|
||||||
|
resp = _upload_prices(auth_client, "date,price\n2025-06-28,55000\n2026-03-01,90000\n")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
auth_client.post("/api/auth/logout")
|
||||||
|
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
|
||||||
|
acct = auth_client.get("/api/capital-accounts").json()[0]
|
||||||
|
assert acct["btc_price_cents"] == 9_000_000 # 2026-03-01 covers 2026-03-31
|
||||||
|
assert acct["btc_close_price_cents"] == 5_500_000 # 2025-06-28 covers the 06-30 close
|
||||||
|
|
||||||
|
|
||||||
|
def test_btc_reupload_overwrites_and_bad_file_rejected(auth_client, session):
|
||||||
|
assert _upload_prices(auth_client, "Date,Close\n2026-01-01,90000\n").status_code == 200
|
||||||
|
r = _upload_prices(auth_client, "Date,Close\n2026-01-01,95000\n")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1 # upsert, not a duplicate row
|
||||||
|
assert r.json()["latest_price_cents"] == 9_500_000
|
||||||
|
assert _upload_prices(auth_client, "just some text\nwith,no,dates\n").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_password_forces_change(client, session):
|
||||||
|
make_user(session, username="fresh", role=UserRole.investor,
|
||||||
|
password=config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"login": "fresh", "password": config.DEFAULT_INVESTOR_PASSWORD},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["must_change_password"] is True
|
||||||
|
|
||||||
|
# Choosing the shared default again is rejected; a real password clears the flag.
|
||||||
|
r = client.post("/api/auth/change-password", json={
|
||||||
|
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
|
||||||
|
"new_password": config.DEFAULT_INVESTOR_PASSWORD,
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = client.post("/api/auth/change-password", json={
|
||||||
|
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
|
||||||
|
"new_password": "my-own-secret-1",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get("/api/auth/me").json()["must_change_password"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarded_stamp(client, session):
|
||||||
|
make_user(session, username="lp2", role=UserRole.investor)
|
||||||
|
client.post("/api/auth/login", json={"login": "lp2", "password": "password123"})
|
||||||
|
assert client.get("/api/auth/me").json()["onboarded_at"] is None
|
||||||
|
assert client.post("/api/auth/onboarded").status_code == 200
|
||||||
|
stamped = client.get("/api/auth/me").json()["onboarded_at"]
|
||||||
|
assert stamped is not None
|
||||||
|
# Idempotent — the first stamp wins.
|
||||||
|
client.post("/api/auth/onboarded")
|
||||||
|
assert client.get("/api/auth/me").json()["onboarded_at"] == stamped
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_close_date_update(auth_client, session):
|
||||||
|
entity = Entity(name="SPV Y", type=EntityType.spv)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
r = auth_client.patch(f"/api/entities/{entity.id}", json={"close_date": "2025-11-15"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["close_date"] == "2025-11-15"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Batch historical capital backfill: several eNAV files → per-quarter statements, auto-matched."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, User, UserRole
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _alloc_si_file(report: datetime, rows: list[dict]) -> bytes:
|
||||||
|
"""Build a minimal eNAV workbook with an ALLOC SI roster and a report date in A1."""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "ALLOC SI"
|
||||||
|
ws["A1"] = report # _enav_as_of reads the report date from the top-left cells
|
||||||
|
header = ["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
|
||||||
|
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"]
|
||||||
|
ws.append([None] * 7) # row 2 spacer
|
||||||
|
ws.append(header) # row 3 header
|
||||||
|
for r in rows:
|
||||||
|
ws.append([
|
||||||
|
r.get("id"), "LP", r["name"],
|
||||||
|
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"],
|
||||||
|
])
|
||||||
|
out = io.BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_backfill_builds_history(auth_client, session):
|
||||||
|
fund = Entity(name="LTPF III", type=EntityType.fund)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(fund)
|
||||||
|
|
||||||
|
# One member matched by fund-admin ID, one by name; both already exist.
|
||||||
|
alice = make_user(session, username="alice", name="Alice Trust",
|
||||||
|
role=UserRole.investor, external_investor_id="INV-100")
|
||||||
|
make_user(session, username="bob", name="Bob Llc", role=UserRole.investor)
|
||||||
|
|
||||||
|
q3 = _alloc_si_file(datetime(2025, 9, 30), [
|
||||||
|
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 420_000},
|
||||||
|
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 205_000},
|
||||||
|
{"id": "INV-999", "name": "Ghost Capital", "commit": 999, "contrib": 999, "ending": 999}, # no account → skipped
|
||||||
|
])
|
||||||
|
q4 = _alloc_si_file(datetime(2025, 12, 31), [
|
||||||
|
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 455_000},
|
||||||
|
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 210_000},
|
||||||
|
])
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/import/capital-accounts/batch",
|
||||||
|
data={"entity_id": fund.id},
|
||||||
|
files=[
|
||||||
|
("files", ("LTPF_III_Q3.xlsx", q3, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||||
|
("files", ("LTPF_III_Q4.xlsx", q4, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
assert body["total_statements"] == 4 # 2 members × 2 quarters
|
||||||
|
q3res = next(f for f in body["files"] if "Q3" in f["filename"])
|
||||||
|
assert q3res["as_of_date"] == "2025-09-30"
|
||||||
|
assert q3res["matched"] == 2
|
||||||
|
assert q3res["skipped"] == ["Ghost Capital"] # unknown member reported, not created
|
||||||
|
|
||||||
|
# No account was created for the unknown roster name.
|
||||||
|
assert session.exec(select(User).where(User.name == "Ghost Capital")).first() is None
|
||||||
|
|
||||||
|
# Alice now has two statements — a real trend line — and the latest (Q4) is intact.
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(CapitalAccountStatement.investor_user_id == alice.id)
|
||||||
|
.order_by(CapitalAccountStatement.as_of_date) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
assert [s.as_of_date for s in stmts] == [date(2025, 9, 30), date(2025, 12, 31)]
|
||||||
|
assert [s.ending_balance_cents for s in stmts] == [42_000_000, 45_500_000]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_reimport_updates_in_place(auth_client, session):
|
||||||
|
fund = Entity(name="LTPF IV", type=EntityType.fund)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(fund)
|
||||||
|
carol = make_user(session, username="carol", name="Carol Ira", role=UserRole.investor)
|
||||||
|
|
||||||
|
def one_file(ending: int) -> bytes:
|
||||||
|
return _alloc_si_file(datetime(2025, 12, 31),
|
||||||
|
[{"id": "INV-1", "name": "Carol Ira", "ending": ending}])
|
||||||
|
|
||||||
|
for ending in (300_000, 315_000): # corrected figure re-imported at the same quarter
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/import/capital-accounts/batch",
|
||||||
|
data={"entity_id": fund.id},
|
||||||
|
files=[("files", ("f.xlsx", one_file(ending), "application/octet-stream"))],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == carol.id)
|
||||||
|
).all()
|
||||||
|
assert len(stmts) == 1 # upsert, not a duplicate
|
||||||
|
assert stmts[0].ending_balance_cents == 31_500_000
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Clearing a fund's partners removes only that fund's statements + access grants,
|
||||||
|
never the investor accounts (which may belong to other funds)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fund(session, name):
|
||||||
|
e = Entity(name=name, type=EntityType.fund)
|
||||||
|
session.add(e)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(e)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _add_partner(session, entity_id, user_id, ending):
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
|
||||||
|
commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
|
||||||
|
distributions_cents=0, ending_balance_cents=ending,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_partners_scoped_to_one_fund(auth_client, session):
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
|
||||||
|
fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
|
||||||
|
lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
|
||||||
|
# Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
|
||||||
|
_add_partner(session, fund2.id, lp.id, 1_000_00)
|
||||||
|
_add_partner(session, fund3.id, lp.id, 2_000_00)
|
||||||
|
|
||||||
|
resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {"statements": 1, "access_grants": 1}
|
||||||
|
|
||||||
|
# Fund III is wiped of partners...
|
||||||
|
assert session.exec(
|
||||||
|
CapitalAccountStatement.__table__.select().where(
|
||||||
|
CapitalAccountStatement.entity_id == fund3.id
|
||||||
|
)
|
||||||
|
).first() is None
|
||||||
|
assert session.exec(
|
||||||
|
EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
|
||||||
|
).first() is None
|
||||||
|
assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
|
||||||
|
|
||||||
|
# ...but Fund II keeps its partner, and the investor account still exists.
|
||||||
|
assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
|
||||||
|
assert session.get(User, lp.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_partners_missing_entity_404(auth_client):
|
||||||
|
assert auth_client.delete("/api/entities/99999/partners").status_code == 404
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Entity CRUD round-trips through the API as a writer."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_crud_roundtrip(auth_client):
|
||||||
|
# Create
|
||||||
|
created = auth_client.post(
|
||||||
|
"/api/entities",
|
||||||
|
json={"name": "Ten31 Fund I", "type": "fund", "vintage_year": 2025},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
entity_id = created.json()["id"]
|
||||||
|
|
||||||
|
# Read (single)
|
||||||
|
got = auth_client.get(f"/api/entities/{entity_id}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["name"] == "Ten31 Fund I"
|
||||||
|
|
||||||
|
# List
|
||||||
|
listed = auth_client.get("/api/entities")
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert any(e["id"] == entity_id for e in listed.json())
|
||||||
|
|
||||||
|
# Update
|
||||||
|
patched = auth_client.patch(
|
||||||
|
f"/api/entities/{entity_id}", json={"name": "Ten31 Fund I, LP"}
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["name"] == "Ten31 Fund I, LP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_entity_404(auth_client):
|
||||||
|
assert auth_client.get("/api/entities/99999").status_code == 404
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Linking a GP entity to its investor account (so Assets can pull real balances)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_entity_to_investor(auth_client, session):
|
||||||
|
inv = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
|
||||||
|
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||||
|
session.add(gp)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
|
||||||
|
linked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": inv.id})
|
||||||
|
assert linked.status_code == 200, linked.text
|
||||||
|
assert linked.json()["linked_user_id"] == inv.id
|
||||||
|
|
||||||
|
# Unlink.
|
||||||
|
unlinked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": None})
|
||||||
|
assert unlinked.status_code == 200
|
||||||
|
assert unlinked.json()["linked_user_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_rejects_non_investor(auth_client, session):
|
||||||
|
staff = make_user(session, username="ops2", role=UserRole.operations)
|
||||||
|
gp = Entity(name="Mgmt Co", type=EntityType.mgmt_co)
|
||||||
|
session.add(gp)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_balances_include_household(auth_client, session):
|
||||||
|
"""Balances under linked (household) names of the linked account are included."""
|
||||||
|
primary = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
|
||||||
|
secondary = make_user(
|
||||||
|
session, username="ten31llc_trust", role=UserRole.investor,
|
||||||
|
name="Ten31 LLC Trust", primary_account_id=primary.id,
|
||||||
|
)
|
||||||
|
f1 = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
f2 = Entity(name="LTPF II", type=EntityType.fund)
|
||||||
|
gp = Entity(name="Ten31 LLC", type=EntityType.gp, linked_user_id=primary.id)
|
||||||
|
session.add_all([f1, f2, gp])
|
||||||
|
session.commit()
|
||||||
|
for x in (f1, f2, gp):
|
||||||
|
session.refresh(x)
|
||||||
|
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=f1.id, investor_user_id=primary.id, as_of_date=date(2026, 3, 31),
|
||||||
|
ending_balance_cents=600_000,
|
||||||
|
))
|
||||||
|
# This balance sits under the linked secondary name, not the primary.
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=f2.id, investor_user_id=secondary.id, as_of_date=date(2026, 3, 31),
|
||||||
|
ending_balance_cents=400_000,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["linked_name"] == "Ten31 LLC"
|
||||||
|
assert {b["entity_id"] for b in body["balances"]} == {f1.id, f2.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_balances_unlinked_is_empty(auth_client, session):
|
||||||
|
gp = Entity(name="Mgmt", type=EntityType.mgmt_co)
|
||||||
|
session.add(gp)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["balances"] == []
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Exited positions (0.2.33): a member who sold/transferred their stake shows as exited
|
||||||
|
instead of a phantom -100% loss, and drops out of committed totals."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||||
|
)
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_fund(session, *, commitment=4_000_000_00, balance=0):
|
||||||
|
entity = Entity(name="Pawn Fund", type=EntityType.spv)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
lp = make_user(session, username="seller", role=UserRole.investor, name="Seller LP")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=commitment, beginning_balance_cents=0,
|
||||||
|
contributions_cents=commitment, distributions_cents=0,
|
||||||
|
ending_balance_cents=balance,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
return entity, lp
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_exited_flows_to_partners_and_statements(auth_client, session):
|
||||||
|
entity, lp = _setup_fund(session)
|
||||||
|
|
||||||
|
resp = auth_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
|
||||||
|
json={"exited_on": "2026-05-15"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["exited_on"] == "2026-05-15"
|
||||||
|
|
||||||
|
partners = auth_client.get(f"/api/entities/{entity.id}/partners").json()
|
||||||
|
assert partners[0]["exited_on"] == "2026-05-15"
|
||||||
|
|
||||||
|
# The LP's own capital-account view carries the exit date.
|
||||||
|
lp_client = auth_client
|
||||||
|
lp_client.post("/api/auth/logout")
|
||||||
|
assert lp_client.post(
|
||||||
|
"/api/auth/login", json={"login": "seller", "password": "password123"}
|
||||||
|
).status_code == 200
|
||||||
|
accounts = lp_client.get("/api/capital-accounts").json()
|
||||||
|
assert accounts[0]["exited_on"] == "2026-05-15"
|
||||||
|
|
||||||
|
# Undo clears it.
|
||||||
|
lp_client.post("/api/auth/logout")
|
||||||
|
assert lp_client.post(
|
||||||
|
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||||
|
).status_code == 200
|
||||||
|
resp = lp_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{lp.id}/exited", json={"exited_on": None}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["exited_on"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_investor_view_mirrors_exit(auth_client, session):
|
||||||
|
"""The admin's read-only Investor View must carry exited_on exactly like the LP's own
|
||||||
|
portal — it was missing there (the bug behind the 'active card despite exit' report)."""
|
||||||
|
entity, lp = _setup_fund(session)
|
||||||
|
assert auth_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
|
||||||
|
json={"exited_on": "2026-05-15"},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
view = auth_client.get(f"/api/users/{lp.id}/investor-view").json()
|
||||||
|
assert view["capital_accounts"], "expected the LP's statements in the view"
|
||||||
|
assert all(c["exited_on"] == "2026-05-15" for c in view["capital_accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_exited_member_excluded_from_rollup_committed(auth_client, session):
|
||||||
|
entity, seller = _setup_fund(session)
|
||||||
|
# The buyer joins the roster with the same commitment (they bought the stake).
|
||||||
|
buyer = make_user(session, username="buyer", role=UserRole.investor, name="Buyer LP")
|
||||||
|
session.add(EntityAccess(user_id=buyer.id, entity_id=entity.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity.id, investor_user_id=buyer.id, as_of_date=date(2026, 6, 30),
|
||||||
|
commitment_cents=4_000_000_00, beginning_balance_cents=0,
|
||||||
|
contributions_cents=4_000_000_00, distributions_cents=0,
|
||||||
|
ending_balance_cents=4_200_000_00,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Before the exit both commitments count — the double-count problem.
|
||||||
|
rollup = auth_client.get("/api/entities/rollup").json()
|
||||||
|
row = next(r for r in rollup if r["id"] == entity.id)
|
||||||
|
assert row["committed_cents"] == 8_000_000_00
|
||||||
|
|
||||||
|
assert auth_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{seller.id}/exited",
|
||||||
|
json={"exited_on": "2026-05-15"},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
rollup = auth_client.get("/api/entities/rollup").json()
|
||||||
|
row = next(r for r in rollup if r["id"] == entity.id)
|
||||||
|
assert row["committed_cents"] == 4_000_000_00
|
||||||
|
|
||||||
|
|
||||||
|
def test_exited_without_access_row(auth_client, session):
|
||||||
|
"""A manually-entered investor may have statements but no access grant yet — marking
|
||||||
|
them exited from the Capital Accounts screen creates the roster row with the flag set.
|
||||||
|
Clearing an exit that was never set stays a 404."""
|
||||||
|
entity, _ = _setup_fund(session)
|
||||||
|
manual = make_user(session, username="manual-lp", role=UserRole.investor)
|
||||||
|
|
||||||
|
resp = auth_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{manual.id}/exited",
|
||||||
|
json={"exited_on": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
resp = auth_client.put(
|
||||||
|
f"/api/entities/{entity.id}/partners/{manual.id}/exited",
|
||||||
|
json={"exited_on": "2026-05-15"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["exited_on"] == "2026-05-15"
|
||||||
|
access = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.entity_id == entity.id, EntityAccess.user_id == manual.id
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert str(access.exited_on) == "2026-05-15"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""NAV history backfill (0.2.43): old eNAV files add past quarters to valuation history
|
||||||
|
WITHOUT touching current holdings — no cost-basis regression, no resurrected positions."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
Entity, EntityType, Holding, Position, RoundStatus, Valuation, ValuationRound,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enav_file(report: datetime, hld_rows: list[tuple], alloc_rows: list[dict] | None = None) -> bytes:
|
||||||
|
"""Minimal eNAV workbook: an HLD sheet (and optionally an ALLOC SI roster).
|
||||||
|
|
||||||
|
hld_rows: (security_name, quantity, cost, value) tuples.
|
||||||
|
"""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "HLD"
|
||||||
|
ws["A1"] = report # _enav_as_of scans the top-left cells for the report date
|
||||||
|
ws.append([None])
|
||||||
|
ws.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
|
||||||
|
for name, qty, cost, value in hld_rows:
|
||||||
|
ws.append([name, qty, cost, value])
|
||||||
|
if alloc_rows is not None:
|
||||||
|
alloc = wb.create_sheet("ALLOC SI")
|
||||||
|
alloc.append(["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
|
||||||
|
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"])
|
||||||
|
for r in alloc_rows:
|
||||||
|
alloc.append([r.get("id"), "LP", r["name"],
|
||||||
|
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"]])
|
||||||
|
out = io.BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _fund_with_current_book(session):
|
||||||
|
"""A fund holding one position (cost $100) with its NAV already signed for Q1 2026."""
|
||||||
|
fund = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(fund)
|
||||||
|
holding = Holding(entity_id=fund.id, company_name="Acme")
|
||||||
|
session.add(holding)
|
||||||
|
session.commit()
|
||||||
|
pos = Position(holding_id=holding.id, security_name="Acme - Series A",
|
||||||
|
investment_date=date(2023, 1, 1), cost_cents=100_00)
|
||||||
|
session.add(pos)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(pos)
|
||||||
|
rnd = ValuationRound(entity_id=fund.id, quarter_end=date(2026, 3, 31),
|
||||||
|
status=RoundStatus.approved, is_seed=True)
|
||||||
|
session.add(rnd)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(rnd)
|
||||||
|
session.add(Valuation(round_id=rnd.id, position_id=pos.id, value_cents=900_00))
|
||||||
|
session.commit()
|
||||||
|
return fund, pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_file_becomes_history_round_without_touching_book(auth_client, session):
|
||||||
|
fund, pos = _fund_with_current_book(session)
|
||||||
|
old = _enav_file(datetime(2025, 9, 30), [
|
||||||
|
("Acme - Series A", 10, 123.0, 555.0), # matches today's book
|
||||||
|
("Ghost - SAFE", 5, 999.0, 999.0), # sold since; must NOT be created
|
||||||
|
])
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
f"/api/import/schedule?entity_id={fund.id}&commit=true",
|
||||||
|
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["history_only"] is True
|
||||||
|
assert body["status"] == "added"
|
||||||
|
assert body["matched"] == 1 and body["unmatched"] == 1
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
# The quarter landed in valuation history with the matched row's value only.
|
||||||
|
hist_round = session.exec(select(ValuationRound).where(
|
||||||
|
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
|
||||||
|
)).first()
|
||||||
|
assert hist_round is not None
|
||||||
|
vals = session.exec(select(Valuation).where(Valuation.round_id == hist_round.id)).all()
|
||||||
|
assert [(v.position_id, v.value_cents) for v in vals] == [(pos.id, 555_00)]
|
||||||
|
# Today's book is untouched: cost basis kept, no ghost position resurrected.
|
||||||
|
assert session.get(Position, pos.id).cost_cents == 100_00
|
||||||
|
assert session.exec(select(Holding).where(Holding.company_name == "Ghost")).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_round_is_kept(auth_client, session):
|
||||||
|
fund, pos = _fund_with_current_book(session)
|
||||||
|
signed = ValuationRound(entity_id=fund.id, quarter_end=date(2025, 9, 30),
|
||||||
|
status=RoundStatus.approved, is_seed=False)
|
||||||
|
session.add(signed)
|
||||||
|
session.commit()
|
||||||
|
old = _enav_file(datetime(2025, 9, 30), [("Acme - Series A", 10, 123.0, 555.0)])
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
f"/api/import/schedule?entity_id={fund.id}&commit=true",
|
||||||
|
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["status"] == "kept-signed"
|
||||||
|
session.expire_all()
|
||||||
|
assert session.exec(select(Valuation).join(
|
||||||
|
ValuationRound, Valuation.round_id == ValuationRound.id
|
||||||
|
).where(ValuationRound.quarter_end == date(2025, 9, 30))).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_backfill_also_records_nav(auth_client, session):
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import CapitalAccountStatement, EntityAccess, UserRole
|
||||||
|
|
||||||
|
fund, pos = _fund_with_current_book(session)
|
||||||
|
lp = make_user(session, username="lp", name="Alice Trust", role=UserRole.investor)
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=fund.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
old = _enav_file(
|
||||||
|
datetime(2025, 9, 30),
|
||||||
|
[("Acme - Series A", 10, 123.0, 555.0), ("Ghost - SAFE", 5, 999.0, 999.0)],
|
||||||
|
alloc_rows=[{"id": "INV-1", "name": "Alice Trust", "commit": 1000, "contrib": 400, "ending": 420}],
|
||||||
|
)
|
||||||
|
|
||||||
|
for expected_status in ("added", "updated"): # second pass proves idempotency
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/import/capital-accounts/batch",
|
||||||
|
data={"entity_id": str(fund.id)},
|
||||||
|
files=[("files", ("old.xlsx", io.BytesIO(old), "application/octet-stream"))],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
f = resp.json()["files"][0]
|
||||||
|
assert f["error"] is None and f["skipped"] == []
|
||||||
|
assert f["statements_written"] == 1
|
||||||
|
assert f["nav_status"] == expected_status
|
||||||
|
assert f["nav_matched"] == 1 and f["nav_unmatched"] == 1
|
||||||
|
assert f["nav_cents"] == 555_00
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
stmts = session.exec(select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == fund.id
|
||||||
|
)).all()
|
||||||
|
assert len(stmts) == 1 # upserted, not duplicated
|
||||||
|
rounds = session.exec(select(ValuationRound).where(
|
||||||
|
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
|
||||||
|
)).all()
|
||||||
|
assert len(rounds) == 1
|
||||||
|
assert session.get(Position, pos.id).cost_cents == 100_00
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Admin Investor View reconstructs what one investor sees, read-only."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_investor_view_reconstructs(auth_client, session):
|
||||||
|
inv = make_user(session, username="lp1", role=UserRole.investor)
|
||||||
|
ent = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
other = Entity(name="Not Theirs", type=EntityType.fund)
|
||||||
|
session.add(ent)
|
||||||
|
session.add(other)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ent)
|
||||||
|
session.refresh(other)
|
||||||
|
|
||||||
|
session.add(EntityAccess(user_id=inv.id, entity_id=ent.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=ent.id, investor_user_id=inv.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=500_000, ending_balance_cents=600_000,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.get(f"/api/users/{inv.id}/investor-view")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["user"]["username"] == "lp1"
|
||||||
|
# Only the granted entity is visible, not the other one.
|
||||||
|
assert [e["id"] for e in body["entities"]] == [ent.id]
|
||||||
|
assert len(body["capital_accounts"]) == 1
|
||||||
|
assert body["capital_accounts"][0]["ending_balance_cents"] == 600_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_investor_view_rejects_non_investor(auth_client, session):
|
||||||
|
staff = make_user(session, username="ops", role=UserRole.operations)
|
||||||
|
assert auth_client.get(f"/api/users/{staff.id}/investor-view").status_code == 400
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Credit-fund loan schedule: the eNAV 'HLD-LOANS' sheet must fold into holdings and NAV.
|
||||||
|
|
||||||
|
Regression for the SATS Credit Fund case where the Portal showed $3.6M of securities while
|
||||||
|
the fund held a $46.3M loan — because the loan lives on a separate schedule, not the HLD
|
||||||
|
Holdings Report. See import_router._parse_loans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
from ten31portal.routers.import_router import _parse_schedule_xlsx
|
||||||
|
|
||||||
|
|
||||||
|
def _enav_with_loan() -> bytes:
|
||||||
|
"""Minimal eNAV: an HLD securities sheet plus an HLD-LOANS loan schedule."""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
hld = wb.active
|
||||||
|
hld.title = "HLD"
|
||||||
|
hld["A1"] = datetime(2025, 12, 31)
|
||||||
|
hld.append([None])
|
||||||
|
hld.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
|
||||||
|
hld.append(["BTC", 18.74, 1_734_584.37, 1_639_942.63])
|
||||||
|
hld.append(["Fold Holdings, Inc. - Warrant 03/26/25", 3_702_360, 0, 0])
|
||||||
|
hld.append(["TOTAL - LONG", None, 1_734_584.37, 3_597_442.63])
|
||||||
|
|
||||||
|
loans = wb.create_sheet("HLD-LOANS")
|
||||||
|
loans.append(["SATS CREDIT FUND LP"])
|
||||||
|
loans.append([None])
|
||||||
|
# Header + one loan row + a total row the parser must skip.
|
||||||
|
loans.append([
|
||||||
|
"COUNTERPART", "SECURITY NAME", "ISSUER NAME", "TRADE DATE",
|
||||||
|
"PRINCIPAL BALANCE", "TOTAL COST", "VALUE",
|
||||||
|
"VALUE PLUS ACCRUED INTEREST REC / (PAY)",
|
||||||
|
])
|
||||||
|
loans.append([
|
||||||
|
"Fold Holdings, Inc.", "Fold Holdings, Inc. (Floating Rate)", "Fold Holdings, Inc.",
|
||||||
|
datetime(2025, 3, 26), 46_279_500, 46_279_500, 46_279_500, 46_494_185.46,
|
||||||
|
])
|
||||||
|
loans.append(["TOTAL - LOAN", None, None, None, 46_279_500, 46_279_500, 46_279_500, 46_494_185.46])
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_loan_schedule_folds_into_holdings_and_nav():
|
||||||
|
name, as_of, holdings, positions, errors = _parse_schedule_xlsx(_enav_with_loan())
|
||||||
|
|
||||||
|
assert errors == []
|
||||||
|
assert as_of == date(2025, 12, 31)
|
||||||
|
|
||||||
|
# The loan groups under its issuer, which the warrant already created: no duplicate holding.
|
||||||
|
assert [h["company_name"] for h in holdings].count("Fold Holdings, Inc.") == 1
|
||||||
|
|
||||||
|
loan = next(p for p in positions if p["security_name"] == "Fold Holdings, Inc. (Floating Rate)")
|
||||||
|
assert loan["company_name"] == "Fold Holdings, Inc."
|
||||||
|
assert loan["value_cents"] == 46_494_185_46 # principal + accrued interest, in cents
|
||||||
|
assert loan["cost_cents"] == 46_279_500_00
|
||||||
|
assert loan["investment_date"] == "2025-03-26"
|
||||||
|
assert loan["shares"] is None
|
||||||
|
|
||||||
|
# NAV is the sum of every position value: securities ($3.6M) + the loan ($46.5M).
|
||||||
|
nav = sum(p["value_cents"] or 0 for p in positions)
|
||||||
|
assert nav == 1_639_942_63 + 0 + 46_494_185_46
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_loan_sheet_is_a_noop():
|
||||||
|
"""A workbook with no HLD-LOANS sheet parses the securities exactly as before."""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
hld = wb.active
|
||||||
|
hld.title = "HLD"
|
||||||
|
hld["A1"] = datetime(2025, 12, 31)
|
||||||
|
hld.append([None])
|
||||||
|
hld.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
|
||||||
|
hld.append(["Acme - Series A", 10, 100.0, 900.0])
|
||||||
|
out = io.BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
|
||||||
|
_, _, holdings, positions, errors = _parse_schedule_xlsx(out.getvalue())
|
||||||
|
assert errors == []
|
||||||
|
assert len(positions) == 1
|
||||||
|
assert positions[0]["value_cents"] == 900_00
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
|
||||||
|
enable-investor-logins conversion, and the documents "New" badge watermark."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal import config
|
||||||
|
from ten31portal.auth import hash_password, verify_password
|
||||||
|
from ten31portal.models import (
|
||||||
|
Document, Entity, EntityAccess, EntityType, User, UserRole,
|
||||||
|
)
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, username, password):
|
||||||
|
return client.post("/api/auth/login", json={"login": username, "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_requires_eight_chars(auth_client):
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={"current_password": "password123", "new_password": "short7c"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "8 characters" in resp.json()["detail"]
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={"current_password": "password123", "new_password": "longenough8"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_created_member_gets_default_password(auth_client, session):
|
||||||
|
entity = Entity(name="Test Fund", type=EntityType.fund)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/import/capital-accounts/commit",
|
||||||
|
json={
|
||||||
|
"entity_id": entity.id,
|
||||||
|
"as_of_date": "2026-03-31",
|
||||||
|
"investors": [{
|
||||||
|
"action": "create",
|
||||||
|
"name": "New LP",
|
||||||
|
"username": "newlp",
|
||||||
|
"value_dollars": 100_000,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
user = session.exec(select(User).where(User.username == "newlp")).one()
|
||||||
|
assert user.login_enabled is True
|
||||||
|
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
|
||||||
|
|
||||||
|
# And the account can actually sign in with it.
|
||||||
|
resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_investor_logins_converts_only_no_login_accounts(session):
|
||||||
|
from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
|
||||||
|
|
||||||
|
no_login = make_user(session, username="dormant", role=UserRole.investor,
|
||||||
|
login_enabled=False, name="Dormant LP")
|
||||||
|
has_login = make_user(session, username="active-lp", role=UserRole.investor,
|
||||||
|
password="theirownpw", name="Active LP")
|
||||||
|
linked = make_user(session, username="linked", role=UserRole.investor,
|
||||||
|
login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
|
||||||
|
old_active_hash = has_login.password_hash
|
||||||
|
|
||||||
|
# Run the conversion against the test engine (CLI normally uses the real one).
|
||||||
|
import ten31portal.cli as cli_mod
|
||||||
|
orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
|
||||||
|
cli_mod.engine = session.get_bind()
|
||||||
|
cli_mod.run_migrations = lambda: None
|
||||||
|
try:
|
||||||
|
enable_investor_logins(None)
|
||||||
|
finally:
|
||||||
|
cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
|
||||||
|
|
||||||
|
session.refresh(no_login)
|
||||||
|
session.refresh(has_login)
|
||||||
|
session.refresh(linked)
|
||||||
|
assert no_login.login_enabled is True
|
||||||
|
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
|
||||||
|
# Working logins and linked secondary names are untouched.
|
||||||
|
assert has_login.password_hash == old_active_hash
|
||||||
|
assert linked.login_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_documents_new_badge(client, session):
|
||||||
|
entity = Entity(name="Badge Fund", type=EntityType.fund)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||||
|
|
||||||
|
old_doc = Document(
|
||||||
|
entity_id=entity.id, category="statement", title="Old statement",
|
||||||
|
original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
|
||||||
|
storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
|
||||||
|
)
|
||||||
|
session.add(old_doc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert _login(client, "lp", "password123").status_code == 200
|
||||||
|
|
||||||
|
# First ever visit: nothing badged (no watermark yet), watermark gets set.
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert [d["is_new"] for d in docs] == [False]
|
||||||
|
|
||||||
|
# A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
|
||||||
|
session.refresh(lp)
|
||||||
|
lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
|
||||||
|
session.add(lp)
|
||||||
|
new_doc = Document(
|
||||||
|
entity_id=entity.id, category="k1", title="Fresh K-1",
|
||||||
|
original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
|
||||||
|
storage_path="x-new",
|
||||||
|
)
|
||||||
|
session.add(new_doc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
flags = {d["title"]: d["is_new"] for d in docs}
|
||||||
|
assert flags == {"Fresh K-1": True, "Old statement": False}
|
||||||
|
|
||||||
|
# Within the same visit (watermark just advanced) the badge computation stays stable.
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""The startup re-import queue: a staged eNAV is imported on boot, then deleted.
|
||||||
|
|
||||||
|
This is how an importer fix (e.g. loan-schedule parsing) gets applied to an
|
||||||
|
already-signed round without a manual admin re-upload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
import ten31portal.database as db
|
||||||
|
from ten31portal import config
|
||||||
|
from ten31portal.models import Entity, EntityType, UserRole, Valuation, ValuationRound
|
||||||
|
from ten31portal.pending_reimports import process_pending_reimports
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from tests.test_loan_import import _enav_with_loan
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_reimport_drains_and_applies(session, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "portal.db"))
|
||||||
|
monkeypatch.setattr(db, "engine", session.get_bind())
|
||||||
|
|
||||||
|
make_user(session, username="approver", role=UserRole.approver)
|
||||||
|
fund = Entity(name="SATS Credit Fund LP", type=EntityType.fund)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(fund)
|
||||||
|
|
||||||
|
queue = tmp_path / "pending_reimports"
|
||||||
|
queue.mkdir()
|
||||||
|
(queue / "sats.xlsx").write_bytes(_enav_with_loan())
|
||||||
|
(queue / "sats.json").write_text(json.dumps({"entity_id": fund.id}))
|
||||||
|
|
||||||
|
process_pending_reimports()
|
||||||
|
|
||||||
|
# The round now includes the loan valuation; NAV = securities + loan (principal + accrued).
|
||||||
|
rnd = session.exec(select(ValuationRound).where(ValuationRound.entity_id == fund.id)).one()
|
||||||
|
nav = sum(
|
||||||
|
v.value_cents
|
||||||
|
for v in session.exec(select(Valuation).where(Valuation.round_id == rnd.id)).all()
|
||||||
|
)
|
||||||
|
assert nav == 1_639_942_63 + 0 + 46_494_185_46
|
||||||
|
|
||||||
|
# Processed files are removed so the import runs exactly once (the JSON carries a password).
|
||||||
|
assert not (queue / "sats.xlsx").exists()
|
||||||
|
assert not (queue / "sats.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_queue_dir_is_a_noop(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "portal.db"))
|
||||||
|
process_pending_reimports() # must not raise when the queue dir is absent
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""The entity rollup aggregates invested cost and the latest signed valuation correctly."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
Entity, EntityType, Holding, Position, RoundStatus, Valuation, ValuationRound,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollup_sums_cost_and_latest_signed_value(auth_client, session):
|
||||||
|
entity = Entity(name="Rollup Fund", type=EntityType.fund)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(entity)
|
||||||
|
|
||||||
|
holding = Holding(entity_id=entity.id, company_name="Acme")
|
||||||
|
session.add(holding)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(holding)
|
||||||
|
|
||||||
|
position = Position(
|
||||||
|
holding_id=holding.id,
|
||||||
|
security_name="Acme Series A",
|
||||||
|
investment_date=date(2025, 1, 15),
|
||||||
|
cost_cents=100_000,
|
||||||
|
)
|
||||||
|
session.add(position)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(position)
|
||||||
|
|
||||||
|
rnd = ValuationRound(
|
||||||
|
entity_id=entity.id,
|
||||||
|
quarter_end=date(2026, 3, 31),
|
||||||
|
status=RoundStatus.approved,
|
||||||
|
)
|
||||||
|
session.add(rnd)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(rnd)
|
||||||
|
|
||||||
|
session.add(Valuation(round_id=rnd.id, position_id=position.id, value_cents=150_000))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.get("/api/entities/rollup")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
row = next(r for r in resp.json() if r["id"] == entity.id)
|
||||||
|
assert row["invested_cents"] == 100_000
|
||||||
|
assert row["last_signed_value_cents"] == 150_000
|
||||||
|
assert row["committed_cents"] == 0 # no capital-account statements added
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Entity stakes: a GP entity's interest in the funds it manages."""
|
||||||
|
|
||||||
|
from ten31portal.models import Entity, EntityType
|
||||||
|
|
||||||
|
|
||||||
|
def test_stake_crud(auth_client, session):
|
||||||
|
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||||
|
fund = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
session.add(gp)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
session.refresh(fund)
|
||||||
|
|
||||||
|
created = auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes",
|
||||||
|
json={"fund_entity_id": fund.id, "ownership_pct": 20, "value_dollars": 1000},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
body = created.json()
|
||||||
|
assert body["fund_name"] == "LTPF I"
|
||||||
|
assert body["fund_type"] == "fund"
|
||||||
|
assert body["value_cents"] == 100_000 # dollars -> cents
|
||||||
|
stake_id = body["id"]
|
||||||
|
|
||||||
|
listed = auth_client.get(f"/api/entities/{gp.id}/stakes")
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
# An entity cannot hold a stake in itself.
|
||||||
|
assert auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": gp.id}
|
||||||
|
).status_code == 400
|
||||||
|
|
||||||
|
# Duplicate stake rejected.
|
||||||
|
assert auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": fund.id}
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
assert auth_client.delete(f"/api/entities/{gp.id}/stakes/{stake_id}").status_code == 200
|
||||||
|
assert auth_client.get(f"/api/entities/{gp.id}/stakes").json() == []
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regression test for the SPA static-file path-traversal fix.
|
||||||
|
|
||||||
|
Before the fix, the `/{path:path}` catch-all joined the request path onto the
|
||||||
|
static dir with no containment check, so percent-encoded traversal
|
||||||
|
(GET /..%2f..%2fdata%2fportal.db) read arbitrary files off disk — including the
|
||||||
|
session secret, which allowed forging an admin session. See main.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from ten31portal.main import _contained_static_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_contained_helper_blocks_traversal():
|
||||||
|
root = Path(tempfile.mkdtemp())
|
||||||
|
static = root / "static"
|
||||||
|
static.mkdir()
|
||||||
|
(static / "index.html").write_text("spa")
|
||||||
|
(static / "app.js").write_text("ok")
|
||||||
|
data = root / "data"
|
||||||
|
data.mkdir()
|
||||||
|
(data / "portal.db").write_text("secret-db")
|
||||||
|
|
||||||
|
# Legit assets resolve within the root.
|
||||||
|
assert _contained_static_path(static, "app.js") == (static / "app.js").resolve()
|
||||||
|
assert _contained_static_path(static, "index.html") == (static / "index.html").resolve()
|
||||||
|
|
||||||
|
# Traversal (already-decoded, i.e. what ..%2f becomes) escapes -> None.
|
||||||
|
for evil in ("../data/portal.db", "../../data/portal.db", "../data/../data/portal.db"):
|
||||||
|
assert _contained_static_path(static, evil) is None, evil
|
||||||
|
|
||||||
|
|
||||||
|
def test_spa_route_does_not_leak_via_encoded_traversal():
|
||||||
|
"""End-to-end: encoded traversal against the real route shape returns the
|
||||||
|
SPA shell, never the out-of-root file."""
|
||||||
|
root = Path(tempfile.mkdtemp())
|
||||||
|
static = root / "static"
|
||||||
|
static.mkdir()
|
||||||
|
(static / "index.html").write_text("<html>SPA</html>")
|
||||||
|
data = root / "data"
|
||||||
|
data.mkdir()
|
||||||
|
(data / ".session-secret").write_text("TOPSECRET")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def _index():
|
||||||
|
return FileResponse(static / "index.html")
|
||||||
|
|
||||||
|
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||||
|
async def serve_spa(path: str):
|
||||||
|
file = _contained_static_path(static, path)
|
||||||
|
if file is None or not file.is_file():
|
||||||
|
return _index()
|
||||||
|
return FileResponse(file)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
for attack in (
|
||||||
|
"/..%2f..%2fdata%2f.session-secret",
|
||||||
|
"/%2e%2e%2f%2e%2e%2fdata%2f.session-secret",
|
||||||
|
"/../../data/.session-secret",
|
||||||
|
):
|
||||||
|
r = client.get(attack)
|
||||||
|
assert "TOPSECRET" not in r.text, attack
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""TOTP two-factor: enrollment, two-step login, recovery codes, disable, CLI-style reset."""
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll(client):
|
||||||
|
"""Run the full setup+confirm flow for the signed-in user; return (secret, recovery_codes)."""
|
||||||
|
setup = client.post("/api/auth/totp/setup")
|
||||||
|
assert setup.status_code == 200, setup.text
|
||||||
|
secret = setup.json()["secret"]
|
||||||
|
assert setup.json()["qr_svg"].lstrip().startswith("<?xml") or "<svg" in setup.json()["qr_svg"]
|
||||||
|
confirm = client.post(
|
||||||
|
"/api/auth/totp/confirm", json={"code": pyotp.TOTP(secret).now()}
|
||||||
|
)
|
||||||
|
assert confirm.status_code == 200, confirm.text
|
||||||
|
codes = confirm.json()["recovery_codes"]
|
||||||
|
assert len(codes) == 8
|
||||||
|
return secret, codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_then_login_requires_code(client, session):
|
||||||
|
make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
assert client.post("/api/auth/login", json={"login": "mp", "password": "password123"}).status_code == 200
|
||||||
|
secret, _ = _enroll(client)
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
|
||||||
|
# Password alone no longer signs in — it parks the session pending the code.
|
||||||
|
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"requires_2fa": True}
|
||||||
|
assert client.get("/api/auth/me").status_code == 401
|
||||||
|
|
||||||
|
# Wrong code is rejected; the right code completes sign-in.
|
||||||
|
bad = client.post("/api/auth/login/verify-totp", json={"code": "000000"})
|
||||||
|
assert bad.status_code == 401
|
||||||
|
good = client.post(
|
||||||
|
"/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()}
|
||||||
|
)
|
||||||
|
assert good.status_code == 200, good.text
|
||||||
|
assert good.json()["username"] == "mp"
|
||||||
|
assert good.json()["totp_enabled"] is True
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_code_works_once(client, session):
|
||||||
|
make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
_, codes = _enroll(client)
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
first = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
|
||||||
|
# The same recovery code is spent and cannot be used again.
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
again = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
|
||||||
|
assert again.status_code == 401
|
||||||
|
other = client.post("/api/auth/login/verify-totp", json={"code": codes[1]})
|
||||||
|
assert other.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_without_pending_login_fails(client, session):
|
||||||
|
make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
resp = client.post("/api/auth/login/verify-totp", json={"code": "123456"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_requires_valid_first_code(client, session):
|
||||||
|
make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
setup = client.post("/api/auth/totp/setup")
|
||||||
|
assert setup.status_code == 200
|
||||||
|
bad = client.post("/api/auth/totp/confirm", json={"code": "000000"})
|
||||||
|
assert bad.status_code == 400
|
||||||
|
# Enrollment never completed, so login stays single-step.
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "mp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_restores_single_step_login(client, session):
|
||||||
|
make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
secret, _ = _enroll(client)
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
client.post("/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()})
|
||||||
|
|
||||||
|
wrong = client.post("/api/auth/totp/disable", json={"password": "not-it"})
|
||||||
|
assert wrong.status_code == 400
|
||||||
|
ok = client.post("/api/auth/totp/disable", json={"password": "password123"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "mp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_style_reset_clears_enrollment(client, session):
|
||||||
|
"""Clearing the totp fields (what the reset-2fa CLI does) restores password-only login."""
|
||||||
|
user = make_user(session, username="mp", role=UserRole.approver)
|
||||||
|
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
_enroll(client)
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
|
||||||
|
db_user = session.get(User, user.id)
|
||||||
|
session.refresh(db_user)
|
||||||
|
assert db_user.totp_enabled is True
|
||||||
|
db_user.totp_secret = None
|
||||||
|
db_user.totp_enabled = False
|
||||||
|
db_user.totp_recovery_codes = None
|
||||||
|
session.add(db_user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "mp"
|
||||||
+13
-2
@@ -1,7 +1,10 @@
|
|||||||
# Build context is the repo root (docker build -f deploy/Dockerfile .)
|
# Build context is the repo root (docker build -f deploy/Dockerfile .)
|
||||||
|
|
||||||
# --- Frontend build stage ---
|
# --- Frontend build stage ---
|
||||||
FROM node:20-slim AS frontend-build
|
# Build on the native builder arch ($BUILDPLATFORM) so the JS bundler runs without
|
||||||
|
# cross-arch emulation. The output is static assets (HTML/CSS/JS), which are
|
||||||
|
# architecture-independent and copied into the target-arch runtime stage below.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
@@ -31,8 +34,16 @@ RUN chmod +x ./start.sh
|
|||||||
# Data volume mount point
|
# Data volume mount point
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
# Unprivileged account for the server process. The container still starts as root (see start.sh)
|
||||||
|
# so it can chown the platform-mounted /data volume, then drops to this uid via setpriv.
|
||||||
|
RUN groupadd --gid 10001 appuser \
|
||||||
|
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin appuser
|
||||||
|
|
||||||
ENV TEN31_DB_PATH=/data/portal.db
|
ENV TEN31_DB_PATH=/data/portal.db
|
||||||
ENV TEN31_SESSION_SECRET=***
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Ten31
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
# Ten31Portal
|
||||||
|
|
||||||
|
Internal system of record for Ten31 entities, holdings, positions, and quarterly
|
||||||
|
valuation sign-off, plus an investor / fund-administrator portal.
|
||||||
|
|
||||||
|
## First login
|
||||||
|
|
||||||
|
On first boot a default administrator (approver) account is created. Unless you set
|
||||||
|
the environment variables below, the defaults are:
|
||||||
|
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `Ten31`
|
||||||
|
|
||||||
|
Open the web interface and sign in. Change this password immediately from the Users
|
||||||
|
screen, or create a new admin and disable the default one.
|
||||||
|
|
||||||
|
The login field accepts a username **or** an email address.
|
||||||
|
|
||||||
|
## Accounts and access
|
||||||
|
|
||||||
|
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) use the back-office
|
||||||
|
app: entities, holdings, positions, valuation rounds, import, and audit log.
|
||||||
|
Approvers and the CFO also get the admin screens below.
|
||||||
|
- **External accounts** (`investor`, `fund_administrator`) get an entity-scoped
|
||||||
|
portal and only ever see the entities granted to them.
|
||||||
|
- **Investors** see, per fund, their latest capital-account value and history, plus
|
||||||
|
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||||
|
- **Fund administrators** see their assigned entities and can upload documents.
|
||||||
|
|
||||||
|
From the **Users** screen, create an account with a username and password and check
|
||||||
|
off which entities it can view. Use **Documents** to upload statements and K-1s
|
||||||
|
(shared to a fund or private to one investor), and **Capital Accounts** to enter each
|
||||||
|
investor's figures.
|
||||||
|
|
||||||
|
## Data and backups
|
||||||
|
|
||||||
|
All data — the database, uploaded documents, and the generated session secret — lives
|
||||||
|
on the service's data volume and is included in StartOS platform backups. Create a
|
||||||
|
backup before uninstalling; uninstalling removes all fund data.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.1.0",
|
"version": "0.2.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.1.0",
|
"version": "0.2.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@start9labs/start-sdk": "^0.4.0-beta.58"
|
"@start9labs/start-sdk": "^0.4.0-beta.58"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.1.0",
|
"version": "0.2.48",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+62
-19
@@ -1,8 +1,11 @@
|
|||||||
#!/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
|
||||||
|
# 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
|
if [ -f /data/.session-secret ]; then
|
||||||
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
|
||||||
else
|
else
|
||||||
@@ -10,29 +13,69 @@ if [ "$TEN31_SESSION_SECRET" = "change-me" ]; then
|
|||||||
echo "$TEN31_SESSION_SECRET" > /data/.session-secret
|
echo "$TEN31_SESSION_SECRET" > /data/.session-secret
|
||||||
chmod 600 /data/.session-secret
|
chmod 600 /data/.session-secret
|
||||||
fi
|
fi
|
||||||
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
|
||||||
echo "First boot: running migrations and creating default approver..."
|
echo "First boot: running migrations and creating the admin account..."
|
||||||
|
|
||||||
# Set default credentials (user can change via CLI later)
|
|
||||||
ADMIN_NAME="${TEN31_ADMIN_NAME:-Jonathan}"
|
ADMIN_NAME="${TEN31_ADMIN_NAME:-Jonathan}"
|
||||||
|
ADMIN_USERNAME="${TEN31_ADMIN_USERNAME:-admin}"
|
||||||
ADMIN_EMAIL="${TEN31_ADMIN_EMAIL:-jonathan@ten31.xyz}"
|
ADMIN_EMAIL="${TEN31_ADMIN_EMAIL:-jonathan@ten31.xyz}"
|
||||||
ADMIN_PASSWORD="${TEN31_ADMIN_PASSWORD:-Ten31}"
|
|
||||||
|
|
||||||
python3 -m ten31portal.cli create-user \
|
# No weak default: use an operator-supplied password if given, else generate a strong random
|
||||||
--name "$ADMIN_NAME" \
|
# one and record it (0600) so it can be retrieved once via the "Show Initial Admin Password"
|
||||||
--email "$ADMIN_EMAIL" \
|
# action. There is no fixed default credential to guess.
|
||||||
--role approver \
|
ADMIN_PW_FILE="$(dirname "$TEN31_DB_PATH")/.admin-password"
|
||||||
--password "$ADMIN_PASSWORD" || true
|
if [ -n "$TEN31_ADMIN_PASSWORD" ]; then
|
||||||
|
ADMIN_PASSWORD="$TEN31_ADMIN_PASSWORD"
|
||||||
touch /data/.initialized
|
GENERATED=""
|
||||||
echo "Default approver created: $ADMIN_EMAIL"
|
else
|
||||||
|
ADMIN_PASSWORD="$(python3 -c "import secrets; print(secrets.token_urlsafe(18))")"
|
||||||
|
GENERATED="yes"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Serve frontend static files from the backend
|
if python3 -m ten31portal.cli create-user \
|
||||||
exec uvicorn ten31portal.main:app \
|
--name "$ADMIN_NAME" \
|
||||||
--host 0.0.0.0 \
|
--username "$ADMIN_USERNAME" \
|
||||||
--port 8000 \
|
--email "$ADMIN_EMAIL" \
|
||||||
--log-level info
|
--role approver \
|
||||||
|
--password "$ADMIN_PASSWORD" \
|
||||||
|
--service-admin; then
|
||||||
|
if [ -n "$GENERATED" ]; then
|
||||||
|
printf '%s' "$ADMIN_PASSWORD" > "$ADMIN_PW_FILE"
|
||||||
|
chmod 600 "$ADMIN_PW_FILE"
|
||||||
|
echo "Admin '$ADMIN_USERNAME' created with a generated password."
|
||||||
|
echo " Retrieve it once via the 'Show Initial Admin Password' service action, then change it."
|
||||||
|
else
|
||||||
|
echo "Admin '$ADMIN_USERNAME' created with the operator-supplied password."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch /data/.initialized
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Log level is overridable at runtime for debugging; defaults to info.
|
||||||
|
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" \
|
||||||
|
--proxy-headers --forwarded-allow-ips="*"
|
||||||
|
else
|
||||||
|
exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" \
|
||||||
|
--proxy-headers --forwarded-allow-ips="*"
|
||||||
|
fi
|
||||||
|
|||||||
+483
-53
@@ -2,6 +2,29 @@ import { sdk } from '../sdk'
|
|||||||
|
|
||||||
const { InputSpec, Value, Action } = sdk
|
const { InputSpec, Value, Action } = sdk
|
||||||
|
|
||||||
|
// Run a ten31portal CLI subcommand inside the service container against the data volume.
|
||||||
|
async function runCli(effects: any, args: string[], taskName: string) {
|
||||||
|
const sub = await sdk.SubContainer.of(
|
||||||
|
effects,
|
||||||
|
{ imageId: 'main' },
|
||||||
|
sdk.Mounts.of().mountVolume({
|
||||||
|
volumeId: 'main',
|
||||||
|
subpath: null,
|
||||||
|
mountpoint: '/data',
|
||||||
|
readonly: false,
|
||||||
|
}),
|
||||||
|
taskName,
|
||||||
|
)
|
||||||
|
return sub.exec(
|
||||||
|
['python3', '-m', 'ten31portal.cli', ...args],
|
||||||
|
{ env: { TEN31_DB_PATH: '/data/portal.db' } },
|
||||||
|
30000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResult = (message: string) =>
|
||||||
|
({ version: '1' as const, title: 'Error', message, result: null })
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Action: Create User
|
// Action: Create User
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -26,6 +49,13 @@ const createUserInputSpec = InputSpec.of({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Login username',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'jsmith',
|
||||||
|
}),
|
||||||
password: Value.text({
|
password: Value.text({
|
||||||
name: 'Password',
|
name: 'Password',
|
||||||
description: 'Initial password (user should change after first login)',
|
description: 'Initial password (user should change after first login)',
|
||||||
@@ -36,13 +66,12 @@ const createUserInputSpec = InputSpec.of({
|
|||||||
role: Value.select({
|
role: Value.select({
|
||||||
name: 'Role',
|
name: 'Role',
|
||||||
description:
|
description:
|
||||||
'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.',
|
'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.',
|
||||||
default: 'viewer',
|
default: 'operations',
|
||||||
values: {
|
values: {
|
||||||
approver: 'Approver',
|
approver: 'Managing Partner',
|
||||||
cfo: 'CFO',
|
operations: 'Operations',
|
||||||
fund_admin: 'Fund Admin',
|
fund_admin: 'Fund Admin',
|
||||||
viewer: 'Viewer',
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -51,7 +80,7 @@ const createUserAction = Action.withInput(
|
|||||||
'create-user',
|
'create-user',
|
||||||
{
|
{
|
||||||
name: 'Create User',
|
name: 'Create User',
|
||||||
description: 'Add a new user account with a role',
|
description: 'Add a new staff user account with a role',
|
||||||
warning: null,
|
warning: null,
|
||||||
allowedStatuses: 'only-running',
|
allowedStatuses: 'only-running',
|
||||||
group: null,
|
group: null,
|
||||||
@@ -61,69 +90,470 @@ const createUserAction = Action.withInput(
|
|||||||
async () => ({
|
async () => ({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'viewer' as const,
|
role: 'operations' as const,
|
||||||
}),
|
}),
|
||||||
async ({ input, effects }) => {
|
async ({ input, effects }) => {
|
||||||
try {
|
try {
|
||||||
const sub = await sdk.SubContainer.of(
|
const result = await runCli(
|
||||||
effects,
|
effects,
|
||||||
{ imageId: 'main' },
|
[
|
||||||
sdk.Mounts.of().mountVolume({
|
'create-user',
|
||||||
volumeId: 'main',
|
'--name', input.name,
|
||||||
subpath: null,
|
'--username', input.username,
|
||||||
mountpoint: '/data',
|
'--email', input.email,
|
||||||
readonly: false,
|
'--role', input.role,
|
||||||
}),
|
'--password', input.password,
|
||||||
|
],
|
||||||
'create-user-task',
|
'create-user-task',
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await sub.exec(
|
|
||||||
[
|
|
||||||
'python3',
|
|
||||||
'-m',
|
|
||||||
'ten31portal.cli',
|
|
||||||
'create-user',
|
|
||||||
'--name',
|
|
||||||
input.name,
|
|
||||||
'--email',
|
|
||||||
input.email,
|
|
||||||
'--role',
|
|
||||||
input.role,
|
|
||||||
'--password',
|
|
||||||
input.password,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
env: { TEN31_DB_PATH: '/data/portal.db' },
|
|
||||||
},
|
|
||||||
30000,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
const stderr = result.stderr?.toString() || 'Unknown error'
|
return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`)
|
||||||
return {
|
|
||||||
version: '1' as const,
|
|
||||||
title: 'Error',
|
|
||||||
message: `Failed to create user: ${stderr}`,
|
|
||||||
result: null,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: '1' as const,
|
version: '1' as const,
|
||||||
title: 'User Created',
|
title: 'User Created',
|
||||||
message: `Created user ${input.name} (${input.email}) with role: ${input.role}`,
|
message: `Created ${input.name} (${input.username}) with role: ${input.role}`,
|
||||||
result: null,
|
result: null,
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return {
|
return errorResult(`Failed to create user: ${e.message || e}`)
|
||||||
version: '1' as const,
|
|
||||||
title: 'Error',
|
|
||||||
message: `Failed to create user: ${e.message || e}`,
|
|
||||||
result: null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const actions = sdk.Actions.of().addAction(createUserAction)
|
// ============================================
|
||||||
|
// Action: Reset Password
|
||||||
|
// ============================================
|
||||||
|
const resetPasswordInputSpec = InputSpec.of({
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Username of the account to reset (e.g. the Admin / Service Admin)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'admin',
|
||||||
|
}),
|
||||||
|
password: Value.text({
|
||||||
|
name: 'New Password',
|
||||||
|
description: 'The new password for this account',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'minimum 4 characters',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPasswordAction = Action.withInput(
|
||||||
|
'reset-password',
|
||||||
|
{
|
||||||
|
name: 'Reset Password',
|
||||||
|
description: "Reset any user's password (including the Admin / Service Admin)",
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetPasswordInputSpec,
|
||||||
|
async () => ({ username: '', password: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
effects,
|
||||||
|
['reset-password', '--username', input.username, '--password', input.password],
|
||||||
|
'reset-password-task',
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to reset password')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Password Reset',
|
||||||
|
message: `Password reset for ${input.username}. Their login is enabled.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to reset password: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Reset Two-Factor
|
||||||
|
// ============================================
|
||||||
|
const resetTwoFactorInputSpec = InputSpec.of({
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Username of the account whose two-factor should be cleared (lost phone)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'admin',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetTwoFactorAction = Action.withInput(
|
||||||
|
'reset-2fa',
|
||||||
|
{
|
||||||
|
name: 'Reset Two-Factor',
|
||||||
|
description:
|
||||||
|
"Clear a user's two-factor enrollment so they can sign in with just their password (e.g. after losing their authenticator)",
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetTwoFactorInputSpec,
|
||||||
|
async () => ({ username: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
effects,
|
||||||
|
['reset-2fa', '--username', input.username],
|
||||||
|
'reset-2fa-task',
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to reset two-factor')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Two-Factor Reset',
|
||||||
|
message: `Two-factor cleared for ${input.username}. They can sign in with their password and re-enroll from the app.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to reset two-factor: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: List Users
|
||||||
|
// ============================================
|
||||||
|
const listUsersAction = Action.withoutInput(
|
||||||
|
'list-users',
|
||||||
|
{
|
||||||
|
name: 'List Users',
|
||||||
|
description: 'Show every user account and role',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['list-users'], 'list-users-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to list users')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Users',
|
||||||
|
message: 'The Service Admin cannot be deleted.',
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'No users.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to list users: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Show Initial Admin Password
|
||||||
|
// ============================================
|
||||||
|
const showAdminPasswordAction = Action.withoutInput(
|
||||||
|
'show-admin-password',
|
||||||
|
{
|
||||||
|
name: 'Show Initial Admin Password',
|
||||||
|
description:
|
||||||
|
'Reveal the randomly-generated admin password created on first boot. Sign in with it, then change your password — after which this no longer shows it.',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['show-admin-password'], 'show-admin-password-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to read the admin password')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Initial Admin Password',
|
||||||
|
message: 'Sign in as "admin" with this password, then change it from the portal.',
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: (result.stdout?.toString() || '').trim() || 'No stored password.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to read the admin password: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Delete User
|
||||||
|
// ============================================
|
||||||
|
const deleteUserInputSpec = InputSpec.of({
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Username of the account to delete (use List Users to see them)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'jsmith',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteUserAction = Action.withInput(
|
||||||
|
'delete-user',
|
||||||
|
{
|
||||||
|
name: 'Delete User',
|
||||||
|
description: 'Permanently delete a user account',
|
||||||
|
warning:
|
||||||
|
'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
deleteUserInputSpec,
|
||||||
|
async () => ({ username: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
effects,
|
||||||
|
['delete-user', '--username', input.username],
|
||||||
|
'delete-user-task',
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to delete user')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'User Deleted',
|
||||||
|
message: result.stdout?.toString() || `Deleted ${input.username}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to delete user: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Fix Duplicate Holdings
|
||||||
|
// ============================================
|
||||||
|
const dedupeAction = Action.withoutInput(
|
||||||
|
'dedupe-holdings',
|
||||||
|
{
|
||||||
|
name: 'Fix Duplicate Holdings',
|
||||||
|
description:
|
||||||
|
'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Duplicates Cleaned Up',
|
||||||
|
message: result.stdout?.toString() || 'Done.',
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to clean up duplicates: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: List Funds
|
||||||
|
// ============================================
|
||||||
|
const listFundsAction = Action.withoutInput(
|
||||||
|
'list-funds',
|
||||||
|
{
|
||||||
|
name: 'List Funds',
|
||||||
|
description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['list-funds'], 'list-funds-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to list funds')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Funds',
|
||||||
|
message: null,
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'No funds.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to list funds: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Reset Fund Holdings
|
||||||
|
// ============================================
|
||||||
|
const resetHoldingsInputSpec = InputSpec.of({
|
||||||
|
name: Value.text({
|
||||||
|
name: 'Fund Name',
|
||||||
|
description: 'Exact name of the fund to clear (see List Funds)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Low Time Preference Fund III, LP',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetHoldingsAction = Action.withInput(
|
||||||
|
'reset-holdings',
|
||||||
|
{
|
||||||
|
name: 'Reset Fund Holdings',
|
||||||
|
description:
|
||||||
|
'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.',
|
||||||
|
warning:
|
||||||
|
'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetHoldingsInputSpec,
|
||||||
|
async () => ({ name: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to reset fund')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Fund Cleared',
|
||||||
|
message: result.stdout?.toString() || `Cleared ${input.name}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to reset fund: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Reset Fund Partners
|
||||||
|
// ============================================
|
||||||
|
const resetPartnersInputSpec = InputSpec.of({
|
||||||
|
name: Value.text({
|
||||||
|
name: 'Fund Name',
|
||||||
|
description: 'Exact name of the fund whose partners to clear (see List Funds)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Low Time Preference Fund III, LP',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPartnersAction = Action.withInput(
|
||||||
|
'reset-partners',
|
||||||
|
{
|
||||||
|
name: 'Reset Fund Partners',
|
||||||
|
description:
|
||||||
|
"Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).",
|
||||||
|
warning:
|
||||||
|
"This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.",
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetPartnersInputSpec,
|
||||||
|
async () => ({ name: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to clear partners')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Partners Cleared',
|
||||||
|
message: result.stdout?.toString() || `Cleared partners from ${input.name}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to clear partners: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Enable Investor Logins
|
||||||
|
// ============================================
|
||||||
|
const enableInvestorLoginsAction = Action.withoutInput(
|
||||||
|
'enable-investor-logins',
|
||||||
|
{
|
||||||
|
name: 'Enable Investor Logins',
|
||||||
|
description:
|
||||||
|
'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.',
|
||||||
|
warning: 'Every converted account gets the same well-known default password until the investor changes it.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to enable investor logins')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Investor Logins Enabled',
|
||||||
|
message: 'Send each investor their username; they sign in with the default password and change it.',
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'Nothing to do.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to enable investor logins: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const actions = sdk.Actions.of()
|
||||||
|
.addAction(createUserAction)
|
||||||
|
.addAction(resetPasswordAction)
|
||||||
|
.addAction(resetTwoFactorAction)
|
||||||
|
.addAction(showAdminPasswordAction)
|
||||||
|
.addAction(listUsersAction)
|
||||||
|
.addAction(enableInvestorLoginsAction)
|
||||||
|
.addAction(deleteUserAction)
|
||||||
|
.addAction(dedupeAction)
|
||||||
|
.addAction(listFundsAction)
|
||||||
|
.addAction(resetHoldingsAction)
|
||||||
|
.addAction(resetPartnersAction)
|
||||||
|
|||||||
@@ -1,2 +1,50 @@
|
|||||||
export { v_0_1_0 as current } from './v_0_1_0'
|
export { v_0_2_48 as current } from './v_0_2_48'
|
||||||
export const other = []
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
|
import { v_0_2_47 } from './v_0_2_47'
|
||||||
|
import { v_0_2_46 } from './v_0_2_46'
|
||||||
|
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'
|
||||||
|
import { v_0_2_43 } from './v_0_2_43'
|
||||||
|
import { v_0_2_44 } from './v_0_2_44'
|
||||||
|
import { v_0_2_0 } from './v_0_2_0'
|
||||||
|
import { v_0_2_1 } from './v_0_2_1'
|
||||||
|
import { v_0_2_3 } from './v_0_2_3'
|
||||||
|
import { v_0_2_4 } from './v_0_2_4'
|
||||||
|
import { v_0_2_5 } from './v_0_2_5'
|
||||||
|
import { v_0_2_6 } from './v_0_2_6'
|
||||||
|
import { v_0_2_7 } from './v_0_2_7'
|
||||||
|
import { v_0_2_8 } from './v_0_2_8'
|
||||||
|
import { v_0_2_9 } from './v_0_2_9'
|
||||||
|
import { v_0_2_10 } from './v_0_2_10'
|
||||||
|
import { v_0_2_11 } from './v_0_2_11'
|
||||||
|
import { v_0_2_12 } from './v_0_2_12'
|
||||||
|
import { v_0_2_13 } from './v_0_2_13'
|
||||||
|
import { v_0_2_14 } from './v_0_2_14'
|
||||||
|
import { v_0_2_15 } from './v_0_2_15'
|
||||||
|
import { v_0_2_16 } from './v_0_2_16'
|
||||||
|
import { v_0_2_17 } from './v_0_2_17'
|
||||||
|
import { v_0_2_18 } from './v_0_2_18'
|
||||||
|
import { v_0_2_19 } from './v_0_2_19'
|
||||||
|
import { v_0_2_20 } from './v_0_2_20'
|
||||||
|
import { v_0_2_21 } from './v_0_2_21'
|
||||||
|
import { v_0_2_22 } from './v_0_2_22'
|
||||||
|
import { v_0_2_23 } from './v_0_2_23'
|
||||||
|
import { v_0_2_24 } from './v_0_2_24'
|
||||||
|
import { v_0_2_25 } from './v_0_2_25'
|
||||||
|
import { v_0_2_26 } from './v_0_2_26'
|
||||||
|
import { v_0_2_27 } from './v_0_2_27'
|
||||||
|
import { v_0_2_28 } from './v_0_2_28'
|
||||||
|
import { v_0_2_29 } from './v_0_2_29'
|
||||||
|
import { v_0_2_30 } from './v_0_2_30'
|
||||||
|
import { v_0_2_31 } from './v_0_2_31'
|
||||||
|
import { v_0_2_32 } from './v_0_2_32'
|
||||||
|
import { v_0_2_33 } from './v_0_2_33'
|
||||||
|
import { v_0_2_34 } from './v_0_2_34'
|
||||||
|
import { v_0_2_35 } from './v_0_2_35'
|
||||||
|
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, v_0_2_45, v_0_2_46, v_0_2_47]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_0 = VersionInfo.of({
|
||||||
|
version: '0.2.0:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Investor and fund-administrator accounts. Per-entity access control, built-in document storage (statements, K-1s), and per-investor capital-account statements. Admin screens to create accounts, grant entity access, upload documents, and enter capital-account figures.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// New tables (entity_access, documents, capital_account_statements) and the
|
||||||
|
// users.username column are applied by Alembic on app startup. No StartOS-level
|
||||||
|
// data migration is required.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_1 = VersionInfo.of({
|
||||||
|
version: '0.2.1:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Owner access grid (accounts by fund/SPV, click to grant or revoke access) and a capital-account importer that reads a fund-administrator spreadsheet, matches or creates investor accounts on review, grants entity access, and loads each investor\'s capital-account balance.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// No schema changes beyond 0.2.0; new features reuse existing tables. Alembic
|
||||||
|
// remains the source of truth for the database schema on app startup.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_10 = VersionInfo.of({
|
||||||
|
version: '0.2.10:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'One unified Import: a single eNAV upload loads fund holdings/NAV and members + capital (commitment, paid-in, distributions, current value). Investor view now shows commitment and DPI alongside current value. Menu cleanup: one Import item, Audit Log moved to the bottom.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// Adds capital_account_statements.commitment_cents; applied by Alembic on startup.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_11 = VersionInfo.of({
|
||||||
|
version: '0.2.11:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Linked investor logins: an investor who invests under several names (e.g. an IRA and a trust, or through different Partners) can be given one sign-on that shows every name\'s investments together, labeled by name. Set it in Users → Manage → Login. Also: sortable columns on the Entities view.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// Adds users.primary_account_id; applied by Alembic on startup.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_12 = VersionInfo.of({
|
||||||
|
version: '0.2.12:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Clearnet performance: gzip compression on all responses (the ~320KB app bundle drops to ~90KB over the wire) and long-lived browser caching of content-hashed assets, so repeat visits are near-instant. No data changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_13 = VersionInfo.of({
|
||||||
|
version: '0.2.13:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Re-importing a NAV now updates that quarter in place instead of adding a second round (no more doubled totals on the Entities view). New Ten31 Portal branding and logo.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_14 = VersionInfo.of({
|
||||||
|
version: '0.2.14:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'User administration from StartOS Actions: Create User, Reset Password, List Users, and Delete User. New role layout — Managing Partner (full access incl. sign-off), Operations (full access except sign-off), Fund Admin. The built-in Service Admin can be reset but never deleted.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// Adds users.is_service_admin and flags the bootstrap admin; applied by Alembic on startup.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_15 = VersionInfo.of({
|
||||||
|
version: '0.2.15:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Fixes doubled "Invested" totals by removing duplicate holdings/positions left by older imports (runs automatically on the next import of a fund, or all at once via the new "Fix Duplicate Holdings" action). Usernames are now editable in Users → Manage. Mobile/PWA: installable to a phone home screen with an app icon, plus a responsive layout with a collapsible menu.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_16 = VersionInfo.of({
|
||||||
|
version: '0.2.16:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Clear and rebuild a fund: re-import with the new "Replace existing holdings" option to wipe a fund\'s old positions and rebuild from the file — for when a source change (e.g. Carta → eNAV) renamed every position. Also available as the "Reset Fund Holdings" and "List Funds" actions. Investor capital accounts are never affected.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_17 = VersionInfo.of({
|
||||||
|
version: '0.2.17:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'The Entities view now shows a Committed column (total LP commitments to each fund) alongside Invested (capital deployed into companies) and Last Signed Value.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_18 = VersionInfo.of({
|
||||||
|
version: '0.2.18:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'The fund Partners tab now shows each member\'s Committed, Paid-in, and Distributions alongside their capital value, with a total committed for the fund — matching what investors see in their own portal.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_19 = VersionInfo.of({
|
||||||
|
version: '0.2.19:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Mobile/PWA fixes: the top bar (including Sign out) no longer hides behind the phone status bar, and the Entities view now shows each fund as a card that fits the screen — no more zooming out or sideways scrolling.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_20 = VersionInfo.of({
|
||||||
|
version: '0.2.20:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Passwords now persist when you edit an investor (the password only changes if you type a new one). Investors can change their own password from the portal, and admins keep the ability to reset it. Every password field has a show/hide eye toggle.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_21 = VersionInfo.of({
|
||||||
|
version: '0.2.21:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Importing with replace-existing-holdings now asks for confirmation before clearing a fund, and shows step-by-step progress. You can expand an audit log entry to see its full detail. Entities with no investments show a clear empty state. A brief server hiccup shows a retry prompt instead of logging you out. Backend adds document upload size limits, database indexes, and an automated test suite.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_22 = VersionInfo.of({
|
||||||
|
version: '0.2.22:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Investors see a graph of their capital over time (value, paid-in, and distributions per quarter). A new admin Investor View shows exactly what an investor sees, read-only. Document uploads are scoped to the chosen fund\'s own investors with a clear target, so a file cannot go to the wrong person. GP and management-company entities get an Assets tab that lists their interests in the funds they manage. You can now edit an existing entity, including its type.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_23 = VersionInfo.of({
|
||||||
|
version: '0.2.23:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'You can link a GP or management-company entity to its investor account. Its Assets tab then shows its real capital-account balance in each fund, pulled from the eNAV, with no double entry. Set this on the entity\'s Edit form.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_24 = VersionInfo.of({
|
||||||
|
version: '0.2.24:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'A GP or management entity\'s linked capital balance now shows on its Overview page too, not only the Assets tab. The Assets view also includes balances held under the linked account\'s other legal names (as the eNAV often splits one LLC across names), and shows a clear message when a linked account has no balances on file.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_25 = VersionInfo.of({
|
||||||
|
version: '0.2.25:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Load several past quarters of investor capital at once: the Import page now has a "Backfill historical capital" batch — drop a fund\'s eNAV workbooks and each file\'s members are matched to existing accounts and saved at that file\'s own as-of date, building trend-lines without replacing the latest figures (members not already in the portal are skipped, never created). Investors\' "Capital over time" chart is now collapsed by default and expands per fund.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_26 = VersionInfo.of({
|
||||||
|
version: '0.2.26:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Security hardening (from a full audit): no more fixed default admin password — first boot generates a strong random one, retrievable once via the new "Show Initial Admin Password" action and cleared after you change it. Login now rate-limits repeated failures and returns a single generic message (no username enumeration). The server process runs unprivileged (non-root). Spreadsheet imports are size-capped and batch import errors no longer leak internal details.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_27 = VersionInfo.of({
|
||||||
|
version: '0.2.27:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Entity overview now shows a full Valuation history — every quarter on record with its NAV, status, and signed date — instead of just the latest quarter. Investor portal now shows, per fund/SPV, gain/loss (amount and % vs paid-in) on the capital account and the % of commitment distributed.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_28 = VersionInfo.of({
|
||||||
|
version: '0.2.28:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor gain/loss now measures total value (current NAV + distributions received) against paid-in capital, so an LP who has taken distributions no longer shows a false loss.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_29 = VersionInfo.of({
|
||||||
|
version: '0.2.29:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"New 'Reset Fund Partners' service action (and a 'Clear all partners' button on a fund's Partners tab) removes all of a fund's capital-account statements and investor access grants — for undoing a wrong members import, e.g. one fund's roster loaded into another. Investor accounts and holdings/NAV are left intact. Reset Fund Holdings still only clears holdings; this covers the partner side.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_3 = VersionInfo.of({
|
||||||
|
version: '0.2.3:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Replace the Carta importer with a fund-administrator eNAV importer. Reads the holdings (HLD) sheet into holdings, positions, and a quarter valuation; decrypts password-protected workbooks (password entered on upload); reads the report date from the sheet; accepts XLSX or CSV.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
// No schema changes; import is parsing-only. Alembic owns the DB schema on startup.
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_30 = VersionInfo.of({
|
||||||
|
version: '0.2.30:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor portal: the capital block now reads 'Current Capital Balance' and shows only the percentage gain (green) or loss (red) beneath it — the redundant dollar 'gain/loss vs paid-in' line was removed.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_31 = VersionInfo.of({
|
||||||
|
version: '0.2.31:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Entities view: the 'GP Entities and Management Companies' section is now 'Management Entities' and gains a new 'Carry Vehicle' entity type (with both Partners and Assets tabs) for carry vehicles like Ten31 EP LLC. Investor capital chart: the Distributions line only appears once distributions have actually been made.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_32 = VersionInfo.of({
|
||||||
|
version: '0.2.32:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor portal polish: a portfolio summary card totals commitment, paid-in, distributions and current balance across funds; gain/loss is labeled 'net of paid-in'; headline figures show whole dollars; documents group by year with a 'New' badge since the investor's last visit. Ten31 brand palette (navy/mint from the logo) replaces the orange accents. New members from the eNAV import now start with the default password 'Ten31Portal' (login enabled), and a new 'Enable Investor Logins' action converts existing no-login accounts. Password changes now require at least 8 characters. Login page shows a Portal@ten31.xyz contact line.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user