feat(P02): per-user settings + model keys #18
Reference in New Issue
Block a user
Delete Branch "phase/02-per-user-settings-keys"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
REQ-72 per-user settings persistence (null-poisoning P0 fixed in verify), REQ-73 encrypted model keys (AES-256-GCM, AAD=user_id, masked-only, honest 409 without KEK), REQ-74 per-run credential chain user→server→keyless via ModelAdapterFactory (shared-adapter fallback bug dead). Production entrypoint researchx.api.main (P-3 backend half). Frontend settings live-binding + snapshot regen. Verify: PASS-WITH-FIXES, 264 backend + 131 frontend tests. Ships as v0.3.2.
---ci--- phase: 2 milestone: v0.3 status: execute persona: backend-engineer task: T-2.1 + T-2.2 + T-2.3 requirements: covered: [REQ-72, REQ-73] partial: [] ---/ci--- Key store (T-2.2, REQ-73): auth/key_store.py — AES-256-GCM via cryptography AESGCM, KEK = RESEARCHX_KEK (b64 or hex, exactly 32 bytes; decode/length errors typed as KeyStoreError('bad_kek')), AAD = user_id (cross-user envelope copy fails InvalidTag), fresh os.urandom(12) nonce per write, keys/<user_id>.json {v,alg, updated_at,entries} envelope, atomic tmp+replace write, 0600, crypto in asyncio.to_thread. No KEK = disabled honestly (reason string; set rejected, get None, status says disabled). KEK-loss posture documented in the module docstring (G-105): loss = unrecoverable, honest degradation to server/keyless, re-entry under a re-provisioned KEK. Tamper/corruption surface as typed errors (tampered/unreadable), never a silent tier drop. Settings store (T-2.3, REQ-72): auth/settings.py — settings/ <user_id>.json, atomic, pydantic UserSettings {planner_model, writer_model (gpt-oss:20b defaults), methodology_defaults, appearance}, defaults for fresh users, partial update preserves unset fields. Tests: 36 new (KEK decode b64/hex/wrong-length/garbage, round-trips, AAD tamper via copied envelope, KEK rotation, nonce uniqueness, masked-only status, disabled honesty, stored-key-under-disabled error surfacing, 0600 perms, settings defaults/update/isolation, corrupt-envelope + bit-rot + concurrent-write atomicity adversarial cases, opt-in live keyed tier marker). pyproject gains cryptography>=43. Gates: 220 passed + 3 skipped (live), ruff, mypy.---ci--- phase: 2 milestone: v0.3 status: execute persona: backend-engineer task: T-2.4 + T-2.5 + T-2.6 requirements: covered: [REQ-72, REQ-73, REQ-74] partial: [] ---/ci--- Model factory (T-2.5, REQ-74 — P-2 closure): adapters/model_factory.py — ModelAdapterFactory(secrets, key_store, ...).resolve(user, settings) -> (OllamaModelAdapter, tier) resolving user key (tier 'user') -> env-file OLLAMA_API_KEY (tier 'server') -> keyless (tier 'keyless'); ONE adapter per resolve() call, never cached; tampered key-store envelopes raise KeyStoreError (never a silent tier drop). OllamaModelAdapter gains api_key constructor param (precedence over secrets.resolve; secrets path stays for back-compat; secrets type widened to the SecretsProvider protocol). Also fixes a latent list_models infinite-fallback recursion when cloud==local base and leaks of raw httpx errors (adapter contract = AdapterError). Routes (T-2.4, REQ-72/73): GET/PUT /api/config/settings (partial update, validated, returns updated); PUT /api/config/settings/model-key (raw key in, encrypted at rest, masked status out only; 409 with {key_store: disabled, reason} when no KEK — honest refusal); GET /api/config/settings/model-key (masked status only). Config rework (T-2.6, REQ-74 — P-1 closure): GET /api/config resolves the CURRENT user's tier via the factory with NO run creation and NO reads of runs._actors — resolved_tier, key_store (enabled|disabled), masked key status, per-user owner-keyed cost counters, model_adapter name from the resolved adapter, probe cached in-process keyed (user_id, tier, key_fingerprint) with 60s TTL, AdapterError -> probe None. create_run resolves the factory per run and stamps state.credential_tier (RunState.credential_tier: str | None = None, serialization-compatible with old state files). create_app gains key_store/settings_store/model_factory/ secrets_provider injection params (key_store=None -> disabled store, zero-key harness green). Tests: 26 new — settings CRUD under auth_enabled=True (defaults, partial round-trips, validation, CSRF, per-user isolation), model-key masked round-trip + disabled-honesty 409 + raw-key-never-leaked scan, factory chain (user/server/keyless, settings model overrides, never caches adapters, user-key Authorization precedence), config status per-user tier isolation with zero runs, probe cache/error hermeticity, credential_tier stamping. Gates: 246 passed + 3 skipped, ruff, mypy.---ci--- phase: 2 milestone: v0.3 status: verify requirements: covered: [REQ-72] partial: [] lessons: - pydantic model_copy skips validation: an explicit-null settings field passed the route's exclude_unset merge, returned 200, and poisoned the on-disk payload into a corrupt file that 500s every later GET/PUT/ create_run with no API recovery path (self-inflicted DoS). ---/ci--- verify(P02) P0 fix: PUT /api/config/settings {"planner_model": null} returned 200 but wrote planner_model=null to settings/<uid>.json via model_copy (which skips validation); every subsequent read raised SettingsStoreError -> 500 on GET/PUT settings, config status, and create_run, and the file could only be repaired out-of-band. Route fix (api/app.py): explicit null fields are treated as absent (partial-merge semantics) before the merge. Store fix (auth/settings.py): the merged payload is validated BEFORE it touches disk (UserSettings. model_validate) so a null can never be persisted at all. Tests: route regression (null PUT -> 200 + defaults preserved + later reads/writes/runs healthy), store regression (null-field merge refused, on-disk payload untouched). Hygiene: dead KeyStoreDisabledError import removed from mock-client.ts (the mock never throws it; only the live binding does) — frontend lint is now 0 warnings. Gates: backend 264 passed + 3 skipped, mypy strict clean, ruff clean; frontend 131 passed, tsc clean, build green, drift gate green, eslint 0.