feat(B2): add tenant model + migrations 035/036 + RLS policies

Migration 035: tenants table with 'Schaeffler' default seed.
Migration 036: tenant_id FK on all tables, RLS policies, backfill.
New domains/tenants/ with CRUD router (admin only).
All domain models extended with tenant_id FK.
core/database.py: get_db_for_tenant with RLS context setter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 16:30:41 +01:00
parent b87df4a3e5
commit 251dd703ed
19 changed files with 537 additions and 7 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@@ -22,8 +22,12 @@ class User(Base):
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.client, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="users", lazy="noload")
orders: Mapped[list["Order"]] = relationship("Order", back_populates="created_by_user", foreign_keys="Order.created_by")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user", foreign_keys="AuditLog.user_id")
+9 -1
View File
@@ -1,8 +1,13 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.domains.tenants.models import Tenant
class PricingTier(Base):
@@ -14,6 +19,9 @@ class PricingTier(Base):
price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+7 -1
View File
@@ -1,9 +1,12 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Text
from sqlalchemy import String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.domains.tenants.models import Tenant
class Template(Base):
@@ -18,6 +21,9 @@ class Template(Base):
component_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
description: Mapped[str] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+10
View File
@@ -4,6 +4,10 @@ from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
# TYPE_CHECKING import to avoid circular references
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.domains.tenants.models import Tenant
class Material(Base):
@@ -17,6 +21,9 @@ class Material(Base):
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -32,6 +39,9 @@ class MaterialAlias(Base):
UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
)
alias: Mapped[str] = mapped_column(String(300), nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
material = relationship("Material", back_populates="aliases")
@@ -4,6 +4,9 @@ from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.domains.tenants.models import Tenant
class AuditLog(Base):
@@ -23,6 +26,9 @@ class AuditLog(Base):
)
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id])
target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id])
+10
View File
@@ -33,9 +33,13 @@ class Order(Base):
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
template: Mapped["Template"] = relationship("Template", back_populates="orders")
created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by])
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="orders", lazy="noload")
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="order", cascade="all, delete-orphan"
@@ -92,6 +96,9 @@ class OrderItem(Base):
item_status: Mapped[ItemStatus] = mapped_column(SAEnum(ItemStatus), default=ItemStatus.pending, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -137,6 +144,9 @@ class OrderLine(Base):
nullable=True,
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
+8 -1
View File
@@ -1,7 +1,7 @@
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
@@ -30,6 +30,9 @@ class CadFile(Base):
)
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -61,12 +64,16 @@ class Product(Base):
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="products", lazy="noload")
order_lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="product", cascade="all, delete-orphan"
)
+6
View File
@@ -27,6 +27,9 @@ class OutputType(Base):
Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
@@ -53,6 +56,9 @@ class RenderTemplate(Base):
shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()")
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow)
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships (lazy=noload — loaded explicitly when needed)
users: Mapped[list] = relationship("User", back_populates="tenant", lazy="noload")
orders: Mapped[list] = relationship("Order", back_populates="tenant", lazy="noload")
products: Mapped[list] = relationship("Product", back_populates="tenant", lazy="noload")
+79
View File
@@ -0,0 +1,79 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.utils.auth import require_admin
from app.domains.tenants.schemas import TenantCreate, TenantUpdate, TenantOut
from app.domains.tenants import service
router = APIRouter(prefix="/tenants", tags=["tenants"])
@router.get("/", response_model=list[TenantOut])
async def list_tenants(
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
rows = await service.list_tenants(db)
result = []
for row in rows:
tenant = row["tenant"]
out = TenantOut.model_validate(tenant)
out.user_count = row["user_count"]
result.append(out)
return result
@router.get("/{tenant_id}", response_model=TenantOut)
async def get_tenant(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
tenant = await service.get_tenant(db, tenant_id)
if not tenant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return TenantOut.model_validate(tenant)
@router.post("/", response_model=TenantOut, status_code=status.HTTP_201_CREATED)
async def create_tenant(
body: TenantCreate,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
tenant = await service.create_tenant(db, name=body.name, slug=body.slug, is_active=body.is_active)
return TenantOut.model_validate(tenant)
@router.put("/{tenant_id}", response_model=TenantOut)
async def update_tenant(
tenant_id: uuid.UUID,
body: TenantUpdate,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
tenant = await service.update_tenant(
db, tenant_id,
name=body.name,
slug=body.slug,
is_active=body.is_active,
)
if not tenant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return TenantOut.model_validate(tenant)
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
ok = await service.delete_tenant(db, tenant_id)
if not ok:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Tenant not found or still has users assigned",
)
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class TenantCreate(BaseModel):
name: str
slug: str
is_active: bool = True
class TenantUpdate(BaseModel):
name: str | None = None
slug: str | None = None
is_active: bool | None = None
class TenantOut(BaseModel):
id: uuid.UUID
name: str
slug: str
is_active: bool
user_count: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
+76
View File
@@ -0,0 +1,76 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from app.domains.tenants.models import Tenant
from app.domains.auth.models import User
async def list_tenants(db: AsyncSession) -> list[dict]:
"""Return all tenants with user counts."""
result = await db.execute(
select(
Tenant,
func.count(User.id).label("user_count"),
)
.outerjoin(User, User.tenant_id == Tenant.id)
.group_by(Tenant.id)
.order_by(Tenant.created_at)
)
rows = result.all()
tenants = []
for tenant, user_count in rows:
tenants.append({"tenant": tenant, "user_count": user_count})
return tenants
async def get_tenant(db: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
return result.scalar_one_or_none()
async def create_tenant(db: AsyncSession, name: str, slug: str, is_active: bool = True) -> Tenant:
tenant = Tenant(name=name, slug=slug, is_active=is_active)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant
async def update_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
name: str | None = None,
slug: str | None = None,
is_active: bool | None = None,
) -> Tenant | None:
tenant = await get_tenant(db, tenant_id)
if not tenant:
return None
if name is not None:
tenant.name = name
if slug is not None:
tenant.slug = slug
if is_active is not None:
tenant.is_active = is_active
await db.commit()
await db.refresh(tenant)
return tenant
async def delete_tenant(db: AsyncSession, tenant_id: uuid.UUID) -> bool:
"""Delete a tenant. Returns False if tenant has users or does not exist."""
tenant = await get_tenant(db, tenant_id)
if not tenant:
return False
# Check for users
result = await db.execute(
select(func.count(User.id)).where(User.tenant_id == tenant_id)
)
user_count = result.scalar_one()
if user_count > 0:
return False
await db.delete(tenant)
await db.commit()
return True