Files
microfish/backend/app/services/artifact_store.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

88 lines
2.9 KiB
Python

"""Tenant-scoped artifact store abstraction.
Wraps filesystem artifact persistence behind a small interface so project files,
simulation artifacts, and reports can later be moved to object storage without
changing callers. All paths are resolved under a tenant directory and reject
traversal/absolute components (fail-closed).
"""
from __future__ import annotations
import os
import re
from typing import Optional
_SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class ArtifactStore:
"""Resolve tenant-scoped storage paths rooted under ``root``.
``path_for`` maps an owner/org and nested segments to an absolute path under
``root/<organization>/<segments...>``. Path components are validated so a
caller can never escape the configured root.
"""
def __init__(self, root: str):
if not isinstance(root, str) or not root.strip():
raise ValueError("artifact_root_required")
self.root = os.path.realpath(root)
def _validate_segments(self, segments):
cleaned = []
for segment in segments:
if not isinstance(segment, str) or not _SAFE_SEGMENT.fullmatch(segment):
raise ValueError("invalid_artifact_path")
cleaned.append(segment)
return cleaned
def path_for(self, *segments) -> str:
"""Return a safe absolute path under the root for the given segments.
The first segment is treated as the tenant/owner scope; every segment
must be a safe identifier (no separators, no dots-only, no traversal).
"""
if not segments:
raise ValueError("artifact_path_required")
cleaned = self._validate_segments(segments)
candidate = os.path.realpath(os.path.join(self.root, *cleaned))
if os.path.commonpath([self.root, candidate]) != self.root:
raise ValueError("invalid_artifact_path")
return candidate
def ensure_parent(self, path: str) -> None:
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
def store_bytes(self, path: str, data: bytes) -> None:
self.ensure_parent(path)
with open(path, "wb") as handle:
handle.write(data)
def read_bytes(self, path: str) -> Optional[bytes]:
if not self.exists(path):
return None
with open(path, "rb") as handle:
return handle.read()
def exists(self, path: str) -> bool:
return os.path.isfile(path)
def delete(self, path: str) -> bool:
if self.exists(path):
os.remove(path)
return True
return False
def default_artifact_store() -> ArtifactStore:
"""Artifact store rooted at the configured upload root (filesystem backend).
Later this factory can return an object-storage backed store without
changing callers.
"""
from ..config import Config
return ArtifactStore(Config.UPLOAD_FOLDER)