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

21 KiB
Raw Blame History

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):

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.

  • 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):

# 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).