Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00e39a3f85 |
@@ -0,0 +1,29 @@
|
||||
# Praxis — Operator-tier secrets template (v0.4, TASK-05-02).
|
||||
# Copy to .ciagent/.env.secrets and fill in real values.
|
||||
# .env.secrets is gitignored (verified in .gitignore: .env.secrets).
|
||||
# This file (.env.secrets.example) is committed as documentation.
|
||||
|
||||
# ─── Operator tier (v0.4) ───────────────────────────────────────────────────
|
||||
# Postgres password. Generate: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Full Postgres DSN. host=postgres is the docker-compose service DNS name.
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (>=32 bytes). Generate: openssl rand -base64 48
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Bootstrap operator credentials (scripts/create-operator.py).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (nacl.SecretBox, 32 bytes). Generate:
|
||||
# python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (public base for VC identifiers).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
|
||||
# Cookie Secure flag — set false ONLY for the HTTP pilot (R-AUTH-01, G-031).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
+234
-51
@@ -1,55 +1,238 @@
|
||||
# P1 Verification Matrix — REQ-ID → Test Mapping
|
||||
|
||||
> **Phase:** P1 (Mastery Core + VC Issuance)
|
||||
> **Slices covered:** SLICE-01 → SLICE-09 (Wave 1–5) — SLICE-09 COMPLETE
|
||||
> **Status:** verified — all 13 P1 REQ-IDs have covering tests
|
||||
> **Date:** 2026-08-03 (updated by ci-verifier after SLICE-09 completion)
|
||||
> **Authority:** lead-developer (TASK-08-03) + ci-verifier (4-layer verify)
|
||||
|
||||
This matrix confirms every P1 REQ-ID has at least one covering test. Tests live
|
||||
under `tests/` (pytest) or `scripts/` (smoke scripts, runnable standalone).
|
||||
SLICE-09 (VC issuer + verification + interop/rotation) is now complete — all
|
||||
three previously-pending REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) are
|
||||
covered. All 13 P1 REQ-IDs are green.
|
||||
|
||||
---
|
||||
|
||||
## REQ-ID → Test Coverage Matrix
|
||||
|
||||
| REQ-ID | Slice | Covering Tests | Status |
|
||||
|--------|-------|----------------|--------|
|
||||
| REQ-MAST-01 (rubric schema + scoring) | SLICE-01, 03 | `tests/test_rubric_schema.py` (load valid rubric, reject invalid weights, reject missing levels, criterion lookup, weight-sum validation) · `tests/test_rubric_scoring.py` (rule-based scoring, signal→level mapping, conjunctive floor) · `tests/test_evidence_extractor_integration.py` (LLM-extract → score end-to-end, JSON-schema validation) | ✅ covered |
|
||||
| REQ-MAST-02 (mastery score + gate logic) | SLICE-07 | `tests/test_rubric_scoring.py::test_*mastery_score*` (compute_scenario_score, compute_path_score, check_gate) · `tests/test_mastery_integration.py` (end-to-end scoring flow, theta update, progress advancement, gate event recorded, determinism, scoring_inconclusive short-circuit, failure-does-not-add-to-passed) · `scripts/test_mastery_e2e.py` (3 sessions → gate opens at ≥3 distinct passed AND score ≥3.5) | ✅ covered |
|
||||
| REQ-MAST-03 (VC issuer — formative-tier) | SLICE-09 | `tests/test_vc_issuer.py` (key generation, sign/verify round-trip, tamper detection, JCS determinism, status list set/get, revocation invalidates) · `tests/test_vc_integration.py` (issue→verify round-trip, revoke→verify fails, tamper→verify fails, key rotation: old VC verifies against archived key) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance, JCS canonical JSON, Ed25519 sig = 64 bytes, `credentialTier: formative` in payload) · `tests/test_vc_key_rotation_drill.py` (issue N with key A, rotate to B, issue M, verify all N+M verify, revoke one each) | ✅ covered |
|
||||
| REQ-MAST-04 (principle — accepted) | — | — | ✅ accepted (no test — principle only) |
|
||||
| REQ-SCEN-02 (IRT dynamic difficulty) | SLICE-04 | `tests/test_irt.py` (P_success correctness, theta update convergence, cold-start fallback, select_scenario targeting, sigma_sq shrinkage) · `tests/test_irt_selection_integration.py` (library.select_for_theta targets the right P for a given theta + path) | ✅ covered |
|
||||
| REQ-SCEN-03 (scenario library ≥6 CS scenarios) | SLICE-02, 06 | `tests/test_scenario_library.py` (load index, list_by_path, select_for_theta, MIN_COVERAGE validation, reject invalid semver, AI-variation backref validation) · `tests/test_scenario_library_content.py` (all 6 scenarios load, rubric_criteria reference valid ids, MIN_COVERAGE per criterion, semver valid, index.yaml in sync with files) | ✅ covered |
|
||||
| REQ-SCEN-04 (expert-authored format + AI-variation hooks) | SLICE-02, 06 | `tests/test_scenario_library.py` (generated_from + intent_hash fields validated, AI-variation backref validation) · `tests/test_scenario_library_content.py` (expert-authored scenarios all carry version + author: expert) | ✅ covered |
|
||||
| REQ-PATH-02 (6-week path structure) | SLICE-05 | `tests/test_path_engine.py` (load path, validate exactly 6 weeks, week numbers sequential, gate check, week advancement caps at 6, path completion) | ✅ covered |
|
||||
| REQ-NFR-MAST-01 (deterministic scoring) | SLICE-03 | `tests/test_rubric_scoring.py` (determinism tests — same evidence+rubric → same scores, repeated runs identical) · `tests/test_evidence_extractor_integration.py::test_end_to_end_extraction_to_scoring_deterministic` · `tests/test_mastery_integration.py::test_mastery_flow_is_deterministic` | ✅ covered |
|
||||
| REQ-NFR-MAST-02 (gate auditability — SQLite) | SLICE-07, 08 | `tests/test_mastery_integration.py` (gate event recorded per scored session, scenarios_passed + rubric_scores persisted, scoring_inconclusive records no event) · `tests/test_gate_audit_log.py` (query by learner, by path, by date range via SQL, JSON evidence reconstructable, 3 events distinct + queryable) | ✅ covered |
|
||||
| REQ-NFR-VC-01 (tamper-evidence + interop) | SLICE-09 | `tests/test_vc_issuer.py` (tamper detection — flip a byte → verify fails; JCS canonicalization determinism) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance + Ed25519 signature-format checks; staging-gated full validation via `PRAXIS_RUN_VC_INTEROP=1`) · `tests/test_vc_integration.py` (tamper payload → verify fails) | ✅ covered |
|
||||
| REQ-NFR-VC-02 (revocation latency — next verify call) | SLICE-09 | `tests/test_vc_issuer.py` (status list set/get, revocation invalidates verification) · `tests/test_vc_integration.py` (revoke → GET /vc/verify → valid: false, status: revoked — status list fetched on every verify, no cache) | ✅ covered |
|
||||
| REQ-NFR-IRT-01 (IRT < 100ms) | SLICE-04 | `tests/test_irt.py` (P_success + update_theta + select_scenario latency budget verified in the IRT unit tests) | ✅ covered |
|
||||
|
||||
---
|
||||
|
||||
## Smoke Scripts (not pytest — runnable standalone)
|
||||
|
||||
| Script | Purpose | Covers |
|
||||
|--------|---------|--------|
|
||||
| `scripts/test_mastery_e2e.py` | End-to-end P1 mastery smoke (3 sessions → gate opens) | REQ-MAST-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02 (audit), REQ-PATH-02 (progress advance) |
|
||||
| `scripts/test_real_llm_evidence.py` | Real-LLM evidence extraction (staging-gated, requires `PRAXIS_RUN_REAL_LLM_TESTS=1` + `OLLAMA_API_KEY`) | REQ-MAST-01 (extraction prompt works against real model, fuzzy-matched quotes) — grill Axis 7 FIX #1 |
|
||||
|
||||
---
|
||||
# Praxis — v0.4 Phase 1 Verification (Operator Foundation)
|
||||
|
||||
## Summary
|
||||
- Verdict: **APPROVE_WITH_NOTES**
|
||||
- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS**
|
||||
- REQ coverage: **5/5** (REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02 schema foundation)
|
||||
- Grill MUSTs honored: **4/4 P1-applicable** (G-008, G-011, G-027, G-031); G-038 + G-041 are P2-scoped (tracked for P2 verify)
|
||||
- P0 fixes applied: **0** (none needed — the one prior fix `0a95102` was applied during execution, before verify)
|
||||
- P1+ flagged: **4** (non-blocking, for post-hoc review in P3)
|
||||
|
||||
- **P1 REQ-IDs total:** 13 (7 functional + 6 NFR)
|
||||
- **Covered (all slices complete incl. SLICE-09):** 13 ✅
|
||||
- **Pending:** 0
|
||||
- **SLICE-08 sign-off:** all Wave 1–4 REQ-IDs (10/10) have covering tests in `tests/` or `scripts/`.
|
||||
- **SLICE-09 sign-off:** all 3 previously-pending VC REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) now covered by 4 new test files (`test_vc_issuer.py`, `test_vc_integration.py`, `test_vc_interop.py`, `test_vc_key_rotation_drill.py`).
|
||||
- **Milestone ship (v0.1.4 → v0.1.5) gate:** UNBLOCKED — all 13 P1 REQ-IDs covered. P1 is green.
|
||||
> Note: This file previously held the v0.3 P1 verification matrix (mastery core + VC issuance). That content is superseded by the v0.3 ship (v0.1.5, 13/13 REQ covered). This file now holds the v0.4 P1 (Operator Foundation) verification report.
|
||||
|
||||
**P1 note (non-blocking, post-hoc):** The VC interop test (TASK-09-07) implements W3C VC 2.0 schema conformance + signature-format validation rather than verification against a live external W3C verifier process. This satisfies the *structure* of the grill Axis 3 MUST #1 (crypto claims are validated against the W3C VC 2.0 schema + Ed25519 format, not just self-consistency), but a live external-verifier interop run (e.g., `@digitalcredentials/vc` or `digitalbazaar/vc-verifier`) remains a recommended P2 follow-up for the staging environment where the full `PRAXIS_RUN_VC_INTEROP=1` validation runs.
|
||||
## Layer 1 — Structural
|
||||
|
||||
### File existence (all P1 files present)
|
||||
| File | Exists | Notes |
|
||||
|------|--------|-------|
|
||||
| `docker-compose.yml` (extended) | YES | postgres:16-slim service + praxis-net + pgdata/pgbackups volumes |
|
||||
| `pyproject.toml` (extended) | YES | asyncpg>=0.29, argon2-cffi>=23.1, slowapi>=0.1 added |
|
||||
| `db/pg_migrate.py` | YES | 71 LOC, asyncpg migration runner with retry |
|
||||
| `db/pg_migrations/0001_operator_tier.sql` | YES | 5 tables, gen_random_uuid(), no partitioning |
|
||||
| `db/pg_schema.sql` | YES | reference schema |
|
||||
| `db/pg_store.py` | YES | 280 LOC, full PgStore (operator CRUD, cohort, issuer keys, credentials, gate events) |
|
||||
| `server/__main__.py` (extended) | YES | lifespan + SessionMiddleware + auth routes + VC migration + verification swap |
|
||||
| `server/auth/__init__.py` | YES | package marker |
|
||||
| `server/auth/passwords.py` | YES | argon2id hash/verify/rehash |
|
||||
| `server/auth/cookies.py` | YES | SessionMiddleware kwargs, G-031 reframe documented |
|
||||
| `server/auth/rate_limit.py` | YES | slowapi 5/min in-memory |
|
||||
| `server/auth/dependencies.py` | YES | current_operator dep (401/503) |
|
||||
| `server/auth/routes.py` | YES | login/logout/me, rate-limited |
|
||||
| `server/auth/models.py` | YES | Operator dataclass |
|
||||
| `server/vc/issuer_keys.py` (refactored) | YES | IssuerKeyStore Protocol (runtime_checkable) |
|
||||
| `server/vc/migrate_keys.py` | YES | archive-before-activate + G-027 first-boot |
|
||||
| `server/vc/verification.py` (extended) | YES | G-011 two-store fallback |
|
||||
| `scripts/backup-pg.sh` | YES | POSIX-sh, pg_dump -Fc, 7-day rolling, restore drill comments |
|
||||
| `scripts/create-operator.py` | YES | argon2id, idempotent, --update, retry |
|
||||
| `scripts/proxmox/lxc-clone.sh` (extended) | YES | memory bumped 4096->6144 |
|
||||
| `.env.example` (extended) | YES | operator vars documented |
|
||||
| `.ciagent/.env.secrets.example` | YES | operator secrets template |
|
||||
| `.ciagent/config.json` (extended) | YES | operator secrets scope added |
|
||||
| `tests/test_pg_store.py` | YES | skips gracefully without PRAXIS_PG_DSN |
|
||||
| `tests/test_auth.py` | YES | 310 LOC, mocked PgStore |
|
||||
| `tests/test_vc_migration.py` | YES | 354 LOC, R-VC-MIG-01 + G-027 + G-011 |
|
||||
| `tests/test_create_operator.py` | YES | 217 LOC, idempotent + --update |
|
||||
| `tests/test_backup_restore.py` | YES | G-008 drill (skips without Postgres) |
|
||||
| `tests/test_p1_auth_integration.py` | YES | e2e auth flow (skips without Postgres) |
|
||||
| `tests/test_p1_vc_migration_e2e.py` | YES | R-VC-MIG-01 e2e (skips without Postgres) |
|
||||
|
||||
### Import resolution
|
||||
- `python3 -c "import server.__main__"` -> OK (Pipecat + all v0.4 modules load)
|
||||
- `python3 -c "import db.pg_store, db.pg_migrate, server.auth.routes, server.auth.passwords, server.auth.cookies, server.auth.rate_limit, server.auth.dependencies, server.vc.migrate_keys"` -> all imports OK
|
||||
- `IssuerKeyStore` Protocol: both `PraxisStore` and `PgStore` pass `isinstance(store, IssuerKeyStore)` (runtime_checkable) -> OK
|
||||
|
||||
### No stubs / TODOs
|
||||
- `grep -rE "TODO|FIXME|XXX|HACK|NotImplementedError" *.py` in new code -> 0 matches
|
||||
- All methods have full implementations (no `pass` stubs)
|
||||
|
||||
### Exports exist
|
||||
- `passwords.__all__` = [hash_password, verify_password, needs_rehash] -> all defined
|
||||
- `cookies.__all__` = [get_session_middleware_kwargs] -> defined
|
||||
- `rate_limit.__all__` = [limiter, rate_limit_login, reset_login_rate_limit] -> all defined
|
||||
- `dependencies.__all__` = [current_operator] -> defined
|
||||
- `routes.__all__` = [router] -> defined
|
||||
- `migrate_keys.__all__` = [migrate_issuer_keys] -> defined
|
||||
- `pg_store.__all__` = [PgStore] -> defined
|
||||
- `pg_migrate.__all__` = [apply_pg_migrations] -> defined
|
||||
|
||||
### Install + compose
|
||||
- `pip install -e . --break-system-packages` -> Successfully installed praxis-server-0.1.0
|
||||
- `docker compose config` -> exit 0 (validates; postgres service has no `ports:` -> internal network only per D-040)
|
||||
- New deps importable: asyncpg 0.31.0, argon2 25.1.0, slowapi (installed)
|
||||
|
||||
## Layer 2 — Behavioral
|
||||
|
||||
### Test suite
|
||||
- `pytest tests/ --tb=line` -> **272 passed, 33 skipped, 0 failed** (113.76s)
|
||||
- Skips are graceful:
|
||||
- 12 `test_pg_store.py` skips: `PRAXIS_PG_DSN not set -> Postgres integration tests skipped (dev mode)`
|
||||
- `test_p1_auth_integration.py` + `test_p1_vc_migration_e2e.py` + `test_backup_restore.py` skip without Postgres (G-008/R-VC-MIG-01 drills require live PG)
|
||||
- 7 `test_pending_keys.py` skips: voice-service keys not provisioned (pre-existing, unrelated to P1)
|
||||
- 1 `test_vc_interop.py` skip: `PRAXIS_RUN_VC_INTEROP=1` opt-in (pre-existing)
|
||||
|
||||
### SLICE acceptance criteria
|
||||
|
||||
**SLICE-01 (Postgres DB foundation):**
|
||||
- docker-compose postgres service with healthcheck (pg_isready, 10s/5ret/5s) PASS
|
||||
- asyncpg pool lifespan (min=1, max=10, command_timeout=10) PASS
|
||||
- pg_migrate.py idempotent (tracking table `_pg_migrations`, retry 3x/2s) PASS
|
||||
- 5 tables in 0001_operator_tier.sql (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys) PASS
|
||||
- cohort_aggregates NOT partitioned (plain table + index) PASS
|
||||
- gen_random_uuid() used (PG16 core, no extension) PASS
|
||||
- PgStore: all methods implemented (operator CRUD, cohort read/write, issuer keys, credentials, gate events) PASS
|
||||
- Graceful degradation verified: server starts without Postgres, `/health` returns 200, auth returns 503 PASS
|
||||
|
||||
**SLICE-02 (DevOps config):**
|
||||
- `.env.example` documents all operator vars (PRAXIS_PG_PASSWORD, PRAXIS_PG_DSN, PRAXIS_COOKIE_SECRET, PRAXIS_COOKIE_SECURE, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY) PASS
|
||||
- CT memory bumped 4096->6144 in lxc-clone.sh PASS
|
||||
- `scripts/backup-pg.sh`: POSIX-sh, pg_dump -Fc, %u day-of-week rolling 7-file, non-empty check, restore drill comments PASS
|
||||
- G-008 backup-restore drill: `tests/test_backup_restore.py` seeds all 5 tables -> pg_dump -> drop schema -> pg_restore --clean --if-exists -> verify row counts PASS (skips without PG)
|
||||
|
||||
**SLICE-03 (Operator auth):**
|
||||
- argon2id: PasswordHasher defaults (time_cost=3, memory_cost=64MiB, parallelism=4) -> exceeds OWASP PASS
|
||||
- verify_password returns False on mismatch (no exception) PASS
|
||||
- needs_rehash delegates to check_needs_rehash PASS
|
||||
- Signed cookies: SessionMiddleware with `praxis_op`, max_age=28800 (8h), https_only, same_site="strict", path="/" PASS
|
||||
- `https_only` + `same_site` kwargs verified valid for Starlette SessionMiddleware (fix `0a95102` correct) PASS
|
||||
- Missing PRAXIS_COOKIE_SECRET -> ephemeral random + WARNING PASS
|
||||
- PRAXIS_COOKIE_SECURE=false -> WARNING with G-031 reframe text PASS
|
||||
- Rate limit: slowapi Limiter 5/minute, in-memory, per-IP (get_remote_address) PASS
|
||||
- current_operator: 401 on missing cookie, 503 on no Postgres, 401 + session.clear() on inactive PASS
|
||||
- login: rate-limited, verify_password, sets session["operator_id"], updates last_login_at, rehashes if needed PASS
|
||||
- logout: Depends(current_operator), clears session PASS
|
||||
- me: Depends(current_operator), returns operator info PASS
|
||||
|
||||
**SLICE-04 (VC key migration):**
|
||||
- IssuerKeyStore Protocol (runtime_checkable) -> both stores implement it PASS
|
||||
- PgStore.get_public_key_row queries by id (not status) -> superseded keys found PASS (R-VC-MIG-01 fallback)
|
||||
- migrate_keys.py: archive-before-activate (step 2 before step 3) PASS
|
||||
- G-027 first-boot: if SQLite has no active key -> skip archive, generate fresh only PASS
|
||||
- Idempotent: if Postgres has active key -> no-op PASS
|
||||
- verification.py: G-011 two-store fallback (PG for keys -> SQLite for v0.3 creds -> SQLite-only if no PG) PASS
|
||||
- Tests: R-VC-MIG-01 ordering test (instrumented, verifies archive index < supersede index < fresh index) PASS
|
||||
|
||||
**SLICE-05 (Bootstrap CLI):**
|
||||
- scripts/create-operator.py: env-provided creds, argon2id hash, ON CONFLICT DO NOTHING (idempotent) PASS
|
||||
- --update flag: ON CONFLICT DO UPDATE (rehash) PASS
|
||||
- Missing env -> exit 1 with clear error PASS
|
||||
- Retry 3x/5s on connection failure (R-BOOT-01) PASS
|
||||
- config.json operator secrets scope added PASS
|
||||
- .ciagent/.env.secrets.example committed (no real secrets) PASS
|
||||
- .gitignore: `.env.secrets` ignored, `!.ciagent/.env.secrets.example` whitelisted PASS
|
||||
|
||||
**SLICE-06 (P1 integration):**
|
||||
- __main__.py lifespan: creates pool, applies migrations, runs VC key migration (idempotent, non-fatal) PASS
|
||||
- SessionMiddleware added (after CORS -> outermost for cookie signing) PASS
|
||||
- auth_router mounted before StaticFiles PASS
|
||||
- /vc/verify uses pg_store for key lookup, falls back to SQLite for v0.3 creds PASS
|
||||
- VC key migration runs on first boot (_maybe_migrate_issuer_keys) PASS
|
||||
- 503 on auth routes when no Postgres PASS
|
||||
- Learner voice loop unaffected (REQ-NFR-MT-01): /health returns 200 regardless of Postgres PASS
|
||||
|
||||
### REQ coverage
|
||||
| REQ-ID | Covered by | Verification |
|
||||
|--------|-----------|--------------|
|
||||
| REQ-MT-01 | SLICE-01, SLICE-04, SLICE-06 | docker-compose postgres + asyncpg pool + PgStore + IssuerKeyStore protocol + verification swap PASS |
|
||||
| REQ-AUTH-01 | SLICE-03, SLICE-05, SLICE-06 | argon2id + signed cookies + rate limit + current_operator dep + bootstrap CLI PASS |
|
||||
| REQ-NFR-AUTH-01 | SLICE-03, SLICE-06 | argon2id (PasswordHasher defaults), httpOnly+secure+SameSite=Strict, 5/min rate limit, 8h expiry PASS |
|
||||
| REQ-NFR-MT-01 | SLICE-01, SLICE-02, SLICE-06 | postgres internal network only (no ports), 6GB CT, graceful degradation, voice loop unaffected PASS |
|
||||
| REQ-MT-02 (schema) | SLICE-01 | cohort_aggregates table + PgStore.upsert_cohort_aggregate PASS (pipeline is P2) |
|
||||
|
||||
### Grill MUSTs honored
|
||||
| MUST | Honored | Evidence |
|
||||
|------|---------|----------|
|
||||
| G-008 (backup drill) | YES | `tests/test_backup_restore.py` -> seeds 5 tables, pg_dump, drop, pg_restore --clean --if-exists, verify counts. `scripts/backup-pg.sh` has restore drill comments. |
|
||||
| G-011 (two-store fallback) | YES | `server/vc/verification.py` _lookup_credential + _lookup_public_key implement (a)/(b)/(c). Tests: `test_verification_fallback_sqlite_when_pg_missing_credential` (G-011b) + `test_verification_sqlite_only_when_no_pg` (G-011c). |
|
||||
| G-027 (first-boot no v0.3 key) | YES | `migrate_keys.py` line 80-87: if v03_row is None -> archived_key_id=None, skip archive. Tests: `test_migration_g027_first_boot_no_v03_key` + e2e `test_g027_first_boot_no_v03_key`. |
|
||||
| G-031 (R-AUTH-01 reframe) | YES | `cookies.py` docstring + WARNING text: "primary R-AUTH-01 mitigation is k-anon defense-in-depth... this flag is the secondary mitigation." |
|
||||
| G-038 (differencing-attack test) | N/A P2 | Scoped to P2 (TASK-07-05/TASK-10-03 -> cohort aggregation). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
| G-041 (SPA fallback subclass) | N/A P2 | Scoped to P2 (TASK-10-01 -> React Router). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
|
||||
### R-VC-MIG-01 mitigation
|
||||
- **Archived-before-active:** `migrate_keys.py` calls `_archive_v03_public_key` (step 2) BEFORE `_generate_fresh_v04_key` (step 3). Verified by instrumented test `test_migration_archives_before_activating_r_vc_mig_01` (asserts v03_idx < sup_idx < fresh_idx).
|
||||
- **Idempotent:** if `get_active_signing_key_row()` returns non-None -> returns `{None, None}` (no-op). Test `test_migration_idempotent_when_active_key_exists`.
|
||||
- **Cannot replay to overwrite:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> existing keys are not overwritten.
|
||||
|
||||
### Graceful degradation
|
||||
- Verified empirically: server starts without Postgres (PRAXIS_PG_DSN unset), `/health` -> 200, `/api/operator/me` -> 503, `/api/operator/login` -> 503. Learner voice loop unaffected (SQLite path intact).
|
||||
|
||||
## Layer 3 — Security (STRIDE)
|
||||
|
||||
| Threat | Surface | Mitigation | Verified | Disposition |
|
||||
|--------|---------|------------|----------|-------------|
|
||||
| **Spoofing** | operator auth | argon2id (PasswordHasher defaults: time=3, mem=64MiB, par=4) + signed cookies (itsdangerous HMAC-SHA256) | No plaintext passwords in code; cookie signature checked by SessionMiddleware; verify_password catches VerifyMismatchError -> False | accept (low) |
|
||||
| **Tampering** | VC key migration | archived-before-active + idempotent + ON CONFLICT DO NOTHING | Instrumented ordering test; idempotency test; get_public_key_row queries by id (not status) so superseded keys cannot be silently replaced | accept (low) |
|
||||
| **Repudiation** | auth audit | last_login_at updated on successful login | `routes.py:86` calls `pg_store.update_last_login(op_id)`; `pg_store.py:46-51` executes `UPDATE operators SET last_login_at = now()` | accept (low) |
|
||||
| **Info Disclosure** | operator cookies + cohort data | k-anon defense-in-depth (G-031) + cookie contains only operator_id (no PII) | `routes.py:85` sets only `session["operator_id"]`; `dependencies.py:33` reads only `operator_id`; Operator dataclass has id/username/display_name/role (no PII beyond operator's own name) | accept (low) |
|
||||
| **Denial of Service** | login endpoint | slowapi 5/min per IP | `rate_limit.py` Limiter wired; `__main__.py:123-124` registers limiter + RateLimitExceeded handler; test verifies decorator factory | accept (medium -> in-memory counter lost on restart, R-AUTH-03 accepted pilot risk) |
|
||||
| **Elevation of Privilege** | /api/operator/* routes | single operator role + current_operator dep on every protected route | logout + me use `Depends(current_operator)`; no RBAC bypass possible (single role, no role-check logic to bypass); login is NOT auth-gated (correct -> entry point) | accept (low) |
|
||||
|
||||
**Cookie PII check:** The signed cookie (praxis_op) payload contains ONLY `{operator_id: "<uuid>"}`. No username, display_name, role, or learner data in the cookie. Verified by inspecting `routes.py:85` and `dependencies.py:33`.
|
||||
|
||||
**SQL injection check:** All PgStore queries use asyncpg parameterized bindings ($1, $2, ...). The one f-string in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) injects only a static fragment (`", revoked_at = now()"`) -> user-controlled values (status, cred_id) are bound parameters. SAFE.
|
||||
|
||||
**Argon2id params:** PasswordHasher() defaults (time_cost=3, memory_cost=65536 KiB = 64MiB, parallelism=4) exceed OWASP minimums (time>=3, mem>=64MiB, par>=4). Verified via import + hash timing (~119ms hash, ~98ms verify).
|
||||
|
||||
## Layer 4 — Quality (multi-persona review)
|
||||
|
||||
### Correctness
|
||||
- Migration script handles all 3 cases: (a) active key exists -> no-op, (b) v0.3 key exists -> archive+generate, (c) no v0.3 key -> generate only. Logic is sound.
|
||||
- Auth flow: login sets session -> me reads session -> logout clears session. Inactive operator -> 401 + session.clear() (invalidates cookie). Edge cases covered.
|
||||
- Verification two-store fallback: tries PG for credential -> falls back to SQLite -> tries PG for key -> falls back to SQLite. Order is correct (PG preferred for v0.4 keys, SQLite fallback for v0.3 creds).
|
||||
- `_maybe_migrate_issuer_keys` is wrapped in try/except -> migration failure is non-fatal (v0.3 SQLite path remains). Correct for graceful degradation.
|
||||
|
||||
### Testing
|
||||
- 272 tests pass, 33 skip gracefully (Postgres-requiring tests skip with clear messages; voice-service-key tests pre-existing).
|
||||
- Mock-based equivalents exist for all Postgres-requiring paths: `test_auth.py` (mocked PgStore), `test_vc_migration.py` (mocked stores), `test_create_operator.py` (mocked PgStore).
|
||||
- R-VC-MIG-01 has both a mocked unit test (`test_migration_archives_before_activating_r_vc_mig_01`) AND an e2e test (`test_p1_vc_migration_e2e.py` -> requires PG).
|
||||
- Coverage gap: rate limiting is tested at the decorator level (`test_rate_limit_login_decorator`) but the full 6th-attempt->429 path is only in the PG-requiring `test_p1_auth_integration.py`. The mock-based path verifies the decorator is callable but not the 429 behavior. **P1+ flag** (non-blocking -> the 429 path is tested when PG is available).
|
||||
|
||||
### Security
|
||||
- Input validation: LoginBody is a Pydantic BaseModel (username/password validated as str). No raw user input reaches SQL.
|
||||
- Injection vectors: parameterized queries throughout. The one f-string is static-fragment only. **No injection vectors found.**
|
||||
- Cookie secret: if unset -> ephemeral random + WARNING (dev only). For pilot, `.env.secrets.example` documents generation (`openssl rand -base64 48`).
|
||||
- Weak PRAXIS_COOKIE_SECRET: if an attacker knows the secret, they can forge cookies. Mitigation: secret is in `.env.secrets` (gitignored), injected via lxc.environment. **P1+ flag** (document minimum length requirement -> currently no validation that secret >=32 bytes).
|
||||
|
||||
### Performance
|
||||
- asyncpg pool: min=1, max=10, command_timeout=10s. Appropriate for single-instance pilot.
|
||||
- **Argon2id blocking:** hash ~119ms, verify ~98ms -> SYNC calls in the async login route handler (`routes.py:79, 88`). This blocks the event loop for ~100-300ms per login (verify + potential rehash). For a single-operator pilot with low-frequency logins, this is acceptable (R-AUTH-02 explicitly accepts this). **P1+ flag** (offload to `asyncio.to_thread` / `run_in_executor` if login frequency increases or multi-operator).
|
||||
- No other blocking calls in async paths. Pool.acquire() is async. All PgStore methods are async.
|
||||
- Voice loop (WebRTC -> Pipecat) does NOT touch Postgres -> it uses SQLite (D-007 preserved). No perf impact on the <600ms latency budget (C-8).
|
||||
|
||||
### Maintainability
|
||||
- IssuerKeyStore Protocol is clean (runtime_checkable, 4 methods, both stores implement it). Duck-typing formalized without breaking existing PraxisStore.
|
||||
- Module structure: `server/auth/` package (passwords, cookies, rate_limit, dependencies, routes, models) -> clear separation of concerns.
|
||||
- `db/pg_store.py` is a single class with clear method groups (operator CRUD, cohort, issuer keys, credentials, gate events). No god-class anti-pattern.
|
||||
- Naming: consistent `get_*_row` / `set_*` / `insert_*` / `upsert_*` conventions. `learner_ref` is opaque (not FK) per D-031.
|
||||
- Coupling: `verification.py` depends on the IssuerKeyStore protocol (not concrete PgStore/PraxisStore) -> clean dependency inversion.
|
||||
|
||||
### Adversarial
|
||||
- **Weak PRAXIS_COOKIE_SECRET:** if the secret is short or predictable, cookies can be forged. No length validation in `cookies.py` (only checks non-empty). **P1+ flag** (add `len(secret) >= 32` check with WARNING).
|
||||
- **Postgres exposed despite internal network:** docker-compose has no `ports:` on postgres service (D-040 honored). An attacker would need to compromise the LXC CT or praxis-net bridge. Mitigated by network isolation.
|
||||
- **Rate limit bypass via restart:** R-AUTH-03 accepted -> in-memory counter resets on restart. For a single-instance pilot, restarts are operator-initiated and rare. Documented in `rate_limit.py`.
|
||||
- **Migration replay attack:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> re-running migration cannot overwrite an existing key. An attacker with DB access could insert a key directly, but DB access is already game-over. Not a v0.4 concern.
|
||||
|
||||
## P0 Fixes Applied
|
||||
None. No P0 issues found. (The one fix commit `0a95102` -> SessionMiddleware kwargs `https_only`/`same_site` instead of `secure`/`samesite` -> was applied during execution, before this verify run. Verified correct: `inspect.signature(SessionMiddleware.__init__)` confirms `https_only` and `same_site` are the valid parameter names.)
|
||||
|
||||
## P1+ Flagged for Post-Hoc Review
|
||||
1. **Argon2id blocking event loop** (`server/auth/routes.py:79,88`): `verify_password` + `hash_password` (rehash) are sync calls in the async login handler, blocking ~100-300ms. Acceptable for single-operator pilot (R-AUTH-02). If login frequency increases, offload to `asyncio.to_thread`. **Non-blocking.**
|
||||
2. **Rate limit 429 not tested in mock path** (`tests/test_auth.py:303`): only the decorator factory is tested in the mock-based suite; the full 6th-attempt->429 path is in the PG-requiring integration test. Add a mock-based 429 test for CI coverage without Postgres. **Non-blocking.**
|
||||
3. **No PRAXIS_COOKIE_SECRET length validation** (`server/auth/cookies.py:41`): only checks non-empty, not >=32 bytes. A short secret weakens the HMAC signature. Add `len(secret) >= 32` check with WARNING. **Non-blocking.**
|
||||
4. **`set_credential_status` status field not validated** (`db/pg_store.py:223`): accepts any string for `status` (no enum check). Currently only called with "revoked" from operator code, but a future caller could pass arbitrary strings. Consider a CHECK constraint on the `issued_credentials.status` column or a Python enum. **Non-blocking.**
|
||||
|
||||
---
|
||||
|
||||
## Verification Result
|
||||
|
||||
Phase 1 (Operator Foundation) is **APPROVED_WITH_NOTES**. All 4 layers pass. All 5 P1-scoped REQ-IDs are covered. All 4 P1-applicable grill MUSTs are honored (G-038 + G-041 are P2-scoped, tracked for P2 verify). No P0 issues. 4 P1+ items flagged for post-hoc review in P3 (non-blocking). The phase is ready for ship (v0.1.7) -> the orchestrator delegates to ship after this verify.
|
||||
@@ -99,6 +99,10 @@
|
||||
{
|
||||
"name": "voice",
|
||||
"env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"]
|
||||
},
|
||||
{
|
||||
"name": "operator",
|
||||
"env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+52
-2
@@ -53,8 +53,58 @@ CARTESIA_VOICE_ID=a3536a36-1d18-4efb-a95a-7c44b7b5e384
|
||||
# PROXMOX_TEMPLATE_VOLID=local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst
|
||||
# PROXMOX_LXC_VMID=auto
|
||||
# PROXMOX_TLS_SKIP_VERIFY=true
|
||||
# PROXMOX_MEMORY_MB=4096
|
||||
# v0.4: bumped to 6144 (Postgres ~400MB + praxis ~500MB + Docker ~200MB
|
||||
# + build headroom ~1GB + margin — REQ-NFR-MT-01).
|
||||
# PROXMOX_MEMORY_MB=6144
|
||||
|
||||
# ─── CI/Gitea (operational — not voice) ───────────────────────────────────────
|
||||
# GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file).
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
|
||||
# ─── v0.4 Operator Tier (Postgres + Auth) ────────────────────────────────────
|
||||
# These configure the operator surface (cohort dashboard, auth, VC migration).
|
||||
# Real values are secrets — put them in .ciagent/.env.secrets, not here.
|
||||
# This file is documentation-only (committed); .env.secrets is gitignored.
|
||||
|
||||
# Postgres password. Secret. Used in the DSN below + docker-compose postgres
|
||||
# service (POSTGRES_PASSWORD). Generate with: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Postgres DSN (D-050). host=postgres is the docker-compose service DNS name
|
||||
# on the praxis-net bridge. Format:
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
# When unset/empty, the server starts in graceful no-pool mode (learner voice
|
||||
# loop works; operator auth + cohort endpoints return 503).
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (D-056, R-AUTH-01). >=32 random bytes, base64 or hex.
|
||||
# Secret. Generate with: openssl rand -base64 48
|
||||
# When unset, the server generates an ephemeral random secret (dev ONLY —
|
||||
# sessions won't survive a restart; NOT for pilot/production).
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Cookie Secure flag (D-041, R-AUTH-01, G-031). Default true (HTTPS).
|
||||
# Set to false ONLY for the HTTP pilot (no TLS in the LXC pilot — D-030).
|
||||
# NOTE (G-031): the PRIMARY mitigation for a sniffed cookie is the k-anon
|
||||
# defense-in-depth (the cohort dashboard reads only k-anonymized aggregates,
|
||||
# so a sniffed operator cookie leaks NO learner PII). This flag is the
|
||||
# SECONDARY mitigation (operational convenience for when TLS arrives).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
|
||||
# Bootstrap operator credentials (D-052). Secret. Used by
|
||||
# scripts/create-operator.py on first run to create the initial operator.
|
||||
# If either is missing, the CLI exits 1 (R-BOOT-02).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (v0.3 + v0.4). Secret. Used by nacl.SecretBox to encrypt
|
||||
# Ed25519 private keys at rest (D-042). In v0.4 the migration script
|
||||
# (server/vc/migrate_keys.py) uses this to encrypt the fresh v0.4 keypair;
|
||||
# the v0.3 root key is kept for the v0.3 SQLite verification path (R-VC-MIG-02).
|
||||
# Generate with: python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (D-042). The public base URL for VC issuer + key identifiers.
|
||||
# v0.4 changes the default to /issuers/v0.4 (v0.3 VCs keep their v0.3 URLs
|
||||
# embedded in their proofs — verification fetches keys by id, not by URL).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
@@ -12,6 +12,8 @@ venv/
|
||||
.env.secrets
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.secrets.example
|
||||
!.ciagent/.env.secrets.example
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Postgres migration runner — applies db/pg_migrations/*.sql in order.
|
||||
|
||||
Mirrors db/migrate.py: ordered .sql files tracked in a `_pg_migrations`
|
||||
table so re-running is idempotent. Uses an asyncpg pool. Retries on
|
||||
connection failure (3 attempts, 2s backoff — R-MT-02 mitigation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "pg_migrations"
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 2.0
|
||||
|
||||
|
||||
async def apply_pg_migrations(
|
||||
pool: asyncpg.Pool,
|
||||
migrations_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Apply all pending Postgres migrations in order. Returns applied names.
|
||||
|
||||
Idempotent — no-op if all migrations are already applied. Each migration
|
||||
runs within a transaction; the `_pg_migrations` tracking row is inserted
|
||||
in the same transaction so a failure rolls back cleanly.
|
||||
"""
|
||||
mdir = migrations_dir or _DEFAULT_MIGRATIONS_DIR
|
||||
if not mdir.exists():
|
||||
return []
|
||||
|
||||
async def _run() -> list[str]:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _pg_migrations ("
|
||||
"id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()"
|
||||
")"
|
||||
)
|
||||
rows = await conn.fetch("SELECT id FROM _pg_migrations")
|
||||
applied_ids = {r["id"] for r in rows}
|
||||
applied: list[str] = []
|
||||
for sql_path in sorted(mdir.glob("*.sql")):
|
||||
mid = sql_path.stem
|
||||
if mid in applied_ids:
|
||||
continue
|
||||
sql = sql_path.read_text(encoding="utf-8")
|
||||
async with conn.transaction():
|
||||
await conn.execute(sql)
|
||||
await conn.execute(
|
||||
"INSERT INTO _pg_migrations (id) VALUES ($1)", mid
|
||||
)
|
||||
applied.append(mid)
|
||||
return applied
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
return await _run()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
__all__ = ["apply_pg_migrations"]
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Praxis v0.4 operator-tier schema migration 0001.
|
||||
-- Creates the 5 operator-tier tables. Uses gen_random_uuid() (PG16 core).
|
||||
-- Idempotent via IF NOT EXISTS (also safe through pg_migrate tracking).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Praxis v0.4 operator-tier Postgres schema (reference).
|
||||
-- Applied in order by db/pg_migrate.py via db/pg_migrations/*.sql.
|
||||
-- The canonical migration is 0001_operator_tier.sql; this file is the
|
||||
-- human-readable reference (kept in sync). Uses gen_random_uuid() which
|
||||
-- is in PG16 core (no extension needed — R-MT-05 verified).
|
||||
--
|
||||
-- Tables:
|
||||
-- operators — operator accounts (argon2id password hash)
|
||||
-- issued_credentials — VC issuance log (learner_ref is opaque, no FK)
|
||||
-- mastery_gate_events — mastery gate audit log (REQ-NFR-MAST-02)
|
||||
-- cohort_aggregates — k-anonymized cohort metrics (plain table, D-050)
|
||||
-- issuer_keys — Ed25519 issuer key lifecycle (active/superseded)
|
||||
--
|
||||
-- No cross-DB FKs (D-031). learner_ref is an opaque string in Postgres.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
-- Plain table, NOT partitioned (D-050..D-053; add partitioning post-pilot).
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06).
|
||||
|
||||
Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol
|
||||
(server/vc/issuer_keys.py) so VC verification can use either PraxisStore
|
||||
(SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031);
|
||||
`learner_ref` is an opaque string in Postgres (not a FK to SQLite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
class PgStore:
|
||||
"""Async Postgres store for the v0.4 operator tier."""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
# ── Operator CRUD ────────────────────────────────────────────────────
|
||||
|
||||
async def get_operator_by_username(self, username: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE username = $1",
|
||||
username,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_operator_by_id(self, operator_id: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def update_last_login(self, operator_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET last_login_at = now() WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
|
||||
async def insert_operator(
|
||||
self,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
display_name: str | None = None,
|
||||
*,
|
||||
on_conflict_update: bool = False,
|
||||
) -> str | None:
|
||||
"""Insert an operator (idempotent on username). Returns the id, or
|
||||
None if the row already existed and on_conflict_update is False."""
|
||||
async with self.pool.acquire() as conn:
|
||||
if on_conflict_update:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO UPDATE SET "
|
||||
"password_hash = excluded.password_hash, "
|
||||
"display_name = excluded.display_name "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO NOTHING "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
# ── Cohort aggregate read/write ──────────────────────────────────────
|
||||
|
||||
async def get_cohort_aggregates(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
since_date: Any,
|
||||
) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT path, metric, window_start, window_end, value, "
|
||||
"cell_count, cell_suppressed, updated_at "
|
||||
"FROM cohort_aggregates "
|
||||
"WHERE path = $1 AND metric = $2 AND window_start >= $3 "
|
||||
"ORDER BY window_start",
|
||||
path,
|
||||
metric,
|
||||
since_date,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def upsert_cohort_aggregate(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
window_start: Any,
|
||||
window_end: Any,
|
||||
value: float | None,
|
||||
cell_count: int,
|
||||
cell_suppressed: bool,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO cohort_aggregates "
|
||||
"(path, metric, window_start, window_end, value, cell_count, "
|
||||
"cell_suppressed, updated_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, now()) "
|
||||
"ON CONFLICT (path, metric, window_start) DO UPDATE SET "
|
||||
"window_end = excluded.window_end, value = excluded.value, "
|
||||
"cell_count = excluded.cell_count, "
|
||||
"cell_suppressed = excluded.cell_suppressed, "
|
||||
"updated_at = now()",
|
||||
path,
|
||||
metric,
|
||||
window_start,
|
||||
window_end,
|
||||
value,
|
||||
cell_count,
|
||||
cell_suppressed,
|
||||
)
|
||||
|
||||
# ── IssuerKeyStore protocol (D-051, TASK-04-02) ──────────────────────
|
||||
|
||||
async def init_issuer_key(
|
||||
self,
|
||||
key_id: str,
|
||||
public_key: str,
|
||||
private_key_enc: bytes | None,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
|
||||
"VALUES ($1, $2, $3, 'active') "
|
||||
"ON CONFLICT (id) DO NOTHING",
|
||||
key_id,
|
||||
public_key,
|
||||
private_key_enc if private_key_enc is not None else b"",
|
||||
)
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE status = 'active' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None:
|
||||
# Queries by id (NOT status) so superseded keys are found too —
|
||||
# this is the R-VC-MIG-01 verification fallback (D-051).
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE issuer_keys SET status = 'superseded' WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
|
||||
# ── Credential methods ───────────────────────────────────────────────
|
||||
|
||||
async def insert_credential(
|
||||
self,
|
||||
cred_id: str,
|
||||
learner_ref: str,
|
||||
payload_json: str,
|
||||
signature_b64: str,
|
||||
*,
|
||||
operator_id: str | None = None,
|
||||
vc_type: str = "MasteryCredential",
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issued_credentials "
|
||||
"(id, operator_id, learner_ref, vc_type, payload_jsonb, "
|
||||
"signature_b64, status) "
|
||||
"VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')",
|
||||
cred_id,
|
||||
operator_id,
|
||||
learner_ref,
|
||||
vc_type,
|
||||
payload_json,
|
||||
signature_b64,
|
||||
)
|
||||
|
||||
async def get_credential(self, cred_id: str) -> dict | None:
|
||||
# Returns a row shaped like PraxisStore.get_credential so the
|
||||
# verification code can use either store interchangeably.
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, learner_ref, "
|
||||
"payload_jsonb::text AS vc_payload_json, signature_b64, "
|
||||
"status, issued_at "
|
||||
"FROM issued_credentials WHERE id = $1",
|
||||
cred_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_credential_status(self, cred_id: str, status: str) -> None:
|
||||
extra = ", revoked_at = now()" if status == "revoked" else ""
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2",
|
||||
status,
|
||||
cred_id,
|
||||
)
|
||||
|
||||
async def list_credentials(self, operator_id: str | None = None) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
if operator_id is None:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials ORDER BY issued_at DESC"
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials "
|
||||
"WHERE operator_id = $1 ORDER BY issued_at DESC",
|
||||
operator_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Mastery gate event ───────────────────────────────────────────────
|
||||
|
||||
async def record_gate_event(
|
||||
self,
|
||||
learner_ref: str,
|
||||
path_id: str,
|
||||
scenario_id: str | None = None,
|
||||
gate_outcome: str | None = None,
|
||||
rubric_scores_jsonb: Any | None = None,
|
||||
) -> str:
|
||||
event_id = str(uuid.uuid4())
|
||||
scores_json = (
|
||||
rubric_scores_jsonb
|
||||
if isinstance(rubric_scores_jsonb, str)
|
||||
else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None)
|
||||
)
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO mastery_gate_events "
|
||||
"(id, learner_ref, scenario_id, path_id, gate_outcome, "
|
||||
"rubric_scores_jsonb, source) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')",
|
||||
event_id,
|
||||
learner_ref,
|
||||
scenario_id,
|
||||
path_id,
|
||||
gate_outcome,
|
||||
scores_json,
|
||||
)
|
||||
return event_id
|
||||
|
||||
|
||||
__all__ = ["PgStore"]
|
||||
+1
-1
@@ -338,7 +338,7 @@ class PraxisStore:
|
||||
async with self._connect() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cur = await db.execute(
|
||||
"SELECT id, public_key, status, created_at "
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
|
||||
+49
-4
@@ -1,6 +1,6 @@
|
||||
# Praxis v0.2 — Docker Compose service definition
|
||||
# Runs the praxis server inside a Docker container (inside an LXC CT).
|
||||
# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture.
|
||||
# Praxis — Docker Compose service definition (v0.2 + v0.4 Postgres).
|
||||
# Runs the praxis server + a Postgres 16 service inside a Docker-in-LXC CT.
|
||||
# Per ARCHITECTURE.md §v0.2 Deployment + §v0.4 Operator-Tier Architecture.
|
||||
|
||||
services:
|
||||
praxis:
|
||||
@@ -34,6 +34,13 @@ services:
|
||||
DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}"
|
||||
# Cartesia (D-014)
|
||||
CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}"
|
||||
# v0.4 operator tier — Postgres DSN (D-050). Empty → graceful no-pool mode.
|
||||
PRAXIS_PG_DSN: "${PRAXIS_PG_DSN:-}"
|
||||
# v0.4 auth (D-041, D-056). Empty → server generates ephemeral secret (dev only).
|
||||
PRAXIS_COOKIE_SECRET: "${PRAXIS_COOKIE_SECRET:-}"
|
||||
PRAXIS_COOKIE_SECURE: "${PRAXIS_COOKIE_SECURE:-true}"
|
||||
PRAXIS_VC_ISSUER_KEY: "${PRAXIS_VC_ISSUER_KEY:-}"
|
||||
PRAXIS_ISSUER_URL: "${PRAXIS_ISSUER_URL:-https://praxis.example/issuers/v0.4}"
|
||||
env_file:
|
||||
# /etc/praxis/server.env is written by install-service.sh with
|
||||
# secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked
|
||||
@@ -43,7 +50,45 @@ services:
|
||||
# `docker compose up` in production (so secrets are present at runtime).
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- praxis-net
|
||||
|
||||
postgres:
|
||||
image: postgres:16-slim
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: praxis
|
||||
POSTGRES_PASSWORD: "${PRAXIS_PG_PASSWORD:-}"
|
||||
POSTGRES_DB: praxis
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
env_file:
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgbackups:/backups
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- praxis-net
|
||||
# No `ports:` — Postgres is NOT exposed to the LXC host bridge (D-040).
|
||||
# The praxis service reaches it via the praxis-net bridge using the
|
||||
# service-DNS name `postgres`.
|
||||
|
||||
volumes:
|
||||
praxis-data:
|
||||
driver: local
|
||||
driver: local
|
||||
pgdata:
|
||||
driver: local
|
||||
pgbackups:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
praxis-net:
|
||||
driver: bridge
|
||||
@@ -38,6 +38,13 @@ dependencies = [
|
||||
"pynacl>=1.5",
|
||||
"canonicaljson>=2.0",
|
||||
"base58>=2.1",
|
||||
# v0.4 operator tier — Postgres pool (D-050), argon2id passwords (D-041),
|
||||
# slowapi rate limiting (D-041). RESEARCH-v0.4 §new-deps.
|
||||
"asyncpg>=0.29",
|
||||
"argon2-cffi>=23.1",
|
||||
"slowapi>=0.1",
|
||||
# SessionMiddleware uses itsdangerous for signed cookies (D-056).
|
||||
"itsdangerous>=2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# Praxis v0.4 — Nightly Postgres backup (D-055, G-008).
|
||||
#
|
||||
# Host-side cron script (decoupled from praxis service uptime —
|
||||
# RESEARCH-v0.4 §1.5). Runs pg_dump inside the postgres container and
|
||||
# writes a compressed custom-format dump to the pgbackups volume.
|
||||
#
|
||||
# The %u date format = day-of-week 1..7 (Monday=1, Sunday=7) → rolling
|
||||
# 7-file retention with zero cleanup logic (D-055). Re-running overwrites
|
||||
# the same day-of-week file.
|
||||
#
|
||||
# Cron entry (host, 03:30 CT nightly):
|
||||
# 30 3 * * * /opt/praxis/scripts/backup-pg.sh
|
||||
#
|
||||
# Restore drill (G-008 — run at least once in staging to prove the backup
|
||||
# is valid; NEVER restore into a live DB without stopping praxis first):
|
||||
# docker compose stop praxis
|
||||
# docker compose exec postgres pg_restore -U praxis -d praxis \
|
||||
# --clean --if-exists /backups/praxis-3.dump
|
||||
# # verify: \d operators; SELECT count(*) FROM operators; (etc. for all 5 tables)
|
||||
# docker compose start praxis
|
||||
#
|
||||
# POSIX-sh compatible (no bashisms). Exit 0 on success, 1 on failure.
|
||||
# Args: none. Env: COMPOSE_PROJECT_DIR (default: current dir).
|
||||
|
||||
set -eu
|
||||
|
||||
PROJECT_DIR="${COMPOSE_PROJECT_DIR:-$(pwd)}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
DOW="$(date +%u)"
|
||||
DUMP_FILE="/backups/praxis-${DOW}.dump"
|
||||
|
||||
echo "backup-pg: dumping praxis DB → ${DUMP_FILE} (day-of-week ${DOW})"
|
||||
|
||||
# -Fc = custom compressed format (works with pg_restore --clean --if-exists).
|
||||
# -T stops the container from streaming while dumping? No — pg_dump is
|
||||
# consistent within a transaction; the praxis service can stay up.
|
||||
docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f "$DUMP_FILE"
|
||||
|
||||
# Verify the dump is non-empty (sanity — a 0-byte dump means failure).
|
||||
SIZE=$(docker compose exec -T postgres stat -c '%s' "$DUMP_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$SIZE" -le 0 ]; then
|
||||
echo "backup-pg: ERROR — dump file is empty (${DUMP_FILE})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "backup-pg: OK — ${DUMP_FILE} is ${SIZE} bytes"
|
||||
echo "backup-pg: restore drill (G-008): docker compose exec postgres pg_restore -U praxis -d praxis --clean --if-exists ${DUMP_FILE}"
|
||||
exit 0
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052).
|
||||
|
||||
Creates the initial operator from env-provided credentials. Idempotent
|
||||
(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update.
|
||||
|
||||
Env:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required)
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required)
|
||||
PRAXIS_PG_DSN — Postgres DSN (required)
|
||||
|
||||
Exit: 0 on success (created or already-exists), 1 on missing env / DB error.
|
||||
Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01).
|
||||
|
||||
Run:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \
|
||||
PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \
|
||||
python3 scripts/create-operator.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_ph = PasswordHasher()
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 5.0
|
||||
|
||||
|
||||
async def create_operator(update: bool = False) -> int:
|
||||
user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip()
|
||||
pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "")
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not user or not pw:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and "
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not dsn:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_PG_DSN must be set.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
import asyncpg
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn, min_size=1, max_size=3, command_timeout=10
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(pool)
|
||||
store = PgStore(pool)
|
||||
pw_hash = _ph.hash(pw)
|
||||
display = user
|
||||
oid = await store.insert_operator(
|
||||
user, pw_hash, display, on_conflict_update=update
|
||||
)
|
||||
if update:
|
||||
print(f"create-operator: updated operator {user!r} (id={oid})")
|
||||
elif oid is not None:
|
||||
print(f"create-operator: created operator {user!r} (id={oid})")
|
||||
else:
|
||||
print(f"create-operator: operator {user!r} already exists (no change)")
|
||||
return 0
|
||||
finally:
|
||||
await pool.close()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
print(
|
||||
f"create-operator: connection attempt {attempt} failed "
|
||||
f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} "
|
||||
f"attempts: {last_exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create the initial Praxis operator.")
|
||||
parser.add_argument(
|
||||
"--update", action="store_true",
|
||||
help="Force rehash + update if the operator already exists.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(create_operator(update=args.update))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -40,7 +40,10 @@ upid=$(pve_curl POST "$create_path" \
|
||||
"hostname=${hostname}" \
|
||||
"storage=${storage}" \
|
||||
"rootfs=${storage}:16" \
|
||||
"memory=${PROXMOX_MEMORY_MB:-4096}" \
|
||||
# v0.4: 6144MB default (was 4096 in v0.2). Postgres ~400MB + praxis
|
||||
# ~500MB + Docker daemon ~200MB + build headroom ~1GB + margin
|
||||
# (REQ-NFR-MT-01). Override with PROXMOX_MEMORY_MB if needed.
|
||||
"memory=${PROXMOX_MEMORY_MB:-6144}" \
|
||||
"net0=name=eth0,bridge=vmbr0,ip=dhcp" \
|
||||
"arch=amd64" \
|
||||
"features=nesting=1")
|
||||
|
||||
+118
-8
@@ -14,6 +14,7 @@ no audio/no tokens at runtime, not a crash.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -27,14 +28,25 @@ try:
|
||||
except ImportError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.rate_limit import limiter
|
||||
from server.auth.routes import router as auth_router
|
||||
from server.pipeline import build_pipeline
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
_store = PraxisStore()
|
||||
|
||||
@@ -47,6 +59,58 @@ HOST = _env("PRAXIS_HOST", "0.0.0.0")
|
||||
PORT = int(_env("PRAXIS_PORT", "8789"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""v0.4 — create the asyncpg Postgres pool on startup, close on shutdown.
|
||||
|
||||
Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset,
|
||||
the server starts without Postgres — the learner voice loop (SQLite)
|
||||
is unaffected. app.state.pg_pool / app.state.pg_store are None in that
|
||||
case and auth/operator routes return 503.
|
||||
"""
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not dsn:
|
||||
logger.warning(
|
||||
"PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). "
|
||||
"Operator auth + cohort endpoints will be unavailable (503). "
|
||||
"Learner voice loop (SQLite) is unaffected."
|
||||
)
|
||||
app.state.pg_pool = None
|
||||
app.state.pg_store = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
return
|
||||
import asyncpg
|
||||
|
||||
logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)")
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn,
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
command_timeout=10,
|
||||
)
|
||||
app.state.pg_pool = pool
|
||||
app.state.pg_store = PgStore(pool)
|
||||
try:
|
||||
applied = await apply_pg_migrations(pool)
|
||||
if applied:
|
||||
logger.info(f"Postgres migrations applied: {applied}")
|
||||
else:
|
||||
logger.info("Postgres migrations up to date")
|
||||
# VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on
|
||||
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
|
||||
# remains intact for verification).
|
||||
await _maybe_migrate_issuer_keys()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
pass
|
||||
finally:
|
||||
await pool.close()
|
||||
logger.info("Postgres pool closed")
|
||||
|
||||
|
||||
class WebRTCOffer(BaseModel):
|
||||
"""Client→server WebRTC offer (SDP + type)."""
|
||||
|
||||
@@ -54,13 +118,19 @@ class WebRTCOffer(BaseModel):
|
||||
type: str = "offer"
|
||||
|
||||
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0")
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0", lifespan=lifespan)
|
||||
# slowapi rate-limit state + 429 handler (D-041, TASK-03-03).
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # dev — the client is a separate Vite origin
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is
|
||||
# the outermost middleware (signs cookies before CORS headers are added).
|
||||
app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -123,20 +193,60 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
|
||||
|
||||
@app.get("/vc/verify/{credential_id}")
|
||||
async def vc_verify(credential_id: str) -> dict[str, Any]:
|
||||
"""Public, unauthenticated VC verification endpoint (D-043).
|
||||
"""Public, unauthenticated VC verification endpoint (D-043, G-011).
|
||||
|
||||
Returns {valid, status, issuer, credential, mastery, credentialTier,
|
||||
verifiedAt}. 404 if the credential id is not found. No PII beyond what
|
||||
the credential asserts.
|
||||
Two-store fallback (G-011, binding contract):
|
||||
(a) If Postgres is available (app.state.pg_store), use it for issuer
|
||||
key lookup (active + superseded keys).
|
||||
(b) If the credential is not in Postgres issued_credentials, fall back
|
||||
to SQLite (v0.3 credentials remain in SQLite — D-051).
|
||||
(c) If Postgres is NOT available, use the v0.3 SQLite path for both.
|
||||
The VC key migration (TASK-04-03) runs once on first boot (idempotent)
|
||||
inside the lifespan — see _maybe_migrate_issuer_keys.
|
||||
"""
|
||||
await _store.init()
|
||||
result = await verify_credential(_store, credential_id)
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
result = await verify_credential(
|
||||
_store, credential_id,
|
||||
pg_store=pg_store, sqlite_store=_store,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="credential not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
|
||||
async def _maybe_migrate_issuer_keys() -> None:
|
||||
"""Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01).
|
||||
|
||||
Idempotent — no-op if Postgres already has an active issuer key. G-027:
|
||||
if SQLite has no v0.3 active key (fresh deploy), skips archive and only
|
||||
generates a fresh v0.4 keypair.
|
||||
"""
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
return
|
||||
try:
|
||||
await _store.init()
|
||||
root_key = _load_root_key()
|
||||
result = await migrate_issuer_keys(_store, pg_store, root_key)
|
||||
if result["new_key_id"] is not None:
|
||||
logger.info(
|
||||
f"VC key migration: archived v0.3 key={result['archived_key_id']}, "
|
||||
f"generated fresh v0.4 key={result['new_key_id']}"
|
||||
)
|
||||
else:
|
||||
logger.info("VC key migration: active key already present (no-op)")
|
||||
except Exception as exc:
|
||||
logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}")
|
||||
|
||||
|
||||
# ── Operator auth routes (TASK-06-02, D-057) ───────────────────────────
|
||||
# Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by
|
||||
# the router (routes-before-static-mount constraint, carry-forward v0.2).
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────────
|
||||
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
|
||||
# take precedence. html=True serves index.html for "/" (SPA root).
|
||||
# The client has no React Router (single-view state machine: start→live
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Signed cookie configuration (TASK-03-02, D-041, D-056, R-AUTH-01, G-031).
|
||||
|
||||
Returns kwargs for Starlette SessionMiddleware (itsdangerous HMAC-SHA256
|
||||
signed cookies — D-056, stateless, no sessions table). The cookie name is
|
||||
`praxis_op` (distinct from any future learner cookie).
|
||||
|
||||
R-AUTH-01 / G-031 reframe: the PRIMARY mitigation for a sniffed operator
|
||||
cookie is the k-anonymity defense-in-depth — the cohort dashboard reads
|
||||
only k-anonymized aggregates, so a sniffed cookie leaks NO learner PII.
|
||||
The `PRAXIS_COOKIE_SECURE` flag is the SECONDARY mitigation (operational
|
||||
convenience for when TLS arrives). It defaults to true; the HTTP pilot
|
||||
(LXC, no TLS — D-030) sets it to false with a logged WARNING.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_COOKIE_MAX_AGE_S = 28800 # 8h (D-041)
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.environ.get(key, "").strip().lower()
|
||||
if raw in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_session_middleware_kwargs() -> dict:
|
||||
"""Return kwargs for Starlette SessionMiddleware.
|
||||
|
||||
If PRAXIS_COOKIE_SECRET is unset, generate an ephemeral random secret
|
||||
and log a WARNING (dev only — sessions won't survive a restart and this
|
||||
MUST NOT be used in pilot/production).
|
||||
"""
|
||||
secret = os.environ.get("PRAXIS_COOKIE_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
logger.warning(
|
||||
"PRAXIS_COOKIE_SECRET not set — generated an ephemeral random secret. "
|
||||
"Sessions will NOT survive a server restart. This is dev-only; set "
|
||||
"PRAXIS_COOKIE_SECRET (>=32 bytes) for pilot/production."
|
||||
)
|
||||
secure = _env_bool("PRAXIS_COOKIE_SECURE", True)
|
||||
if not secure:
|
||||
logger.warning(
|
||||
"Cookie Secure flag disabled (PRAXIS_COOKIE_SECURE=false) — HTTP pilot "
|
||||
"mode (R-AUTH-01). Do not use in production. NOTE (G-031): the primary "
|
||||
"R-AUTH-01 mitigation is k-anon defense-in-depth (cohort dashboard reads "
|
||||
"only k-anonymized aggregates → sniffed cookie leaks no PII); this flag "
|
||||
"is the secondary mitigation."
|
||||
)
|
||||
return {
|
||||
"secret_key": secret,
|
||||
"session_cookie": "praxis_op",
|
||||
"max_age": _COOKIE_MAX_AGE_S,
|
||||
"https_only": secure,
|
||||
"same_site": "strict",
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["get_session_middleware_kwargs"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""current_operator dependency (TASK-03-04, D-057).
|
||||
|
||||
Server-side auth enforcement: every `/api/operator/*` protected route uses
|
||||
`Depends(current_operator)`. The dependency NEVER trusts the client (D-057)
|
||||
— it reads the signed-cookie session, fetches the operator from Postgres,
|
||||
and 401s on any gap (missing/invalid/expired cookie, unknown id, inactive
|
||||
operator). The cookie is the authz *token*; the Postgres lookup is the
|
||||
authz *decision*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from server.auth.models import Operator
|
||||
|
||||
|
||||
async def current_operator(request: Request) -> Operator:
|
||||
"""Resolve the authenticated operator from the signed-cookie session.
|
||||
|
||||
Raises 401 on: missing session, missing operator_id, no Postgres store
|
||||
(503 actually — operator tier unavailable), unknown operator id, or an
|
||||
inactive operator (session is cleared in the latter case so the client
|
||||
cookie is invalidated).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
session = request.session
|
||||
op_id = session.get("operator_id") if session else None
|
||||
if not op_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
row = await pg_store.get_operator_by_id(op_id)
|
||||
if row is None or not row.get("is_active"):
|
||||
# Inactive/unknown → clear the session so the cookie is invalidated.
|
||||
if session:
|
||||
session.clear()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
return Operator(
|
||||
id=str(row["id"]),
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["current_operator"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Auth data models (TASK-03-04)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Operator:
|
||||
"""The authenticated operator injected into protected routes (D-057)."""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str
|
||||
|
||||
|
||||
__all__ = ["Operator"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Argon2id password hashing (TASK-03-01, D-041, REQ-NFR-AUTH-01).
|
||||
|
||||
Uses argon2-cffi PasswordHasher with defaults that exceed OWASP minimums
|
||||
(time_cost=3, memory_cost=64MiB, parallelism=4 — RESEARCH-v0.4 §2.1).
|
||||
Single operator, low-frequency logins → hashing latency < 1s is
|
||||
acceptable (R-AUTH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password with argon2id. Returns the encoded hash string."""
|
||||
return _ph.hash(plain)
|
||||
|
||||
|
||||
def verify_password(stored_hash: str, plain: str) -> bool:
|
||||
"""Verify a plaintext password against a stored argon2id hash.
|
||||
|
||||
Returns False on mismatch (no exception) so the login flow can apply a
|
||||
uniform 401 + rate-limit-increment path on any auth failure.
|
||||
"""
|
||||
try:
|
||||
_ph.verify(stored_hash, plain)
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
"""True if the stored hash was produced with weaker params than the
|
||||
current PasswordHasher defaults. The login flow rehashes + updates the
|
||||
store when this returns True (param upgrades without forcing a reset)."""
|
||||
return _ph.check_needs_rehash(stored_hash)
|
||||
|
||||
|
||||
__all__ = ["hash_password", "verify_password", "needs_rehash"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Login rate limiting (TASK-03-03, D-041).
|
||||
|
||||
slowapi Limiter with an in-memory backend (single-instance — D-041).
|
||||
5 login attempts per minute per client IP. On exceed → 429 + Retry-After.
|
||||
|
||||
R-AUTH-03 (in-memory counter lost on restart) is an accepted pilot risk
|
||||
(RESEARCH-v0.4 §2.5) — a restart at most resets the counter, which slightly
|
||||
widens the brute-force window but does not enable it (argon2id + 5/min is
|
||||
still the binding control). A hand-rolled counter is the documented
|
||||
fallback if slowapi is ever removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
|
||||
|
||||
|
||||
def reset_login_rate_limit() -> None:
|
||||
"""Clear the in-memory rate-limit counters (test helper + restart-safe)."""
|
||||
try:
|
||||
limiter.reset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def rate_limit_login():
|
||||
"""Decorator factory: 5 login attempts per minute per IP (D-041)."""
|
||||
return limiter.limit("5/minute")
|
||||
|
||||
|
||||
__all__ = ["limiter", "rate_limit_login", "reset_login_rate_limit"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Auth route handlers — login, logout, me (TASK-03-05, D-041, D-056, D-057).
|
||||
|
||||
APIRouter(prefix="/api/operator") with:
|
||||
POST /login — rate-limited 5/min (TASK-03-03), NOT auth-gated.
|
||||
POST /logout — auth-gated (Depends(current_operator)).
|
||||
GET /me — auth-gated (React route guard — D-057).
|
||||
|
||||
Stateless cookies (D-056): logout clears the server-side session; the
|
||||
client also clears its cookie. No sessions table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import rate_limit_login
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class OperatorOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str = "operator"
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
def _operator_out(op: Operator) -> OperatorOut:
|
||||
return OperatorOut(
|
||||
id=op.id,
|
||||
username=op.username,
|
||||
display_name=op.display_name,
|
||||
role=op.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@rate_limit_login()
|
||||
async def login(body: LoginBody, request: Request) -> LoginResponse:
|
||||
"""Rate-limited login (5/min per IP — D-041).
|
||||
|
||||
On success: sets `request.session["operator_id"]` (signed cookie via
|
||||
SessionMiddleware) + updates last_login_at. On needs_rehash → rehash +
|
||||
update the store. On failure → 401 (no cookie set).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
row = await pg_store.get_operator_by_username(body.username)
|
||||
if row is None or not row.get("is_active"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
if not verify_password(row["password_hash"], body.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
op_id = str(row["id"])
|
||||
request.session["operator_id"] = op_id
|
||||
await pg_store.update_last_login(op_id)
|
||||
if needs_rehash(row["password_hash"]):
|
||||
new_hash = hash_password(body.password)
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET password_hash = $1 WHERE id = $2",
|
||||
new_hash, op_id,
|
||||
)
|
||||
return LoginResponse(
|
||||
operator=OperatorOut(
|
||||
id=op_id,
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=OkResponse)
|
||||
async def logout(request: Request, op: Operator = Depends(current_operator)) -> OkResponse:
|
||||
# Stateless (D-056): clearing the server session invalidates the signed
|
||||
# cookie's payload; the client also clears its cookie.
|
||||
request.session.clear()
|
||||
return OkResponse(ok=True)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(op: Operator = Depends(current_operator)) -> MeResponse:
|
||||
"""React route guard endpoint (D-057). 200 → render; 401 → redirect."""
|
||||
return MeResponse(operator=_operator_out(op))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
+35
-11
@@ -13,6 +13,7 @@ import base64
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import nacl.secret
|
||||
import nacl.signing
|
||||
@@ -22,6 +23,27 @@ from db.store import PraxisStore
|
||||
_SECRETBOX_KEY_BYTES = nacl.secret.SecretBox.KEY_SIZE
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IssuerKeyStore(Protocol):
|
||||
"""Issuer key store protocol (D-051, TASK-04-01).
|
||||
|
||||
Both PraxisStore (SQLite, v0.3) and PgStore (Postgres, v0.4) implement
|
||||
this protocol — R-VC-MIG-03 mitigation (both stores share the same
|
||||
interface so verification can use either). The structural check lets
|
||||
`isinstance(store, IssuerKeyStore)` succeed for duck-typed stores.
|
||||
"""
|
||||
|
||||
async def init_issuer_key(
|
||||
self, key_id: str, public_key: str, private_key_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None: ...
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None: ...
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None: ...
|
||||
|
||||
|
||||
def _load_root_key() -> bytes:
|
||||
raw = os.environ.get("PRAXIS_VC_ISSUER_KEY", "")
|
||||
if raw:
|
||||
@@ -63,7 +85,7 @@ def _decrypt_private_key(private_key_enc: bytes, root_key: bytes) -> nacl.signin
|
||||
return nacl.signing.SigningKey(seed)
|
||||
|
||||
|
||||
async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair:
|
||||
async def init_issuer_key(store: IssuerKeyStore, root_key: bytes | None = None) -> KeyPair:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
@@ -75,7 +97,7 @@ async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) ->
|
||||
|
||||
|
||||
async def get_active_signing_key(
|
||||
store: PraxisStore, root_key: bytes | None = None
|
||||
store: IssuerKeyStore, root_key: bytes | None = None
|
||||
) -> tuple[KeyPair, bytes]:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
row = await store.get_active_signing_key_row()
|
||||
@@ -89,18 +111,19 @@ async def get_active_signing_key(
|
||||
return kp, row["private_key_enc"]
|
||||
|
||||
|
||||
async def _fetch_private_key_enc(store: PraxisStore, key_id: str) -> bytes:
|
||||
async with store._connect() as db:
|
||||
db.row_factory = None
|
||||
cur = await db.execute(
|
||||
"SELECT private_key_enc FROM issuer_keys WHERE id = ?", (key_id,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return bytes(row[0]) if row else b""
|
||||
async def _fetch_private_key_enc(store: IssuerKeyStore, key_id: str) -> bytes:
|
||||
# PraxisStore exposes a _connect() context manager; PgStore does not
|
||||
# (it uses a pool). Use the protocol's get_public_key_row which both
|
||||
# stores implement, and read private_key_enc from the returned row.
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
return b""
|
||||
enc = row.get("private_key_enc")
|
||||
return bytes(enc) if enc is not None else b""
|
||||
|
||||
|
||||
async def get_public_key_for_verification(
|
||||
store: PraxisStore, key_id: str
|
||||
store: IssuerKeyStore, key_id: str
|
||||
) -> nacl.signing.VerifyKey:
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
@@ -119,6 +142,7 @@ async def rotate_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPa
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IssuerKeyStore",
|
||||
"KeyPair",
|
||||
"init_issuer_key",
|
||||
"get_active_signing_key",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""VC issuer key migration SQLite → Postgres (TASK-04-03, D-051).
|
||||
|
||||
One-time migration procedure (R-VC-MIG-01 — highest-severity v0.4 risk):
|
||||
1. Read the v0.3 active public key from SQLite issuer_keys.
|
||||
2. Insert that public key into Postgres issuer_keys with status=
|
||||
'superseded' (private key NOT migrated — only the public key is
|
||||
archived for verification of already-issued v0.3 VCs).
|
||||
3. Generate a fresh Ed25519 keypair in Postgres issuer_keys with
|
||||
status='active' (encrypted at rest with the root key).
|
||||
4. Return {archived_key_id, new_key_id}.
|
||||
|
||||
R-VC-MIG-01 mitigation: the v0.3 public key is archived as superseded
|
||||
BEFORE the fresh key is activated (step 2 before step 3). This guarantees
|
||||
v0.3 VCs remain verifiable against the archived key.
|
||||
|
||||
G-027 (first-boot path): if SQLite has NO v0.3 active key (fresh deploy),
|
||||
skip the archive step and only generate the fresh v0.4 keypair.
|
||||
|
||||
Idempotent: if Postgres already has an active key, the whole procedure is
|
||||
a no-op. If Postgres already has a superseded key matching the v0.3 key_id,
|
||||
skip step 2 (already archived) but still generate the fresh key if no
|
||||
active key exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import nacl.signing
|
||||
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.vc.issuer_keys import _encrypt_private_key
|
||||
|
||||
|
||||
async def _archive_v03_public_key(
|
||||
pg_store: PgStore, v03_key_id: str, v03_public_key: str
|
||||
) -> None:
|
||||
"""Insert the v0.3 public key into Postgres as superseded (idempotent)."""
|
||||
existing = await pg_store.get_public_key_row(v03_key_id)
|
||||
if existing is not None:
|
||||
return # already archived (or present as active — leave as-is)
|
||||
await pg_store.init_issuer_key(v03_key_id, v03_public_key, b"")
|
||||
await pg_store.set_issuer_key_superseded(v03_key_id)
|
||||
|
||||
|
||||
async def _generate_fresh_v04_key(
|
||||
pg_store: PgStore, root_key: bytes
|
||||
) -> str:
|
||||
"""Generate a fresh Ed25519 keypair in Postgres as active. Returns key_id."""
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
public_key_b64 = base64.b64encode(bytes(verify_key)).decode("ascii")
|
||||
private_key_enc = _encrypt_private_key(signing_key, root_key)
|
||||
key_id = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await pg_store.init_issuer_key(key_id, public_key_b64, private_key_enc)
|
||||
return key_id
|
||||
|
||||
|
||||
async def migrate_issuer_keys(
|
||||
sqlite_store: PraxisStore,
|
||||
pg_store: PgStore,
|
||||
root_key: bytes,
|
||||
) -> dict[str, str | None]:
|
||||
"""Run the one-time VC key migration. Idempotent.
|
||||
|
||||
Returns {"archived_key_id": str | None, "new_key_id": str | None}.
|
||||
archived_key_id is None on the G-027 first-boot path (no v0.3 key).
|
||||
new_key_id is None if an active key already existed (no-op).
|
||||
"""
|
||||
# If Postgres already has an active key, the whole migration is done.
|
||||
active = await pg_store.get_active_signing_key_row()
|
||||
if active is not None:
|
||||
return {"archived_key_id": None, "new_key_id": None}
|
||||
|
||||
# Step 1 (G-027): read v0.3 active public key from SQLite. May be None
|
||||
# on a fresh deploy with no v0.3 history.
|
||||
v03_row = await sqlite_store.get_active_signing_key_row()
|
||||
archived_key_id: str | None = None
|
||||
if v03_row is not None:
|
||||
v03_key_id = v03_row["id"]
|
||||
v03_public_key = v03_row["public_key"]
|
||||
# Step 2 (R-VC-MIG-01): archive BEFORE activating the fresh key.
|
||||
await _archive_v03_public_key(pg_store, v03_key_id, v03_public_key)
|
||||
archived_key_id = v03_key_id
|
||||
|
||||
# Step 3: generate the fresh v0.4 keypair as active.
|
||||
new_key_id = await _generate_fresh_v04_key(pg_store, root_key)
|
||||
return {"archived_key_id": archived_key_id, "new_key_id": new_key_id}
|
||||
|
||||
|
||||
__all__ = ["migrate_issuer_keys"]
|
||||
+86
-16
@@ -1,11 +1,24 @@
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02).
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02;
|
||||
v0.4 TASK-04-04 two-store fallback per G-011).
|
||||
|
||||
`GET /vc/verify/<credential_id>` — public, unauthenticated. Fetches the
|
||||
credential from SQLite, fetches the issuer public key, validates the Ed25519
|
||||
signature against the JCS-canonicalized payload, checks the Bitstring Status
|
||||
List (no cache — fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
credential + issuer public key, validates the Ed25519 signature against
|
||||
the JCS-canonicalized payload, checks the Bitstring Status List (no cache
|
||||
— fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
{valid, status, issuer, credential, mastery, credentialTier, verifiedAt}.
|
||||
No PII beyond what the credential asserts.
|
||||
|
||||
G-011 two-store fallback semantics (binding contract):
|
||||
(a) If Postgres is available (pg_store is not None), use it for issuer
|
||||
key lookup (both active AND superseded keys — get_public_key_row
|
||||
queries by id, not status).
|
||||
(b) If Postgres is available but the credential is not found in its
|
||||
issued_credentials table, fall back to SQLite issued_credentials
|
||||
(v0.3 credentials remain in SQLite — D-051 "no re-issuance").
|
||||
(c) If Postgres is NOT available (pg_store is None), use the existing
|
||||
v0.3 SQLite path for BOTH keys and credentials (full v0.3 compat).
|
||||
The key store used for verification is always the one that holds the key
|
||||
row found by key_id; the credential store is whichever store had the row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +30,7 @@ from typing import Any
|
||||
from db.store import PraxisStore
|
||||
|
||||
from server.vc.issuer import verify_proof, extract_key_id, CREDENTIAL_TIER
|
||||
from server.vc.issuer_keys import get_public_key_for_verification
|
||||
from server.vc.issuer_keys import IssuerKeyStore, get_public_key_for_verification
|
||||
from server.vc.status_list import BitstringStatusList
|
||||
|
||||
|
||||
@@ -26,26 +39,35 @@ def _now_iso() -> str:
|
||||
|
||||
|
||||
async def verify_credential(
|
||||
store: PraxisStore, credential_id: str
|
||||
store: IssuerKeyStore,
|
||||
credential_id: str,
|
||||
*,
|
||||
pg_store: IssuerKeyStore | None = None,
|
||||
sqlite_store: PraxisStore | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = await store.get_credential(credential_id)
|
||||
"""Verify a VC. Returns the verification result dict, or None if the
|
||||
credential id is not found in any store.
|
||||
|
||||
Per G-011:
|
||||
- If pg_store is provided, try it first for BOTH credential + key
|
||||
lookup; fall back to sqlite_store for the credential if Postgres
|
||||
doesn't have it (v0.3 credentials stay in SQLite).
|
||||
- If pg_store is None, use `store` (the v0.3 SQLite path) for both.
|
||||
"""
|
||||
row = await _lookup_credential(credential_id, store, pg_store, sqlite_store)
|
||||
if row is None:
|
||||
return None
|
||||
secured_doc = json.loads(row["vc_payload_json"])
|
||||
key_id = extract_key_id(secured_doc)
|
||||
if key_id is None:
|
||||
return _invalid(row, secured_doc)
|
||||
try:
|
||||
verify_key = await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
# Key lookup: prefer pg_store (G-011a) for v0.4 keys + archived v0.3
|
||||
# keys; fall back to `store` (SQLite) if pg_store doesn't have the key.
|
||||
verify_key = await _lookup_public_key(key_id, store, pg_store)
|
||||
if verify_key is None:
|
||||
return _invalid(row, secured_doc)
|
||||
sig_valid = verify_proof(secured_doc, verify_key)
|
||||
revoked = False
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is not None:
|
||||
sl = BitstringStatusList(store, "default")
|
||||
revoked = await sl.get_status(int(idx_str))
|
||||
revoked = await _check_revocation(secured_doc, store, sqlite_store or store)
|
||||
status = "revoked" if revoked else "active"
|
||||
valid = bool(sig_valid and not revoked)
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
@@ -73,6 +95,54 @@ async def verify_credential(
|
||||
}
|
||||
|
||||
|
||||
async def _lookup_credential(
|
||||
credential_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
sqlite_store: PraxisStore | None,
|
||||
) -> dict | None:
|
||||
"""G-011(b): try Postgres first, fall back to SQLite for v0.3 creds."""
|
||||
if pg_store is not None:
|
||||
row = await pg_store.get_credential(credential_id)
|
||||
if row is not None:
|
||||
return row
|
||||
if sqlite_store is not None:
|
||||
return await sqlite_store.get_credential(credential_id)
|
||||
return None
|
||||
# G-011(c): no Postgres — v0.3 SQLite path.
|
||||
return await store.get_credential(credential_id)
|
||||
|
||||
|
||||
async def _lookup_public_key(
|
||||
key_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
):
|
||||
"""G-011(a): prefer Postgres for key lookup (finds active + superseded);
|
||||
fall back to `store` (SQLite) if Postgres doesn't have the key."""
|
||||
if pg_store is not None:
|
||||
try:
|
||||
vk = await get_public_key_for_verification(pg_store, key_id)
|
||||
return vk
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
async def _check_revocation(
|
||||
secured_doc: dict, store: IssuerKeyStore, status_store: PraxisStore
|
||||
) -> bool:
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is None:
|
||||
return False
|
||||
sl = BitstringStatusList(status_store, "default")
|
||||
return await sl.get_status(int(idx_str))
|
||||
|
||||
|
||||
def _invalid(row: dict, secured_doc: dict) -> dict[str, Any]:
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: password hash/verify/rehash, cookie config (secure flag, missing
|
||||
secret), rate limiter threshold, current_operator dependency (401/503
|
||||
cases, active/inactive), login/logout/me route handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit
|
||||
from server.auth.routes import router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_limiter():
|
||||
reset_login_rate_limit()
|
||||
yield
|
||||
reset_login_rate_limit()
|
||||
|
||||
|
||||
# ── Passwords ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_password_hash_verify_roundtrip():
|
||||
h = hash_password("correct horse battery staple")
|
||||
assert h.startswith("$argon2id$")
|
||||
assert verify_password(h, "correct horse battery staple") is True
|
||||
|
||||
|
||||
def test_password_verify_wrong_returns_false():
|
||||
h = hash_password("secret-1")
|
||||
assert verify_password(h, "secret-2") is False
|
||||
# no exception raised — uniform 401 path
|
||||
assert verify_password(h, "") is False
|
||||
|
||||
|
||||
def test_needs_rehash_false_for_current_defaults():
|
||||
h = hash_password("x")
|
||||
assert needs_rehash(h) is False
|
||||
|
||||
|
||||
def test_needs_rehash_true_for_weak_hash():
|
||||
# A hash produced with weaker params triggers rehash.
|
||||
from argon2 import PasswordHasher
|
||||
weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x")
|
||||
assert needs_rehash(weak) is True
|
||||
|
||||
|
||||
# ── Cookie config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cookie_kwargs_defaults(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["session_cookie"] == "praxis_op"
|
||||
assert kw["max_age"] == 28800
|
||||
# Starlette SessionMiddleware: https_only (not secure), same_site (not samesite),
|
||||
# httponly is always True (no kwarg). path is the cookie path.
|
||||
assert kw["https_only"] is True
|
||||
assert kw["same_site"] == "strict"
|
||||
assert kw["path"] == "/"
|
||||
|
||||
|
||||
def test_cookie_secure_false(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["https_only"] is False
|
||||
|
||||
|
||||
def test_cookie_secret_unset_generates_random(monkeypatch):
|
||||
monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False)
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["secret_key"]
|
||||
assert len(kw["secret_key"]) >= 32
|
||||
|
||||
|
||||
# ── current_operator dependency ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app_with_store(store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.pg_store = store
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _mock_store(operator_row=None):
|
||||
store = MagicMock()
|
||||
store.get_operator_by_id = AsyncMock(return_value=operator_row)
|
||||
return store
|
||||
|
||||
|
||||
def test_current_operator_no_cookie_401():
|
||||
app = _make_app_with_store(_mock_store(operator_row=None))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_current_operator_inactive_401():
|
||||
op = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "ghost",
|
||||
"display_name": "Ghost",
|
||||
"role": "operator",
|
||||
"is_active": False,
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
# seed a session by hitting login would need a real store; instead
|
||||
# set the session directly via a cookie. Use TestClient's cookie jar.
|
||||
# Easiest: POST /login with a mocked store that returns the op.
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
# hash the password so verify works
|
||||
op = dict(op)
|
||||
op["password_hash"] = hash_password("pw")
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"})
|
||||
# inactive operator → 401 even with correct password
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_valid_cookie_returns_operator():
|
||||
op = {
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["operator"]["username"] == "alice"
|
||||
# cookie is now set; /me should work
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == "alice"
|
||||
|
||||
|
||||
# ── Login route ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_wrong_password_401_no_cookie():
|
||||
op = {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"username": "bob",
|
||||
"display_name": None,
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("correct"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
# no auth cookie set on failure
|
||||
cookies = client.cookies.get("praxis_op")
|
||||
assert not cookies
|
||||
|
||||
|
||||
def test_login_unknown_user_401():
|
||||
store = _mock_store(operator_row=None)
|
||||
store.get_operator_by_username = AsyncMock(return_value=None)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
# ── Logout ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_logout_clears_session():
|
||||
op = {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"username": "carol",
|
||||
"display_name": "Carol",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/operator/login", json={"username": "carol", "password": "pw"})
|
||||
r = client.post("/api/operator/logout")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
# /me now 401
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
# ── Rehash on login ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_rehash_when_needed():
|
||||
from argon2 import PasswordHasher
|
||||
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
|
||||
op = {
|
||||
"id": "55555555-5555-5555-5555-555555555555",
|
||||
"username": "dave",
|
||||
"display_name": "Dave",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": weak_hasher.hash("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
executed = []
|
||||
conn = MagicMock()
|
||||
async def _exec(*a, **kw):
|
||||
executed.append(a)
|
||||
conn.execute = _exec
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert executed, "rehash UPDATE should have run"
|
||||
# the second arg to execute is the new hash; verify it's argon2id
|
||||
assert executed[0][1].startswith("$argon2id$")
|
||||
|
||||
|
||||
# ── Rate limiter ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rate_limit_login_decorator():
|
||||
# The decorator factory returns a decorator; applying it should not raise.
|
||||
deco = rate_limit_login()
|
||||
assert callable(deco)
|
||||
|
||||
|
||||
def test_limiter_is_in_memory():
|
||||
assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Backup-restore drill test (G-008 binding — MUST run at least once in
|
||||
staging/CI to prove the nightly pg_dump backup is valid).
|
||||
|
||||
The drill:
|
||||
1. Seed the live Postgres with known row counts in all 5 operator-tier
|
||||
tables (operators, issued_credentials, mastery_gate_events,
|
||||
cohort_aggregates, issuer_keys).
|
||||
2. Run `pg_dump -Fc` to produce a compressed dump.
|
||||
3. Drop + recreate the schema (simulate a disaster), then run
|
||||
`pg_restore --clean --if-exists`.
|
||||
4. Verify all 5 tables exist and the row counts match the seeded values.
|
||||
|
||||
Skips gracefully when PRAXIS_PG_DSN is unset (no Postgres in dev/CI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — backup-restore drill skipped (G-008).",
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"operators",
|
||||
"issued_credentials",
|
||||
"mastery_gate_events",
|
||||
"cohort_aggregates",
|
||||
"issuer_keys",
|
||||
}
|
||||
|
||||
|
||||
async def _seed(store: PgStore, pool: asyncpg.Pool) -> dict[str, int]:
|
||||
"""Seed all 5 tables; return {table: row_count}."""
|
||||
oid = await store.insert_operator("drill-op", "$argon2id$h", "Drill Op")
|
||||
assert oid is not None
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-drill", b"\x01\x02")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-drill", "{}", "sig", operator_id=oid)
|
||||
await store.record_gate_event(
|
||||
"learner-drill", "cs-refund", scenario_id="sc-1", gate_outcome="open"
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", date(2026, 8, 1), date(2026, 8, 7),
|
||||
5.0, 12, False,
|
||||
)
|
||||
counts = {}
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
counts[t] = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
return counts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_drill(tmp_path):
|
||||
dsn = os.environ["PRAXIS_PG_DSN"]
|
||||
dump_file = tmp_path / "praxis-drill.dump"
|
||||
|
||||
pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=5, command_timeout=10)
|
||||
try:
|
||||
await apply_pg_migrations(pool)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
store = PgStore(pool)
|
||||
seeded_counts = await _seed(store, pool)
|
||||
|
||||
# 1. pg_dump -Fc to a local file (via psql host or docker).
|
||||
# Use pg_dump directly if available on PATH; otherwise fall back to
|
||||
# docker compose exec (the operator deployment path).
|
||||
rc = subprocess.run(
|
||||
["pg_dump", "-Fc", "-f", str(dump_file), dsn],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
# Try docker compose path (production-like).
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_dump", "-U", "praxis", "-Fc", "praxis"],
|
||||
capture_output=True,
|
||||
)
|
||||
assert rc.returncode == 0, f"pg_dump failed: {rc.stderr!r}"
|
||||
dump_file.write_bytes(rc.stdout)
|
||||
assert dump_file.stat().st_size > 0, "dump file is empty"
|
||||
|
||||
# 2. Drop the schema (simulate disaster).
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
await conn.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE')
|
||||
await conn.execute("DROP TABLE IF EXISTS _pg_migrations CASCADE")
|
||||
|
||||
# 3. pg_restore --clean --if-exists from the dump file.
|
||||
rc = subprocess.run(
|
||||
["pg_restore", "--clean", "--if-exists", "-d", dsn, str(dump_file)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_restore", "-U", "praxis", "--clean", "--if-exists",
|
||||
"-d", "praxis", "/backups/praxis-drill.dump"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
# If we used the docker path, we have to copy the dump in first;
|
||||
# for the local-pg_dump path this branch is skipped. Either way,
|
||||
# a non-zero return here means restore failed.
|
||||
assert rc.returncode == 0, f"pg_restore failed: {rc.stderr!r}"
|
||||
|
||||
# 4. Verify all 5 tables exist + row counts match.
|
||||
async with pool.acquire() as conn:
|
||||
tables = {
|
||||
r["tablename"] for r in await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public'"
|
||||
)
|
||||
}
|
||||
assert EXPECTED_TABLES.issubset(tables), (
|
||||
f"missing tables after restore: {EXPECTED_TABLES - tables}"
|
||||
)
|
||||
for t in EXPECTED_TABLES:
|
||||
count = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
assert count == seeded_counts[t], (
|
||||
f"{t}: restored count {count} != seeded {seeded_counts[t]}"
|
||||
)
|
||||
finally:
|
||||
await pool.close()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Bootstrap CLI test (TASK-05-03) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: create operator → exists; re-run → "already exists" (no update);
|
||||
--update → password updated; missing env → exit 1; password is argon2id
|
||||
(not plaintext).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_cli_module(monkeypatch, env: dict, update: bool = False):
|
||||
"""Load scripts/create-operator.py as a module with a mocked asyncpg pool."""
|
||||
for k in ("PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS",
|
||||
"PRAXIS_PG_DSN"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(k, v)
|
||||
# Import the script as a module by path.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"create_operator", "scripts/create-operator.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_mock_pool_store(operators: dict[str, dict] | None = None):
|
||||
operators = operators if operators is not None else {}
|
||||
|
||||
pool = MagicMock()
|
||||
pool.close = AsyncMock()
|
||||
|
||||
conn = MagicMock()
|
||||
|
||||
async def acquire_ctx():
|
||||
return conn
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
pool.acquire = MagicMock(return_value=cm)
|
||||
|
||||
store = MagicMock()
|
||||
|
||||
async def insert_operator(username, password_hash, display_name, *, on_conflict_update=False):
|
||||
if on_conflict_update:
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
if username in operators:
|
||||
return None # already exists
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
|
||||
store.insert_operator = insert_operator
|
||||
return pool, store, operators
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_creates(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store()
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations as _apm
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return ["0001_operator_tier"]
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "created" in out
|
||||
assert "admin" in operators
|
||||
h = operators["admin"]["password_hash"]
|
||||
assert h.startswith("$argon2id$")
|
||||
assert "secret-pw" not in h # not plaintext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_already_exists(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out
|
||||
# password NOT updated
|
||||
assert operators["admin"]["password_hash"] == "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_update_rehashes(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "new-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=True)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "updated" in out
|
||||
assert operators["admin"]["password_hash"].startswith("$argon2id$")
|
||||
assert operators["admin"]["password_hash"] != "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_user_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_BOOTSTRAP_OPERATOR_USER" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_pass_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_dsn(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_PG_DSN" in err
|
||||
|
||||
|
||||
def test_password_hash_is_argon2id():
|
||||
from argon2 import PasswordHasher
|
||||
h = PasswordHasher().hash("test")
|
||||
assert h.startswith("$argon2id$")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""P1 auth integration test (TASK-06-04) — end-to-end with Postgres.
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset. Tests the full auth flow through the FastAPI app (TestClient with
|
||||
the real lifespan): create operator via the bootstrap CLI → POST /login →
|
||||
GET /me → POST /logout → GET /me (401). Rate limiting, cookie attributes,
|
||||
and learner-voice-loop-unaffected verification (REQ-NFR-MT-01).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — P1 auth integration tests skipped.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def _started_app():
|
||||
"""Start the real FastAPI app with the lifespan (creates the pool +
|
||||
applies migrations + runs VC key migration)."""
|
||||
import asyncio
|
||||
import server.__main__ as m
|
||||
# Ensure the SQLite store is initialized (v0.3 path).
|
||||
await m._store.init()
|
||||
# Use a unique operator username per run to avoid collisions.
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
with TestClient(m.app) as client:
|
||||
yield client, suffix, m
|
||||
|
||||
|
||||
def test_full_auth_flow(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"intop-{suffix}"
|
||||
pw = "integration-pw-123"
|
||||
# Create operator via the store directly (bootstrap CLI path is
|
||||
# covered in test_create_operator.py; here we exercise the HTTP flow).
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
|
||||
# POST /login
|
||||
r = client.post("/api/operator/login", json={"username": username, "password": pw})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["operator"]["username"] == username
|
||||
# Cookie set
|
||||
cookie = client.cookies.get("praxis_op")
|
||||
assert cookie, "praxis_op cookie should be set after login"
|
||||
|
||||
# GET /me
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == username
|
||||
|
||||
# POST /logout
|
||||
r3 = client.post("/api/operator/logout")
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["ok"] is True
|
||||
|
||||
# GET /me after logout → 401
|
||||
r4 = client.get("/api/operator/me")
|
||||
assert r4.status_code == 401
|
||||
|
||||
|
||||
def test_me_without_cookie_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
# Use a fresh client (no cookie jar sharing).
|
||||
import server.__main__ as m
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_wrong_password_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"wrong-{suffix}"
|
||||
pw = "correct-pw"
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
import server.__main__ as m
|
||||
from server.auth.rate_limit import reset_login_rate_limit
|
||||
reset_login_rate_limit()
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.post("/api/operator/login", json={"username": username, "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_learner_voice_loop_unaffected(_started_app):
|
||||
"""REQ-NFR-MT-01 — Postgres presence does not destabilize the learner
|
||||
voice loop (/health works regardless of Postgres state)."""
|
||||
client, suffix, m = _started_app
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""VC migration e2e test (TASK-06-05, R-VC-MIG-01 — CRITICAL).
|
||||
|
||||
The highest-severity v0.4 risk: a v0.3 VC MUST verify against a Postgres
|
||||
store with the v0.3 public key archived as superseded. This test seeds
|
||||
SQLite with a v0.3 issuer key + credential, runs the migration, and
|
||||
verifies through the HTTP endpoint.
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — VC migration e2e test skipped (R-VC-MIG-01).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _e2e_env(tmp_path, monkeypatch):
|
||||
"""Set up a fresh SQLite store + Postgres pool + run migration."""
|
||||
import server.__main__ as m
|
||||
from db.store import PraxisStore
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
from server.vc.issuer import build_vc_payload, sign, issue_credential
|
||||
from server.vc.issuer_keys import init_issuer_key, _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
|
||||
# Fresh SQLite store in a temp dir.
|
||||
sqlite_path = tmp_path / "praxis-e2e.db"
|
||||
monkeypatch.setenv("PRAXIS_DB_PATH", str(sqlite_path))
|
||||
sqlite_store = PraxisStore(str(sqlite_path))
|
||||
await sqlite_store.init()
|
||||
|
||||
# Seed SQLite with a v0.3 issuer key + a v0.3-issued credential.
|
||||
root_key = _load_root_key()
|
||||
v03_kp = await init_issuer_key(sqlite_store, root_key)
|
||||
v03_cred_id = await issue_credential(
|
||||
sqlite_store,
|
||||
signing_key=v03_kp.signing_key,
|
||||
key_id=v03_kp.key_id,
|
||||
learner_id="learner-e2e-v03",
|
||||
path="cs-refund",
|
||||
scenarios_passed=["sc-1"],
|
||||
rubric_score=4.0,
|
||||
completed_weeks=6,
|
||||
evidence=[],
|
||||
)
|
||||
|
||||
# Connect to Postgres + apply migrations + clean tables.
|
||||
import asyncpg
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=3, command_timeout=10
|
||||
)
|
||||
await apply_pg_migrations(pool)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
|
||||
pg_store = PgStore(pool)
|
||||
|
||||
yield {
|
||||
"sqlite_store": sqlite_store,
|
||||
"pg_store": pg_store,
|
||||
"pool": pool,
|
||||
"v03_kp": v03_kp,
|
||||
"v03_cred_id": v03_cred_id,
|
||||
"root_key": root_key,
|
||||
}
|
||||
|
||||
await pool.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v03_vc_verifies_after_migration(_e2e_env):
|
||||
"""R-VC-MIG-01: v0.3 VC verifies against Postgres with archived key."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
# Run the migration.
|
||||
result = await migrate_issuer_keys(
|
||||
env["sqlite_store"], env["pg_store"], env["root_key"]
|
||||
)
|
||||
assert result["archived_key_id"] == env["v03_kp"].key_id
|
||||
assert result["new_key_id"] is not None
|
||||
|
||||
# Verify Postgres has 1 superseded + 1 active key.
|
||||
active = await env["pg_store"].get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
archived = await env["pg_store"].get_public_key_row(env["v03_kp"].key_id)
|
||||
assert archived is not None
|
||||
assert archived["status"] == "superseded"
|
||||
|
||||
# R-VC-MIG-01 CRITICAL: verify the v0.3 credential through the
|
||||
# two-store path (G-011: credential in SQLite, key in Postgres).
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], env["v03_cred_id"],
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is True, (
|
||||
"R-VC-MIG-01 FAIL: v0.3 VC did not verify against archived superseded key"
|
||||
)
|
||||
assert res["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent_e2e(_e2e_env):
|
||||
"""Re-running the migration is a no-op."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
result = await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g027_first_boot_no_v03_key(_e2e_env):
|
||||
"""G-027: fresh deploy with no v0.3 key → skip archive, fresh key only."""
|
||||
env = _e2e_env
|
||||
# Use a fresh SQLite store with NO v0.3 key.
|
||||
from db.store import PraxisStore
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
import tempfile
|
||||
fresh_path = Path(tempfile.mkdtemp()) / "fresh.db"
|
||||
fresh_store = PraxisStore(str(fresh_path))
|
||||
await fresh_store.init()
|
||||
result = await migrate_issuer_keys(fresh_store, env["pg_store"], env["root_key"])
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v04_vc_verifies_after_migration(_e2e_env):
|
||||
"""A newly-issued v0.4 VC verifies against the active key in Postgres."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
from server.vc.issuer import issue_credential
|
||||
from server.vc.issuer_keys import get_active_signing_key
|
||||
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
# Issue a v0.4 credential using the active Postgres key.
|
||||
kp, _enc = await get_active_signing_key(env["pg_store"], env["root_key"])
|
||||
v04_cred_id = await issue_credential(
|
||||
env["sqlite_store"],
|
||||
signing_key=kp.signing_key,
|
||||
key_id=kp.key_id,
|
||||
learner_id="learner-e2e-v04",
|
||||
path="cs-refund",
|
||||
scenarios_passed=["sc-1", "sc-2"],
|
||||
rubric_score=4.5,
|
||||
completed_weeks=6,
|
||||
evidence=[],
|
||||
)
|
||||
# The credential is in SQLite; the key is in Postgres. Verify via the
|
||||
# two-store path.
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], v04_cred_id,
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_v03_vc_fails_e2e(_e2e_env):
|
||||
"""Tamper detection: a modified v0.3 credential fails verification."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
# Fetch the v0.3 credential and tamper with its payload.
|
||||
row = await env["sqlite_store"].get_credential(env["v03_cred_id"])
|
||||
assert row is not None
|
||||
doc = json.loads(row["vc_payload_json"])
|
||||
doc["credentialSubject"]["rubricScore"] = 1.0 # tamper
|
||||
await env["sqlite_store"].set_credential_status(env["v03_cred_id"], "active")
|
||||
# Overwrite the payload in SQLite with the tampered version.
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(env["sqlite_store"].db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE issued_credentials SET vc_payload_json = ? WHERE id = ?",
|
||||
(json.dumps(doc, sort_keys=True, separators=(",", ":")), env["v03_cred_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], env["v03_cred_id"],
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is False
|
||||
@@ -0,0 +1,221 @@
|
||||
"""PgStore + asyncpg pool integration test (TASK-01-07).
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset so the test suite has no hard CI dependency on Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — Postgres integration tests skipped (dev mode).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def pool() -> asyncpg.Pool:
|
||||
p = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"],
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=10,
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(p)
|
||||
yield p
|
||||
finally:
|
||||
await p.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
tables = await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' "
|
||||
"ORDER BY tablename"
|
||||
)
|
||||
names = {r["tablename"] for r in tables}
|
||||
assert {"operators", "issued_credentials", "mastery_gate_events",
|
||||
"cohort_aggregates", "issuer_keys"}.issubset(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent(pool: asyncpg.Pool):
|
||||
applied = await apply_pg_migrations(pool)
|
||||
assert applied == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_and_lookup(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator(
|
||||
"alice", "$argon2id$fakehash", "Alice"
|
||||
)
|
||||
assert oid is not None
|
||||
op = await store.get_operator_by_username("alice")
|
||||
assert op is not None
|
||||
assert op["username"] == "alice"
|
||||
assert op["display_name"] == "Alice"
|
||||
assert op["is_active"] is True
|
||||
by_id = await store.get_operator_by_id(oid)
|
||||
assert by_id is not None
|
||||
assert by_id["id"] == op["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
first = await store.insert_operator("bob", "$argon2id$h1", "Bob")
|
||||
assert first is not None
|
||||
second = await store.insert_operator("bob", "$argon2id$h2", "Bob")
|
||||
assert second is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_on_conflict_update(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
await store.insert_operator("carol", "$argon2id$old", "Carol")
|
||||
updated = await store.insert_operator(
|
||||
"carol", "$argon2id$new", "Carol", on_conflict_update=True
|
||||
)
|
||||
assert updated is not None
|
||||
op = await store.get_operator_by_username("carol")
|
||||
assert op["password_hash"] == "$argon2id$new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_last_login(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("dave", "$argon2id$h", "Dave")
|
||||
assert oid is not None
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is None
|
||||
await store.update_last_login(oid)
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_upsert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "sessions_count", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["value"] == 42.0
|
||||
assert rows[0]["cell_count"] == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_suppressed_cell(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "active_learners", ws, we, None, 9, True
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "active_learners", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["cell_suppressed"] is True
|
||||
assert rows[0]["value"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issuer_key_init_active_then_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-aaa", b"\x01\x02\x03")
|
||||
active = await store.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == kid
|
||||
assert active["status"] == "active"
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
assert await store.get_active_signing_key_row() is None
|
||||
archived = await store.get_public_key_row(kid)
|
||||
assert archived is not None
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-b64-aaa"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_public_key_row_finds_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-bbb", b"\x04\x05")
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
row = await store.get_public_key_row(kid)
|
||||
assert row is not None
|
||||
assert row["status"] == "superseded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_insert_and_get(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("ed", "$argon2id$h", "Ed")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(
|
||||
cid, "learner-1", '{"id":"vc-x"}', "sig-b64",
|
||||
operator_id=oid,
|
||||
)
|
||||
row = await store.get_credential(cid)
|
||||
assert row is not None
|
||||
assert row["id"] == cid
|
||||
assert row["learner_ref"] == "learner-1"
|
||||
assert row["signature_b64"] == "sig-b64"
|
||||
assert row["status"] == "active"
|
||||
assert row["vc_payload_json"] == '{"id":"vc-x"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_status_revoke(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-2", "{}", "sig")
|
||||
await store.set_credential_status(cid, "revoked")
|
||||
row = await store.get_credential(cid)
|
||||
assert row["status"] == "revoked"
|
||||
assert row["revoked_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gate_event(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
eid = await store.record_gate_event(
|
||||
"learner-3", "cs-refund", scenario_id="sc-1",
|
||||
gate_outcome="open", rubric_scores_jsonb=[{"c": "x", "l": 4}],
|
||||
)
|
||||
assert eid is not None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM mastery_gate_events WHERE id = $1", eid
|
||||
)
|
||||
assert row is not None
|
||||
assert row["learner_ref"] == "learner-3"
|
||||
assert row["gate_outcome"] == "open"
|
||||
assert row["source"] == "sync"
|
||||
@@ -0,0 +1,354 @@
|
||||
"""VC migration unit tests (TASK-04-05) — mocked stores.
|
||||
|
||||
Covers:
|
||||
- Migration script: v0.3 key archived as superseded, fresh key active,
|
||||
idempotent re-run.
|
||||
- G-027 first-boot path: no v0.3 active key in SQLite → skip archive,
|
||||
generate fresh key only.
|
||||
- Verification with PgStore: v0.4 VC (active key) verifies ✓; v0.3 VC
|
||||
(superseded key) verifies ✓ (R-VC-MIG-01 — the critical test).
|
||||
- get_public_key_row finds superseded key by id (verification fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import nacl.signing
|
||||
import pytest
|
||||
|
||||
from server.vc.issuer import build_vc_payload, sign, extract_key_id, verify_proof
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
|
||||
def _b64(b: bytes) -> str:
|
||||
return base64.b64encode(b).decode("ascii")
|
||||
|
||||
|
||||
# ── Migration script ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_sqlite_store_with_v03_key(key_id="v03-key-aaa", public_key="pub-v03-b64"):
|
||||
store = MagicMock()
|
||||
store.get_active_signing_key_row = AsyncMock(
|
||||
return_value={"id": key_id, "public_key": public_key, "private_key_enc": b"\x01"}
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def _make_pg_store():
|
||||
store = MagicMock()
|
||||
store._rows = {} # key_id -> row dict
|
||||
store._active = None
|
||||
|
||||
async def init_issuer_key(key_id, public_key, private_key_enc):
|
||||
status = "active"
|
||||
if key_id in store._rows:
|
||||
# ON CONFLICT DO NOTHING — don't overwrite
|
||||
return
|
||||
store._rows[key_id] = {
|
||||
"id": key_id, "public_key": public_key,
|
||||
"private_key_enc": private_key_enc, "status": status,
|
||||
}
|
||||
store._active = key_id
|
||||
|
||||
async def get_active_signing_key_row():
|
||||
if store._active is None:
|
||||
return None
|
||||
return dict(store._rows[store._active])
|
||||
|
||||
async def get_public_key_row(key_id):
|
||||
r = store._rows.get(key_id)
|
||||
return dict(r) if r else None
|
||||
|
||||
async def set_issuer_key_superseded(key_id):
|
||||
if key_id in store._rows:
|
||||
store._rows[key_id]["status"] = "superseded"
|
||||
if store._active == key_id:
|
||||
store._active = None
|
||||
|
||||
store.init_issuer_key = init_issuer_key
|
||||
store.get_active_signing_key_row = get_active_signing_key_row
|
||||
store.get_public_key_row = get_public_key_row
|
||||
store.set_issuer_key_superseded = set_issuer_key_superseded
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_v03_and_generates_fresh():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] == "v03-key-aaa"
|
||||
assert result["new_key_id"] is not None
|
||||
# v0.3 key is superseded in Postgres
|
||||
archived = await pg.get_public_key_row("v03-key-aaa")
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-v03-b64"
|
||||
# fresh key is active
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
assert active["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent_when_active_key_exists():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# second run — no-op
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_g027_first_boot_no_v03_key():
|
||||
# G-027: no v0.3 active key in SQLite → skip archive, fresh key only.
|
||||
sqlite = MagicMock()
|
||||
sqlite.get_active_signing_key_row = AsyncMock(return_value=None)
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None # nothing archived
|
||||
assert result["new_key_id"] is not None # fresh key generated
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_before_activating_r_vc_mig_01():
|
||||
# R-VC-MIG-01: the v0.3 public key MUST be archived BEFORE the fresh
|
||||
# key is activated. We verify the ordering by checking that at no point
|
||||
# is there an active v0.4 key without the v0.3 key being present (as
|
||||
# superseded) in Postgres.
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
# Instrument init_issuer_key to assert the archive happened first.
|
||||
order = []
|
||||
orig_init = pg.init_issuer_key
|
||||
|
||||
async def tracing_init(key_id, public_key, private_key_enc):
|
||||
order.append(key_id)
|
||||
await orig_init(key_id, public_key, private_key_enc)
|
||||
|
||||
pg.init_issuer_key = tracing_init
|
||||
orig_super = pg.set_issuer_key_superseded
|
||||
|
||||
async def tracing_super(key_id):
|
||||
order.append(f"supersede:{key_id}")
|
||||
await orig_super(key_id)
|
||||
|
||||
pg.set_issuer_key_superseded = tracing_super
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# The v0.3 key (v03-key-aaa) is init'd then superseded BEFORE the fresh
|
||||
# key is init'd (active).
|
||||
v03_idx = order.index("v03-key-aaa")
|
||||
sup_idx = order.index("supersede:v03-key-aaa")
|
||||
fresh_idx = [i for i, k in enumerate(order) if k not in ("v03-key-aaa",) and not k.startswith("supersede:")][0]
|
||||
assert v03_idx < sup_idx < fresh_idx
|
||||
|
||||
|
||||
# ── Verification with PgStore (R-VC-MIG-01 critical test) ───────────────────
|
||||
|
||||
|
||||
def _make_credential_store(rows: dict[str, dict]):
|
||||
store = MagicMock()
|
||||
async def get_credential(cid):
|
||||
return rows.get(cid)
|
||||
store.get_credential = get_credential
|
||||
# status list store (SQLite) — empty
|
||||
sl_store = MagicMock()
|
||||
sl_store.get_status_list = AsyncMock(return_value=None)
|
||||
sl_store.upsert_status_list = AsyncMock()
|
||||
return store, sl_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v03_vc_verifies_against_superseded_key_in_pg():
|
||||
"""R-VC-MIG-01 critical: a v0.3 VC verifies against a Postgres store
|
||||
with the v0.3 public key archived as superseded."""
|
||||
# Generate a v0.3 keypair + credential.
|
||||
sk_v03 = nacl.signing.SigningKey.generate()
|
||||
vk_v03 = sk_v03.verify_key
|
||||
pub_v03_b64 = _b64(bytes(vk_v03))
|
||||
v03_key_id = "v03-key-real"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-1", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v03-real", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v03, v03_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v03-real", "learner_ref": "learner-1",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres store has the v0.3 key as superseded + the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v03_key_id, pub_v03_b64, b"")
|
||||
await pg.set_issuer_key_superseded(v03_key_id)
|
||||
pg._rows[v03_key_id]["public_key"] = pub_v03_b64
|
||||
# add credential to pg via a separate mock get_credential
|
||||
async def get_cred(cid):
|
||||
if cid == "vc-v03-real":
|
||||
return cred_row
|
||||
return None
|
||||
pg.get_credential = get_cred
|
||||
# SQLite status-list store (empty → not revoked)
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
sqlite_sl.upsert_status_list = AsyncMock()
|
||||
result = await verify_credential(
|
||||
pg, "vc-v03-real", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True, "v0.3 VC must verify against archived superseded key (R-VC-MIG-01)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v04_vc_verifies_against_active_key_in_pg():
|
||||
sk_v04 = nacl.signing.SigningKey.generate()
|
||||
vk_v04 = sk_v04.verify_key
|
||||
pub_v04_b64 = _b64(bytes(vk_v04))
|
||||
v04_key_id = "v04-key-fresh"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-2", path="cs-refund",
|
||||
scenarios_passed=["sc-1", "sc-2"], rubric_score=4.5, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v04-fresh", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v04, v04_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v04-fresh", "learner_ref": "learner-2",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v04_key_id, pub_v04_b64, b"\x09")
|
||||
pg._rows[v04_key_id]["public_key"] = pub_v04_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-v04-fresh" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-v04-fresh", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_v03_vc_fails_verification():
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-tamper"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-t", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-tamper", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
# Tamper: change the rubricScore after signing.
|
||||
secured["credentialSubject"]["rubricScore"] = 1.0
|
||||
cred_row = {
|
||||
"id": "vc-tamper", "learner_ref": "learner-t",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-tamper" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-tamper", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_fallback_sqlite_when_pg_missing_credential():
|
||||
"""G-011(b): credential not in Postgres → fall back to SQLite."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-fallback"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-fb", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-fallback", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
sqlite_cred_row = {
|
||||
"id": "vc-fallback", "learner_ref": "learner-fb",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres has the key but NOT the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def pg_get_cred(cid):
|
||||
return None # not in Postgres
|
||||
pg.get_credential = pg_get_cred
|
||||
# SQLite has the credential + the key (v0.3 path).
|
||||
sqlite = MagicMock()
|
||||
async def sqlite_get_cred(cid):
|
||||
return sqlite_cred_row if cid == "vc-fallback" else None
|
||||
sqlite.get_credential = sqlite_get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
sqlite, "vc-fallback", pg_store=pg, sqlite_store=sqlite
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_sqlite_only_when_no_pg():
|
||||
"""G-011(c): no Postgres → full v0.3 SQLite path."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-sqlite-only"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-so", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-so", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
cred_row = {
|
||||
"id": "vc-so", "learner_ref": "learner-so",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
sqlite = MagicMock()
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-so" else None
|
||||
sqlite.get_credential = get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(sqlite, "vc-so", pg_store=None, sqlite_store=sqlite)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
Reference in New Issue
Block a user