Files
microfish/backend/app/security/auth.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

109 lines
3.5 KiB
Python

"""Flask request authentication decorators for tenant-scoped routes."""
from __future__ import annotations
import hmac
import secrets
from functools import wraps
from flask import current_app, g, request
from itsdangerous import BadSignature, URLSafeTimedSerializer
from ..services.identity import SessionService
from ..utils.api_errors import ApiError
from .policy import Actor, Role
def _session_factory():
factory = current_app.extensions.get("crowdsight_session_factory")
if factory is None:
raise ApiError("auth_unavailable", 503, "api.internalError")
return factory
def _csrf_serializer() -> URLSafeTimedSerializer:
secret_key = current_app.secret_key
if not secret_key:
raise ApiError("auth_unavailable", 503, "api.internalError")
return URLSafeTimedSerializer(secret_key, salt="crowdsight-csrf")
def issue_csrf_token() -> str:
return _csrf_serializer().dumps(secrets.token_urlsafe(24))
def _validate_csrf() -> None:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
cookie_token = request.cookies.get("crowdsight_csrf", "")
header_token = request.headers.get("X-CSRF-Token", "")
if not cookie_token or not header_token or not hmac.compare_digest(cookie_token, header_token):
raise ApiError("csrf_failed", 403, "common.error")
try:
_csrf_serializer().loads(cookie_token, max_age=SessionService.DEFAULT_TTL_SECONDS)
except BadSignature as exc:
raise ApiError("csrf_failed", 403, "common.error") from exc
def authenticate_readonly_request() -> None:
"""Authenticate a legacy blueprint without exposing a DB session to the route."""
if getattr(g, "auth_context", None) is not None:
return
raw_token = request.cookies.get("crowdsight_session", "")
with _session_factory()() as db_session:
context = SessionService.resolve(db_session, raw_token)
if context is None:
raise ApiError("unauthorized", 401, "common.unauthorized")
_validate_csrf()
g.auth_context = context
def current_actor() -> Actor:
context = getattr(g, "auth_context", None)
if context is None:
raise ApiError("unauthorized", 401, "common.unauthorized")
return Actor(
user_id=context.user.id,
organization_id=context.organization.id,
role=context.membership.role,
)
def require_auth(view):
@wraps(view)
def wrapped(*args, **kwargs):
raw_token = request.cookies.get("crowdsight_session", "")
with _session_factory()() as db_session:
context = SessionService.resolve(db_session, raw_token)
if context is None:
raise ApiError("unauthorized", 401, "common.unauthorized")
_validate_csrf()
g.auth_context = context
g.db_session = db_session
try:
response = view(*args, **kwargs)
db_session.commit()
return response
except Exception:
db_session.rollback()
raise
return wrapped
def require_roles(*allowed_roles: Role | str):
allowed = {role if isinstance(role, Role) else Role(role) for role in allowed_roles}
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
actor = current_actor()
if actor.role not in allowed:
raise ApiError("forbidden", 403, "common.error")
return view(*args, **kwargs)
return require_auth(wrapped)
return decorator