feat(P02): per-user settings + model keys #18

Merged
coreci-bot merged 8 commits from phase/02-per-user-settings-keys into milestone/v0.3-hosted 2026-09-30 10:31:25 +00:00
Owner

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.

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.
coreci-bot added 8 commits 2026-09-30 10:31:20 +00:00
---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: execute
persona: backend-engineer
task: T-2.7
requirements:
  covered: [REQ-74]
  partial: [REQ-75, REQ-85]
---/ci---

researchx/api/main.py — the P-3 backend closure: 'uvicorn
researchx.api.main:app' serves the LIVE wiring (EnvFileSecretsProvider
over the runtime env file — /etc/researchx/env.secrets container
convention, .ciagent/.env.secrets repo-local, RESEARCHX_SECRETS_FILE
override; data root from RESEARCHX_DATA_DIR via paths.data_root();
ModelAdapterFactory with the KeyStore + SettingsStore; search via
resolve_search_adapter — Tavily keyed, documented keyless DuckDuckGo
fallback per G-008).

auth_enabled=True and cookie_secure=True (E-4) are HARDWIRED as
build_app() arguments — no parameters, no env vars, never False on
any serving path (REQ-85/C3-16). create_app now constructs the
fallback fake model ONLY when neither a factory nor a fixed adapter
is wired — a factory-wired app (every production build) holds no
FakeModelAdapter at all; config_status + create_run are fully
factory-driven.

Tests (test_main_entrypoint.py, 8 new): factory resolves real
OllamaModelAdapter never the fake; auth hardwired (401 on all
business routes, /healthz public); cookie Secure hardwired; tier
resolution end to end through the runtime env (server tier with
server key, keyless honest without keys, user tier after key PUT
with encrypted-at-rest file under the DATA ROOT); storage_dir = data
root string; no auth-disable surface in the module (build_app has no
auth_enabled/cookie_secure params, source has no RESEARCHX_AUTH, no
auth_enabled=False). Cookie forwarding in tests is explicit because
the Secure cookie does not replay over plain-http ASGI transport.
Gates: 254 passed + 3 skipped, ruff, mypy.
---ci---
phase: 2
milestone: v0.3
status: execute
persona: backend-engineer
task: T-2.8
requirements:
  covered: [REQ-73, REQ-74]
  partial: []
---/ci---

Security-engineer-hat adversarial extension (T-2.8):

- ModelAdapterFactory.resolve_status(): config-status variant — a
  corrupt/tampered stored envelope DEGRADES the tier to server/keyless
  and carries the honest error message alongside (never a 500, never
  a silent 'user' claim, never a silent drop). create_run keeps the
  raising resolve() (corrupt envelope = honest 409, never a
  wrong-key run).
- GET /api/config surfaces the resolve error in key_status.reason
  when the tier degraded.
- tests/test_key_store_adversarial.py (8 new): garbage-envelope and
  bit-rot ciphertext degradation through the API (200 + reason),
  create_run 409 on corrupt envelope (run not created), PUT-key
  recovery path back to the 'user' tier, full-session raw-key leak
  scan (config/settings/key-status/me/methodologies/admin/run
  responses + disk), two-user AAD bind end to end (second user's tier
  is theirs alone; first user's key never shadows), corrupt settings
  typed-error surfacing, KeyStoreError-is-AdapterError contract pin.
- The unit-tier adversarial cases (KEK decode, AAD copy tamper,
  nonce uniqueness, masked-only, disabled honesty, 0600 perms,
  concurrent-write atomicity, bit-rot, opt-in live keyed tier) live
  in test_key_store_settings.py (STEP 1/4 combined).

Gates: uv run pytest -q -> 262 passed + 3 skipped (live opt-in);
-m live -> 3 skip cleanly zero-key; ruff clean; mypy strict clean.
Regenerated frontend/openapi.json from the P2 backend (uv export via
researchx.api.app) and src/lib/api/generated/schema.d.ts from the
snapshot (G-005). New surface: GET/PUT /api/config/settings,
GET/PUT /api/config/settings/model-key, and the reworked
GET /api/config ConfigStatus (resolved_tier, key_store, key_status,
cost_counters). check:api-drift green.
---ci---
phase: 2
milestone: v0.3
status: execute
persona: frontend-engineer
task: T-2.9
requirements:
  covered: [REQ-72, REQ-73, REQ-74]
---/ci---

GeneratedApiClient: getUserSettings/saveUserSettings/setModelKey/
getConfigStatus against the per-user routes (credentials include +
CSRF header on mutations); KeyStoreDisabledError carries the honest
backend reason from the 409 detail envelope. settings-view: masked
model-key entry (password input, masked status only — never echoed),
planner/writer model inputs bound to settings, resolved credential
tier display, per-user cost counters, storage display-only (D-111);
honest disabled-key-store state. Mock binding: fixture parity (fresh-
user defaults, in-memory masked key, keyless tier, disabled store).
Boundary tests: 12 new (both bindings); test-harness stub reads the
Request stream correctly. 131 frontend tests green; tsc/lint/drift
gates clean.
---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.
---ci---
phase: 2
milestone: v0.3
status: verify
verdict: PASS-WITH-FIXES
layers:
  structural: PASS
  behavioral: PASS
  security: PASS
  quality: PASS-WITH-FLAGS
p0_fixed: "8e19365 — PUT /api/config/settings explicit-null poisoning (model_copy skipped validation → poisoned disk file → cascading 500s, no recovery path); nulls treated as absent + store validates merged payload"
requirements:
  covered: [REQ-72, REQ-73, REQ-74]
  partial: []
flags_for_p3:
  - "P2: getConnectionStatus has no live override — T-3.9 surface swaps must include the Connections panel"
  - "P3: pin a credential_tier serialization-compat test (pre-v0.3.2 state files)"
flags_for_p5:
  - "P2: corrupt settings file → unhandled 500 on GET settings/config (SettingsStoreError caught in put only) — settings store should degrade honestly like the key store"
  - "P2: ModelCredentialsSection live-mode component test (boundary tests only now)"
---/ci---

Final gates: backend 264 passed + 3 skipped; frontend 131 passed;
eslint 0 warnings; drift green; build green.
coreci-bot merged commit ee26ff1658 into milestone/v0.3-hosted 2026-09-30 10:31:25 +00:00
Sign in to join this conversation.