Manage the password in the app; close remaining audit findings

Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.

Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
  lock, so a concurrent rename could leave the search index matching a name the
  UI no longer showed. All four routes now lock first; removing the lock again
  makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
  422 now, and the validation error handler no longer chokes trying to echo a
  non-finite value back.

Checks go from 134 to 181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 10:31:06 -04:00
parent 7bdf276342
commit ab82b5e9a9
11 changed files with 626 additions and 49 deletions
+40 -9
View File
@@ -45,26 +45,34 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 122 checks, in-process
.venv/bin/python -m tests.test_concurrency # 12 checks, against a real uvicorn
.venv/bin/python -m tests.test_api # 166 checks, in-process
.venv/bin/python -m tests.test_concurrency # 15 checks, against a real uvicorn
```
`test_api` exercises the API end to end against a throwaway database — the auth
gate and session-token signing, nested categories and locations, search across
every indexed field, filter rollups, stock adjustment and history, patch
semantics including explicit nulls, taxonomy cycle rejection, security headers
and asset versioning, and login throttling.
and asset versioning, login throttling, and the full password-management flow
including scrypt hashing, session invalidation and the recovery CLI.
`test_concurrency` needs a real server process, because a lost update only shows
up when two requests genuinely overlap inside SQLite. It fires overlapping
adjustments, patches and creates at one part and asserts the stock log always
sums to the stored quantity.
sums to the stored quantity, and races taxonomy renames against reads to check
the search index never describes a name the tree no longer has.
## Configuration
The password is **managed from the app**, not from a config file. `PARTS_PASSWORD`
is only the bootstrap credential: it works until a password is set through the
UI, and is ignored from then on (otherwise "changing" the password would leave
the old one working). The stored password is a salted scrypt hash in the
`settings` table, which lives in the `/data` volume and survives rebuilds.
| Variable | Meaning |
|---|---|
| `PARTS_PASSWORD` | The single shared password. Required when auth is on. |
| `PARTS_PASSWORD` | Bootstrap password, used only until one is set in the app. |
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
@@ -73,9 +81,11 @@ sums to the stored quantity.
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
Rotating `PARTS_PASSWORD` alone does **not** invalidate existing sessions when
`PARTS_SECRET` is set independently — the cookie is signed with the secret.
Rotate both to revoke every outstanding cookie.
Every token carries a session epoch. Changing the password bumps it, which
invalidates every outstanding cookie at once while re-issuing one for the
browser that made the change — so a password change really does sign out other
devices, with no need to touch `PARTS_SECRET` on the host. **Sign out other
devices** in Settings bumps the epoch on its own.
Failed logins are throttled on a **global** window rather than per source
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
@@ -90,11 +100,16 @@ Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
other services there — `build: .`, external `caddy_web` network, named volume
for `/data`.
`~/srv/parts` is a checkout of this repository, so deploying is a pull:
```sh
ssh jay@192.168.50.8
cd ~/srv/parts && sudo docker compose up -d --build
cd ~/srv/parts && git pull && docker compose up -d --build
```
`.env` is untracked and gitignored, so it survives the pull. `~/srv` (the infra
repo) ignores `parts/`, since this directory is its own repository.
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.
@@ -135,6 +150,22 @@ The "what could I build with what I'm holding?" idea is meant to arrive as
another consumer of these endpoints — the arbiter on `.8` already has the lane
routing and typed-action machinery for it — rather than as a fork of them.
## Passwords
Open **Settings** (the gear in the header) and use the Password section. It asks
for the current password, takes a new one twice, and signs out every other
device. Until a password has been set in the app, a banner says so.
The only reason to touch a terminal is a forgotten password:
```sh
docker exec -it parts python -m app.admin set-password # prompts, twice
docker exec parts python -m app.admin show-status # where the password comes from
docker exec parts python -m app.admin clear-password # fall back to PARTS_PASSWORD
```
Each of those signs out every session too.
## Hardening notes
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`