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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user