Files
praxis/docs/RESEARCH-operator-postgres-auth.md
T
Praxis CI dc673e5e3d docs(milestone): merge phase/00 pre-execution → milestone/v0.3-mastery-scoring
Phase 0 complete. v0.3 mastery-scoring planning artifacts shipped.
Pipeline: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL.
13 REQ-IDs active (7 functional + 6 NFR); 8 deferred to v0.4 (operator tier).
Decisions D-031..D-049 (19 total, all >=0.70 confidence).
4 MUST grill conditions resolved, 5 FIX tracked.

---ci---
project: praxis
phase: 0
milestone: v0.3
status: complete
requirements:
  covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02]
  partial: []
---/ci---
2026-08-03 19:58:38 +00:00

475 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RESEARCH: Operator Tier — Postgres-in-LXC + Auth for v0.3
**Scope:** Research only. No code changes. Grounded in the current Praxis repo
(`docker-compose.yml` single `praxis` service; `db/store.py` aiosqlite
`PraxisStore`; `db/migrate.py` ordered `.sql` migrations; SQLite schema at
`db/schema.sql`).
**Decisions honored:** D-007 (SQLite learner, preserved), D-031 (hybrid:
SQLite for learner, Postgres for operator), D-040 (Postgres = second
docker-compose service in the existing LXC CT), D-041 (session-cookie auth,
argon2id, single operator role, rate-limited).
**Confidence scores** are 01 (1 = well-established practice / low risk).
---
## 1. Docker-Compose Shape *(confidence: 0.90)*
Add a `postgres` service alongside the existing `praxis` service. Key
best-practices for a second service in an already-running LXC CT:
- **Image:** `postgres:16-slim` (Debian-slim base, glibc — matches the
praxis Dockerfile rationale; avoids Alpine musl locale issues with
`pg_*` clients).
- **Persistence:** named volume `pgdata` (driver: local). Never bind-mount
`/var/lib/postgresql/data` to the CT filesystem — Postgres requires
`chown 999` and a specific directory layout; named volumes handle this.
- **Network isolation:** declare an explicit internal compose network and
attach **only** `praxis` and `postgres` to it. Do **not** publish
`5432` via `ports:`. The `praxis` service keeps its published `8789`.
- `internal: true` on the network blocks egress to the host bridge, but
note: with `internal: true` the postgres container cannot reach the
internet (fine — it doesn't need to). If you later want outbound
backups via network, drop `internal: true` and instead rely on
*not* publishing the port. The simpler, robust choice for a pilot is:
explicit named network, no `ports:` on postgres, no `internal: true`.
- **Healthcheck:** `pg_isready -U praxis -d praxis` every 10s, 5 retries,
5s timeout. `depends_on: { postgres: { condition: service_healthy } }`
on the `praxis` service so the app waits for accept-connections, not
just container start.
- **Init scripts:** mount `./db/pg/init/*.sql` (or `.sh`) at
`/docker-entrypoint-initdb.d/`. These run **only on first boot** (empty
`pgdata`). Use them for: role/db creation, schema bootstrap, and
idempotent seed. For *versioned* schema changes use a migration runner
(see §6) — init scripts are one-shot.
- **Env:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` from the
existing `/etc/praxis/server.env` (do **not** commit secrets to the
compose file). Add `PGDATA=/var/lib/postgresql/data/pgdata` to pin the
subdirectory (survives image upgrades).
- **Restart:** `restart: unless-stopped` (matches praxis).
- **Resources:** for a pilot on a small LXC CT, set a mem limit
(`deploy.resources.limits.memory: 512m`) and rely on Postgres default
`shared_buffers`. Tune later.
**Sketch (shape only, not for commit):**
```yaml
services:
praxis:
# ... existing v0.2 fields unchanged ...
depends_on:
postgres:
condition: service_healthy
networks: [praxis-net]
postgres:
image: postgres:16-slim
restart: unless-stopped
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB:-praxis_operator}
PGDATA: /var/lib/postgresql/data/pgdata
env_file:
- path: /etc/praxis/server.env
required: false
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/pg/init:/docker-entrypoint-initdb.d:ro
- pgbackups:/backups
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER:-praxis} -d ${PG_DB:-praxis_operator}"]
interval: 10s
timeout: 5s
retries: 5
networks: [praxis-net]
# NOTE: no `ports:` — not exposed to the LXC host bridge.
volumes:
praxis-data:
driver: local
pgdata:
driver: local
pgbackups:
driver: local
networks:
praxis-net:
driver: bridge
```
**Risk callouts:**
- If `praxis` currently has no explicit network, compose assigns the
default bridge; adding an explicit network means the *existing*
`praxis` service gets recreated on `up`. Plan a brief downtime window
(see §6).
- `pg_isready` returns healthy before the DB is fully ready for migration
load; `depends_on: service_healthy` is necessary but not sufficient —
the app must still retry the first migration attempt.
---
## 2. Connection Management *(confidence: 0.85)*
Two async DB drivers in one process: **aiosqlite** (already a dep) for the
learner store, **asyncpg** for the operator store.
- **Pools are independent and must not be shared.** asyncpg uses a
`asyncpg.create_pool(...)` (sized pool, real connections). aiosqlite
opens a fresh connection per `async with aiosqlite.connect(...)` (the
current `PraxisStore._connect` pattern). They have nothing in common —
different backends, different lifecycles. **Do not** wrap them in a
single shared `AsyncSession` object; SQLAlchemy's async session is an
option *only if* you adopt SQLAlchemy for both — that's a larger
refactor and not warranted for v0.3.
- **Pool sizing (avoid exhaustion):**
- asyncpg pool: `min_size=2, max_size=10` for a pilot single-instance.
Operator endpoints are low-frequency (cohort dashboard, VC issuance).
- aiosqlite: no pool; the current pattern opens/closes per call. SQLite
is single-writer; keep `WAL` mode and short transactions. This is
already fine for one learner.
- Total concurrent DB connections ≈ asyncpg(10) + aiosqlite(1-2). On a
small CT this is trivial. Exhaustion risk is essentially zero at
pilot scale; revisit if operator endpoints are hit by N concurrent
cohort users.
- **Lifecycle:** create the asyncpg pool once at FastAPI startup
(`lifespan` context manager), close on shutdown. Store on
`app.state.pg_pool`. The `PraxisStore` keeps its current per-call
connect pattern (no change to D-007 code path).
- **Transaction boundaries:** asyncpg use `pool.acquire()` +
`conn.transaction()` for multi-statement writes; aiosqlite unchanged.
- **Config:** `PG_DSN` env var, e.g.
`postgresql://praxis:***@postgres:5432/praxis_operator` (host =
service name on `praxis-net`).
- **Statement timeout:** set `command_timeout=10` on the asyncpg pool to
prevent a slow operator query from blocking the event loop.
**Pip:** `asyncpg>=0.29` (new dep). `aiosqlite>=0.20` already present.
---
## 3. Auth Stack *(confidence: 0.90 for the stack; 0.70 for rate-limit choice)*
D-041 spec: session-cookie, argon2id, single operator role, rate-limited.
### 3a. Session cookie
- **`starlette` `SessionMiddleware`** (FastAPI bundles Starlette). Uses
`itsdangerous` to sign the cookie — no server-side session store
needed (stateless, fits single-instance LXC). Data lives in the cookie
itself, signed with `SECRET_KEY`.
- **Settings:**
- `secret_key`: from env, ≥32 bytes random. **Rotate** by changing the
key (invalidates all sessions — acceptable for a pilot).
- `session_cookie`: `"praxis_op"` (distinct from any future learner
cookie name).
- `max_age`: `28800` (8h, per D-041).
- `path`: `/` (or scope to `/op` if operator routes live under a
prefix — cleaner).
- `https_only`: `True` (Secure flag). **Requires TLS** — the LXC
deployment must terminate TLS (reverse proxy / Caddy / Proxmox
level). If running plain HTTP on the LAN for the pilot, set to
`False` *temporarily* and document the risk; never ship False.
- `httponly`: `True` (the middleware sets this by default; verify).
- `samesite`: `"strict"` (D-041). CSRF defense-in-depth; with Strict,
no credential is sent on cross-site navigations.
- **Cookie contents:** store `{operator_id: str, issued_at: epoch}`.
**Never** store the password hash or any PII. Roles aren't needed in
the cookie yet (single role — see §4).
### 3b. Password hashing — argon2id
- **`argon2-cffi`** (`PasswordHasher` default is argon2id, RFC 9106).
Pip: `argon2-cffi>=23.1`.
- On login: `ph.verify(stored_hash, password)` → on success,
`ph.check_needs_rehash(stored_hash)` → rehash if params bumped.
- Params: keep `PasswordHasher()` defaults for v0.3
(`time_cost=3, memory_cost=64MiB, parallelism=4` — reasonable on a
small CT; benchmark and tune if login latency > 1s).
- Store the hash as `TEXT` in `operators.password_hash`.
### 3c. Rate limiting
Two options:
1. **`slowapi`** (pip `slowapi>=0.1`) — the idiomatic FastAPI choice.
Decorator/IP-based limiter. Default in-memory backend is fine for
single-instance. **Confidence 0.70** — it works, but it's a young lib
and the in-memory backend is per-process (breaks if you ever scale to
>1 praxis process; not a v0.3 concern).
2. **In-memory counter** (a simple `dict[remote_ip, (count, window_start)]`
in a small dependency) — zero deps, trivially auditable. For a single
operator login endpoint this is enough. **Confidence 0.80** for the
pilot specifically.
**Recommendation:** start with `slowapi` on the login route only
(`@limiter.limit("5/minute")`), in-memory backend. Migrate to a Redis
backend only if/when you go multi-instance. Threshold: 5 failed
attempts/minute/IP → 429 + exponential backoff marker.
**Pip additions:** `argon2-cffi>=23.1`, `slowapi>=0.1`. (`starlette` and
`itsdangerous` come with FastAPI.)
---
## 4. Auth Dependency Pattern *(confidence: 0.90)*
Single-role v0.3 → **no RBAC framework needed.** A single FastAPI
`Depends` that resolves the operator from the signed session is the
minimal secure shape.
Concept (not committed code):
```python
# pseudo — shape only
async def current_operator(request: Request) -> Operator:
sess = request.session # populated by SessionMiddleware
op_id = sess.get("operator_id")
if not op_id:
raise HTTPException(401, "not authenticated")
op = await pg_store.get_operator(op_id)
if not op or not op.is_active:
# invalidate the cookie
request.session.clear()
raise HTTPException(401, "operator not found / disabled")
return op
```
- Apply via `Depends(current_operator)` on every operator-tier router.
Group operator routes under an `APIRouter(prefix="/op")` and attach
the dependency at the router level
(`dependencies=[Depends(current_operator)]`) — one declaration, not
per-endpoint.
- Login/logout are **outside** the protected router (login is rate-
limited, not auth-gated).
- **CSRF:** with `SameSite=Strict` + `httponly` cookies, CSRF surface is
minimal for state-changing requests. If any operator endpoint accepts
`Content-Type: application/x-www-form-urlencoded`/`multipart` (form
posts), add a double-submit token or require `Content-Type:
application/json` only (the latter is the cheaper defense — JSON
bodies are not auto-sent by browsers across origins).
### When to migrate to RBAC
Migrate when **any** of these become true:
- A second role appears (admin, auditor, reviewer) — i.e. v0.4+ if the
pilot expands.
- Permissions diverge *within* a role (e.g. some operators can issue
VCs, others can only view cohorts).
- You need row-level visibility rules (operator A sees only their
cohort).
At that point the cheapest upgrade is: add a `role` column to
`operators`, split `current_operator` into `current_operator` (any
authenticated) + `require_role("admin")` (a parametrized dependency
checking `op.role`). Reach for a full RBAC lib (`casbin`,
`fastapi-permissions`) only when the role matrix exceeds ~3 roles × ~5
permissions. **Don't pre-build it.**
---
## 5. Postgres Schema *(confidence: 0.80)*
Operator-tier tables. Types chosen for Postgres 16 specifically
(`TIMESTAMPTZ`, `BIGSERIAL`, `GENERIC` via `JSONB`).
### `operators`
```
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
username TEXT NOT NULL UNIQUE
password_hash TEXT NOT NULL -- argon2id
display_name TEXT NOT NULL
role TEXT NOT NULL DEFAULT 'operator' -- reserved for §4 migration
is_active BOOLEAN NOT NULL DEFAULT TRUE
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
last_login_at TIMESTAMPTZ
```
- Index: unique on `username` (covered by constraint). No extra index
needed at single-operator scale.
- Requires `pgcrypto` extension **or** Postgres 13+ (where
`gen_random_uuid()` is built-in via `pgcrypto` shipped default —
actually: `gen_random_uuid()` is built into core as of PG 13). So no
extension needed on PG16. ✓
### `issued_credentials`
```
id BIGSERIAL PRIMARY KEY
operator_id UUID NOT NULL REFERENCES operators(id)
learner_ref TEXT, -- opaque ref into SQLite side (no FK cross-DB)
vc_type TEXT NOT NULL -- 'mastery' | 'completion' | ...
payload_jsonb JSONB NOT NULL -- the W3C VC document (signed elsewhere)
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
revoked_at TIMESTAMPTZ
```
- Indices:
- `issued_credentials(operator_id, issued_at DESC)` — operator's
issuance log.
- `issued_credentials(learner_ref)` — lookup by learner (k-anon
aggregate joins).
- `issued_credentials(vc_type)` if filtering by type is a dashboard
query.
### `mastery_gate_events`
```
id BIGSERIAL PRIMARY KEY
learner_ref TEXT NOT NULL
scenario_id TEXT NOT NULL
path_id TEXT NOT NULL -- learning path
gate_outcome TEXT NOT NULL -- 'pass' | 'fail' | 'retry'
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
source TEXT NOT NULL DEFAULT 'sync' -- 'sync' from SQLite learner store
```
- Indices:
- `(learner_ref, recorded_at DESC)` — per-learner timeline.
- `(path_id, recorded_at)` — feeds the cohort aggregate.
### `cohort_aggregates` — k-anonymized
Model as **pre-materialized rows** partitioned by `(path_id, week)` with
a minimum bin size enforced at write time (k≥K, e.g. K=5). A 7-day
window is a rolling construct over the weekly partitions.
```
path_id TEXT NOT NULL
week_start DATE NOT NULL -- ISO week Monday
bin_count INTEGER NOT NULL -- learners in this bin
k_anon_pass INTEGER NOT NULL -- pass count, suppressed if < K
k_anon_fail INTEGER NOT NULL -- fail count, suppressed if < K
median_attempts INTEGER
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
PRIMARY KEY (path_id, week_start)
```
- **k-anon rule:** when materializing, if `bin_count < K` emit
`bin_count = <K-masked>` and null-out the count columns (or clamp
them to K). Enforce in the aggregation job, **not** in a SQL view, so
the suppression is auditable at write time.
- **7-day window:** compute on read as a window function over the last
≤2 weekly partitions, or maintain a parallel rolling table. For a
pilot, compute on read:
`SUM(k_anon_pass) ... WHERE week_start >= now()::date - interval '7 days'`.
- Indices: PK covers `(path_id, week_start)`. Add a secondary
`(week_start DESC)` only if you query "all paths for the latest week"
frequently.
**General indices summary:** 4 indices beyond PKs/constraints for v0.3
— keep it lean; add per slow-query evidence.
---
## 6. Migration Strategy *(confidence: 0.85)*
Goal: add Postgres to the **running** v0.2 LXC CT without breaking the
learner service.
### Steps (ordered, low-risk)
1. **Prepare on a staging CT first** (clone the production LXC CT in
Proxmox). Never test the migration path on the live CT.
2. **Add the `postgres` service + `praxis-net` + volumes** to
`docker-compose.yml`. The `praxis` service gains
`depends_on: postgres (service_healthy)` and joins `praxis-net`.
3. **Add init scripts** under `db/pg/init/`:
- `00_create_schema.sql` — the four tables from §5.
- `01_seed_operator.sh` — creates the initial operator with an
argon2id hash (run from env-supplied temp password; force password
change on first login).
These run **only on first boot** of an empty `pgdata` volume.
4. **Add the asyncpg pool + operator store + auth wiring** to the praxis
image (new code paths, new deps in `pyproject.toml`). Learner paths
(`db/store.py`, `db/migrate.py`) **unchanged** — D-007 preserved.
5. **Build the new image** (`docker compose build praxis`) — does not
touch the running container.
6. **Controlled cutover:**
- `docker compose up -d postgres` → wait for healthy.
- `docker compose up -d praxis` → recreate the praxis container with
the new image. Expect ~515s of downtime (the learner voice loop
is not HA anyway). The SQLite volume (`praxis-data`) is untouched,
so learner state is preserved across the recreate.
7. **Smoke tests:** `/health`, learner voice loop, operator login, one
cohort-dashboard read.
8. **Rollback plan:** if operator endpoints misbehave, revert the
praxis image tag and `docker compose up -d praxis` again — Postgres
stays up but unused. Learner path is independent, so a bad operator
rollout does **not** regress v0.2 learner behavior. This is the
core safety property of the hybrid (D-031) design.
### Versioned migrations beyond first boot
The SQLite side already has `db/migrate.py` (ordered `.sql`, `_migrations`
table). For Postgres, two options:
- **(a) Reuse the pattern:** a `pg_migrate.py` mirroring the SQLite
runner, against a `_pg_migrations` table. Lowest cognitive load —
same mental model, same directory convention (`db/pg/migrations/`).
- **(b) Adopt `yoyo-migrations` or `alembic`:** more machinery, not
warranted at 4 tables.
**Recommendation (a):** mirror the existing runner. Run on praxis
startup (after the pool is up), idempotent. **Confidence 0.80** on the
pattern; it's exactly what v0.2 already does for SQLite.
---
## 7. Backup *(confidence: 0.85)*
Minimum viable backup for a pilot operator Postgres in LXC:
- **Method:** `pg_dump -Fc` (custom compressed format) → file in the
`pgbackups` volume. `-Fc` gives you selective restore and parallel
restore later.
- **Frequency:** daily is enough for a pilot. A cron job *inside the
postgres container* (or a sidecar) runs:
```
pg_dump -U praxis -Fc praxis_operator > /backups/pg_$(date +%u).dump
```
Using `%u` (day-of-week 17) gives a rolling 7-file retention with
zero cleanup logic.
- **Where:** `/backups` is the `pgbackups` named volume. Keep backups
**inside the compose stack** so they move with the CT. For off-CT
safety: a Proxmox-level cron `pct push`/`rsync` of the `pgbackups`
volume to the Proxmox host or a NAS — out of scope for the app, but
the named volume makes it a one-line host-side copy.
- **Restore (drill it once):**
```
docker compose exec postgres pg_restore -U praxis -d praxis_operator \
--clean --if-exists /backups/pg_3.dump
```
`--clean --if-exists` drops+recreates objects; safe against a
partially-populated DB. **Never** restore into the live DB without
stopping the praxis service first.
- **Don't back up** the SQLite side here — it's already on the
`praxis-data` volume and covered by whatever volume backup the CT
already has. Keep the two backup streams separate (matches the hybrid
design).
- **Encryption at rest:** out of scope for the MVP; rely on LXC/Proxmox
disk encryption. If the `pgbackups` volume is ever pulled off-host,
`gpg -c` the dump in the cron step.
**Pip:** none new for backup (uses `pg_dump`/`pg_restore` shipped with
the postgres image).
---
## Summary table — new pip dependencies
| Dep | Purpose | Confidence |
|---|---|---|
| `asyncpg>=0.29` | Postgres async driver / pool | 0.90 |
| `argon2-cffi>=23.1` | argon2id password hashing | 0.95 |
| `slowapi>=0.1` | login rate limiting (in-memory) | 0.70 |
| `starlette` (already via FastAPI) | `SessionMiddleware` signed cookies | 0.95 |
| `itsdangerous` (already via Starlette) | cookie signing | 0.95 |
## Cross-cutting risks (watch list)
1. **TLS or not:** Secure cookie flag requires TLS. Confirm the LXC
fronting layer terminates HTTPS before enabling `https_only=True`.
2. **First-boot-only init scripts:** if `pgdata` already exists (e.g.
after a failed first boot), seed scripts **won't re-run** — keep a
separate re-runnable seed path (the `01_seed_operator.sh` should be
idempotent via `ON CONFLICT DO NOTHING` or a shell guard).
3. **Two migration runners** (SQLite + Postgres) — keep directory
layouts visually distinct: `db/migrations/` (SQLite, existing) vs
`db/pg/migrations/` (Postgres, new). Don't merge.
4. **Event-loop blocking:** argon2id hashing is CPU-bound
(`time_cost=3` ≈ 3080ms). For a single operator login this is fine
on the main event loop; if you ever batch-hashed, move to
`run_in_executor`. Not a v0.3 concern.
5. **Cross-DB joins are impossible** (SQLite ↔ Postgres). Anything that
needs both (e.g. a dashboard joining learner sessions to issued VCs)
must be assembled in application code. The `learner_ref` opaque key
in `issued_credentials`/`mastery_gate_events` is the join handle —
keep it stable and never reuse SQLite rowids directly (use the
existing `sess-…`/`learner-1` string ids).