# 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 0–1 (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 = ` 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 ~5–15s 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 1–7) 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` ≈ 30–80ms). 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).