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.
88 lines
2.9 KiB
Python
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)
|