Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
116 lines
3.8 KiB
Python
116 lines
3.8 KiB
Python
"""Versioned, redacted platform settings service.
|
|
|
|
The API key is encrypted with a Fernet key derived from ``SECRET_KEY`` so the
|
|
plaintext never appears in the record, API responses, or logs. The public API
|
|
surface only ever sees a masked value and the settings version; a per-job
|
|
snapshot references the version rather than the secret.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
|
|
from cryptography.fernet import Fernet
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..config import Config
|
|
from ..models.settings import PlatformSettings
|
|
|
|
MASK = "sk-••••••••"
|
|
|
|
|
|
def _utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class SettingsService:
|
|
"""Flush-only settings repository; caller owns transactions."""
|
|
|
|
def __init__(self, session: Session):
|
|
self.session = session
|
|
|
|
def _fernet(self) -> Fernet:
|
|
secret = Config.SECRET_KEY
|
|
if not secret:
|
|
raise ValueError("settings_secret_key_required")
|
|
digest = hashlib.sha256(secret.encode("utf-8")).digest()
|
|
key = base64.urlsafe_b64encode(digest)
|
|
return Fernet(key)
|
|
|
|
def _encrypt_secret(self, api_key: str) -> str:
|
|
return self._fernet().encrypt(api_key.encode("utf-8")).decode("utf-8")
|
|
|
|
def _decrypt_secret(self, secret_ref: str) -> Optional[str]:
|
|
if not secret_ref:
|
|
return None
|
|
try:
|
|
return self._fernet().decrypt(secret_ref.encode("utf-8")).decode("utf-8")
|
|
except Exception:
|
|
return None
|
|
|
|
def _latest_row(self) -> Optional[PlatformSettings]:
|
|
return (
|
|
self.session.query(PlatformSettings)
|
|
.order_by(PlatformSettings.created_at.desc())
|
|
.first()
|
|
)
|
|
|
|
def save_settings(
|
|
self,
|
|
settings: dict,
|
|
*,
|
|
api_key: Optional[str] = None,
|
|
updated_by: Optional[str] = None,
|
|
) -> str:
|
|
"""Persist a new version, encrypting the API key if provided."""
|
|
version = f"v{uuid4().hex[:12]}"
|
|
# Clear active on all existing rows, then insert the new active version.
|
|
for row in self.session.query(PlatformSettings).filter(
|
|
PlatformSettings.active.is_(True)
|
|
):
|
|
row.active = False
|
|
record = PlatformSettings(
|
|
version=version,
|
|
settings=settings,
|
|
secret_ref=self._encrypt_secret(api_key) if api_key else None,
|
|
updated_by_user_id=updated_by,
|
|
active=True,
|
|
)
|
|
self.session.add(record)
|
|
self.session.flush()
|
|
return version
|
|
|
|
def _mask(self, settings: dict) -> dict:
|
|
out = dict(settings or {})
|
|
# A secret value should never be in the public dict; be defensive.
|
|
for key in list(out.keys()):
|
|
if "key" in key.lower() or "secret" in key.lower() or "token" in key.lower():
|
|
out[key] = MASK
|
|
return out
|
|
|
|
def active_settings(self) -> dict:
|
|
row = self._latest_row()
|
|
if row is None:
|
|
return {"settings": {}, "version": None, "updated_by": None, "api_key": MASK}
|
|
masked = self._mask(row.settings if isinstance(row.settings, dict) else {})
|
|
return {
|
|
"settings": masked,
|
|
"version": row.version,
|
|
"updated_by": row.updated_by_user_id,
|
|
"api_key": MASK if row.secret_ref else MASK,
|
|
}
|
|
|
|
def snapshot_for_job(self) -> dict:
|
|
row = self._latest_row()
|
|
if row is None:
|
|
return {"settings": {}, "version": None, "settings_version": None}
|
|
return {
|
|
"settings": self._mask(row.settings if isinstance(row.settings, dict) else {}),
|
|
"version": row.version,
|
|
"settings_version": row.version,
|
|
}
|