From f6ddaca14fa5e0c480af553e8d7dcb341ce4ca40 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 22 Mar 2026 15:31:45 -0700 Subject: [PATCH] feat(settings): credential store + fix Task 6 blocking review issues - add scripts/credential_store.py (keyring/file/env-ref backends, Fernet encryption) - email password stored via credential store, never returned in GET - email GET returns password_set flag; PUT accepts new password or ${ENV_VAR} ref - move integration actions to store (connectIntegration, testIntegration, disconnectIntegration) - add tier-gating UI with locked state and upgrade prompt - move subprocess/socket/imaplib/ssl imports to top level --- dev-api.py | 63 ++++-- scripts/credential_store.py | 197 ++++++++++++++++++ web/src/stores/settings/system.ts | 44 +++- web/src/views/settings/SystemSettingsView.vue | 106 ++++++---- 4 files changed, 348 insertions(+), 62 deletions(-) create mode 100644 scripts/credential_store.py diff --git a/dev-api.py b/dev-api.py index 4788f71..4b1c4b3 100644 --- a/dev-api.py +++ b/dev-api.py @@ -4,28 +4,36 @@ Reads directly from /devl/job-seeker/staging.db. Run with: conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload """ -import sqlite3 -import os -import sys -import re +import imaplib import json +import logging +import os +import re +import socket +import sqlite3 +import ssl as ssl_mod +import subprocess +import sys import threading -from urllib.parse import urlparse -from bs4 import BeautifulSoup from datetime import datetime from pathlib import Path +from typing import Optional, List +from urllib.parse import urlparse + +import requests import yaml +from bs4 import BeautifulSoup from fastapi import FastAPI, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from typing import Optional, List -import requests # Allow importing peregrine scripts for cover letter generation PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") if str(PEREGRINE_ROOT) not in sys.path: sys.path.insert(0, str(PEREGRINE_ROOT)) +from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402 + DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") app = FastAPI(title="Peregrine Dev API") @@ -1196,9 +1204,6 @@ def byok_ack(payload: ByokAckPayload): # ── Settings: System — Services ─────────────────────────────────────────────── -import subprocess -import socket - SERVICES_REGISTRY = [ {"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]}, {"name": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]}, @@ -1261,15 +1266,25 @@ def stop_service(name: str): # ── Settings: System — Email ────────────────────────────────────────────────── EMAIL_PATH = Path("config/email.yaml") +EMAIL_CRED_SERVICE = "peregrine" +EMAIL_CRED_KEY = "imap_password" + +# Non-secret fields stored in yaml +EMAIL_YAML_FIELDS = ("host", "port", "ssl", "username", "sent_folder", "lookback_days") @app.get("/api/settings/system/email") def get_email_config(): try: - if not EMAIL_PATH.exists(): - return {} - with open(EMAIL_PATH) as f: - return yaml.safe_load(f) or {} + config = {} + if EMAIL_PATH.exists(): + with open(EMAIL_PATH) as f: + config = yaml.safe_load(f) or {} + # Never return the password — only indicate whether it's set + password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY) + config["password_set"] = bool(password) + config.pop("password", None) # strip if somehow in yaml + return config except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -1278,10 +1293,16 @@ def get_email_config(): def save_email_config(payload: dict): try: EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True) - # Write with restricted permissions (contains password) + # Extract password before writing yaml + password = payload.pop("password", None) or payload.pop("password_set", None) + # Only store if it's a real new value (not the sentinel True/False) + if password and isinstance(password, str): + set_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY, password) + # Write non-secret fields to yaml (chmod 600 still, contains username) + safe_config = {k: v for k, v in payload.items() if k in EMAIL_YAML_FIELDS} fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, 'w') as f: - yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False) + with os.fdopen(fd, "w") as f: + yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False) return {"ok": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -1289,13 +1310,15 @@ def save_email_config(payload: dict): @app.post("/api/settings/system/email/test") def test_email(payload: dict): - import imaplib, ssl as ssl_mod try: + # Allow test to pass in a password override, or use stored credential + password = payload.get("password") or get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY) host = payload.get("host", "") port = int(payload.get("port", 993)) use_ssl = payload.get("ssl", True) username = payload.get("username", "") - password = payload.get("password", "") + if not all([host, username, password]): + return {"ok": False, "error": "Missing host, username, or password"} if use_ssl: ctx = ssl_mod.create_default_context() conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) diff --git a/scripts/credential_store.py b/scripts/credential_store.py new file mode 100644 index 0000000..f4cafaf --- /dev/null +++ b/scripts/credential_store.py @@ -0,0 +1,197 @@ +""" +Credential store abstraction for Peregrine. + +Backends (set via CREDENTIAL_BACKEND env var): + auto → try keyring, fall back to file (default) + keyring → python-keyring (OS Keychain / SecretService / libsecret) + file → Fernet-encrypted JSON in config/credentials/ (key at config/.credential_key) + +Env var references: + Any stored value matching ${VAR_NAME} is resolved from os.environ at read time. + Users can store "${IMAP_PASSWORD}" as the credential value; it is never treated + as the actual secret — only the env var it points to is used. +""" + +import os +import re +import json +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_ENV_REF = re.compile(r'^\$\{([A-Z_][A-Z0-9_]*)\}$') + +CRED_DIR = Path("config/credentials") +KEY_PATH = Path("config/.credential_key") + + +def _resolve_env_ref(value: str) -> Optional[str]: + """If value is ${VAR_NAME}, return os.environ[VAR_NAME]; otherwise return None.""" + m = _ENV_REF.match(value) + if m: + resolved = os.environ.get(m.group(1)) + if resolved is None: + logger.warning("Credential reference %s is set but env var is not defined", value) + return resolved + return None + + +def _get_backend() -> str: + backend = os.environ.get("CREDENTIAL_BACKEND", "auto").lower() + if backend != "auto": + return backend + # Auto: try keyring, fall back to file + try: + import keyring + kr = keyring.get_keyring() + # Reject the null/fail keyring — it can't actually store anything + if "fail" in type(kr).__name__.lower() or "null" in type(kr).__name__.lower(): + raise RuntimeError("No usable keyring backend found") + return "keyring" + except Exception: + return "file" + + +def _get_fernet(): + """Return a Fernet instance, auto-generating the key on first use.""" + try: + from cryptography.fernet import Fernet + except ImportError: + return None + + if KEY_PATH.exists(): + key = KEY_PATH.read_bytes().strip() + else: + key = Fernet.generate_key() + KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(KEY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(key) + logger.info("Generated new credential encryption key at %s", KEY_PATH) + + return Fernet(key) + + +def _file_read(service: str) -> dict: + """Read the credentials file for a service, decrypting if possible.""" + cred_file = CRED_DIR / f"{service}.json" + if not cred_file.exists(): + return {} + raw = cred_file.read_bytes() + fernet = _get_fernet() + if fernet: + try: + return json.loads(fernet.decrypt(raw)) + except Exception: + # May be an older plaintext file — try reading as text + try: + return json.loads(raw.decode()) + except Exception: + logger.error("Failed to read credentials for service %s", service) + return {} + else: + try: + return json.loads(raw.decode()) + except Exception: + return {} + + +def _file_write(service: str, data: dict) -> None: + """Write the credentials file for a service, encrypting if possible.""" + CRED_DIR.mkdir(parents=True, exist_ok=True) + cred_file = CRED_DIR / f"{service}.json" + fernet = _get_fernet() + if fernet: + content = fernet.encrypt(json.dumps(data).encode()) + fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(content) + else: + logger.warning( + "cryptography package not installed — storing credentials as plaintext with chmod 600. " + "Install with: pip install cryptography" + ) + content = json.dumps(data).encode() + fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(content) + + +def get_credential(service: str, key: str) -> Optional[str]: + """ + Retrieve a credential. If the stored value is an env var reference (${VAR}), + resolves it from os.environ at call time. + """ + backend = _get_backend() + raw: Optional[str] = None + + if backend == "keyring": + try: + import keyring + raw = keyring.get_password(service, key) + except Exception as e: + logger.error("keyring get failed for %s/%s: %s", service, key, e) + else: # file + data = _file_read(service) + raw = data.get(key) + + if raw is None: + return None + + # Resolve env var references transparently + resolved = _resolve_env_ref(raw) + if resolved is not None: + return resolved + if _ENV_REF.match(raw): + return None # reference defined but env var not set + + return raw + + +def set_credential(service: str, key: str, value: str) -> None: + """ + Store a credential. Value may be a literal secret or a ${VAR_NAME} reference. + Env var references are stored as-is and resolved at get time. + """ + if not value: + return + + backend = _get_backend() + + if backend == "keyring": + try: + import keyring + keyring.set_password(service, key, value) + return + except Exception as e: + logger.error("keyring set failed for %s/%s: %s — falling back to file", service, key, e) + backend = "file" + + # file backend + data = _file_read(service) + data[key] = value + _file_write(service, data) + + +def delete_credential(service: str, key: str) -> None: + """Remove a stored credential.""" + backend = _get_backend() + + if backend == "keyring": + try: + import keyring + keyring.delete_password(service, key) + return + except Exception: + backend = "file" + + data = _file_read(service) + data.pop(key, None) + if data: + _file_write(service, data) + else: + cred_file = CRED_DIR / f"{service}.json" + if cred_file.exists(): + cred_file.unlink() diff --git a/web/src/stores/settings/system.ts b/web/src/stores/settings/system.ts index b408bf4..bb4ea39 100644 --- a/web/src/stores/settings/system.ts +++ b/web/src/stores/settings/system.ts @@ -153,6 +153,46 @@ export const useSystemStore = defineStore('settings/system', () => { if (data) integrations.value = data } + async function connectIntegration(id: string, credentials: Record) { + const { data, error } = await useApiFetch<{ok: boolean; error?: string}>( + `/api/settings/system/integrations/${id}/connect`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) } + ) + if (error || !data?.ok) { + return { ok: false, error: data?.error ?? 'Connection failed' } + } + await loadIntegrations() + return { ok: true } + } + + async function testIntegration(id: string, credentials: Record) { + const { data, error } = await useApiFetch<{ok: boolean; error?: string}>( + `/api/settings/system/integrations/${id}/test`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) } + ) + return { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) } + } + + async function disconnectIntegration(id: string) { + const { error } = await useApiFetch( + `/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' } + ) + if (!error) await loadIntegrations() + } + + async function saveEmailWithPassword(payload: Record) { + emailSaving.value = true + emailError.value = null + const { error } = await useApiFetch('/api/settings/system/email', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + emailSaving.value = false + if (error) emailError.value = 'Save failed — please try again.' + else await loadEmail() // reload to get fresh password_set status + } + async function loadFilePaths() { const { data } = await useApiFetch>('/api/settings/system/paths') if (data) filePaths.value = data @@ -191,8 +231,8 @@ export const useSystemStore = defineStore('settings/system', () => { services, emailConfig, integrations, serviceErrors, emailSaving, emailError, filePaths, deployConfig, filePathsSaving, deploySaving, loadServices, startService, stopService, - loadEmail, saveEmail, testEmail, - loadIntegrations, + loadEmail, saveEmail, testEmail, saveEmailWithPassword, + loadIntegrations, connectIntegration, testIntegration, disconnectIntegration, loadFilePaths, saveFilePaths, loadDeployConfig, saveDeployConfig, } diff --git a/web/src/views/settings/SystemSettingsView.vue b/web/src/views/settings/SystemSettingsView.vue index 856fc89..1a15bee 100644 --- a/web/src/views/settings/SystemSettingsView.vue +++ b/web/src/views/settings/SystemSettingsView.vue @@ -86,8 +86,12 @@
- - Gmail: use an App Password, not your account password. + + Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.
@@ -98,7 +102,7 @@
- @@ -116,24 +120,39 @@
{{ integration.name }} - - {{ integration.connected ? 'Connected' : 'Disconnected' }} - -
-
-
- - -
-
- - +
+ + Requires {{ integration.tier_required }} + + + {{ integration.connected ? 'Connected' : 'Disconnected' }} +
-
- + +
+

Upgrade to {{ integration.tier_required }} to use this integration.

+ +
@@ -215,12 +234,13 @@