Files
microfish/backend/app/services/settings_service.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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,
}