Files
microfish/backend/app/models/saas.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

139 lines
5.2 KiB
Python

"""SQLAlchemy identity and tenant metadata models."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import List
from uuid import uuid4
from sqlalchemy import CheckConstraint, DateTime, Enum as SAEnum, ForeignKey, Integer, String, UniqueConstraint, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import Base
from ..security.policy import Role
def _id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _role_values(enum_type):
return [member.value for member in enum_type]
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("org"))
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(80), nullable=False, unique=True, index=True)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="active", server_default=text("'active'")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
memberships: Mapped[List["Membership"]] = relationship(
back_populates="organization", cascade="all, delete-orphan"
)
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("usr"))
email_normalized: Mapped[str] = mapped_column(String(320), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(
String(512), nullable=False, default="!invite_pending", server_default=text("'!invite_pending'")
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="active", server_default=text("'active'")
)
auth_version: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
locale: Mapped[str] = mapped_column(
String(8), nullable=False, default="th", server_default=text("'th'")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
memberships: Mapped[List["Membership"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
sessions: Mapped[List["AuthSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Membership(Base):
__tablename__ = "memberships"
__table_args__ = (
UniqueConstraint("user_id", "organization_id", name="uq_membership_user_org"),
CheckConstraint(
"role IN ('super_admin', 'admin', 'user')",
name="ck_membership_role",
),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("mem"))
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[Role] = mapped_column(
SAEnum(
Role,
name="role",
values_callable=_role_values,
native_enum=False,
create_constraint=False,
validate_strings=True,
),
nullable=False,
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="active", server_default=text("'active'")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
user: Mapped[User] = relationship(back_populates="memberships")
organization: Mapped[Organization] = relationship(back_populates="memberships")
sessions: Mapped[List["AuthSession"]] = relationship(back_populates="membership")
class AuthSession(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: _id("ses"))
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
membership_id: Mapped[str] = mapped_column(
ForeignKey("memberships.id", ondelete="CASCADE"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
auth_version: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utc_now, server_default=func.now(), nullable=False
)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[User] = relationship(back_populates="sessions")
membership: Mapped[Membership] = relationship(back_populates="sessions")