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.
139 lines
5.2 KiB
Python
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")
|