Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
+19
-11
@@ -8,17 +8,20 @@ ansprechpartner (company employees / contact persons).
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
ForeignKey,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
Float,
|
||||
JSON,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -37,6 +40,8 @@ class Contact(Base, TenantMixin):
|
||||
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
|
||||
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||
Index("ix_contacts_tenant_type", "tenant_id", "type"),
|
||||
Index("ix_contacts_tenant_name", "tenant_id", "name"),
|
||||
@@ -53,10 +58,13 @@ class Contact(Base, TenantMixin):
|
||||
# ── Identity & Type ──
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
|
||||
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
|
||||
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
|
||||
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
|
||||
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
@@ -116,12 +124,12 @@ class Contact(Base, TenantMixin):
|
||||
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
|
||||
|
||||
# ── Discounts ──
|
||||
discount_crew: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_transport: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_rental: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_sale: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_subrent: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||
|
||||
# ── Geo ──
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
@@ -151,7 +159,7 @@ class Contact(Base, TenantMixin):
|
||||
)
|
||||
|
||||
# ── Custom fields ──
|
||||
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||
|
||||
# ── FTS ──
|
||||
search_tsv: Mapped[Any] = mapped_column(
|
||||
@@ -222,7 +230,7 @@ class ContactPerson(Base, TenantMixin):
|
||||
|
||||
# ── Other ──
|
||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||
|
||||
# ── Audit ──
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""SQLAlchemy model for the transactional event outbox table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class EventOutbox(Base):
|
||||
"""Row in the ``event_outbox`` table.
|
||||
|
||||
Each row represents a domain event that was written within a business
|
||||
transaction and is waiting to be published to the in-process event bus
|
||||
by the outbox worker.
|
||||
"""
|
||||
|
||||
__tablename__ = "event_outbox"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=False, index=True,
|
||||
)
|
||||
event_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default="pending",
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0",
|
||||
)
|
||||
max_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="5",
|
||||
)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
+19
-14
@@ -6,36 +6,28 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.core.db import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class User(Base, TenantMixin):
|
||||
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""User entity — globally unique email, tenant membership via UserTenant."""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
is_system_admin: Mapped[bool] = mapped_column(
|
||||
@@ -44,7 +36,13 @@ class User(Base, TenantMixin):
|
||||
|
||||
|
||||
class UserTenant(Base):
|
||||
"""N:M association — user membership in tenants."""
|
||||
"""N:M association — user membership in tenants.
|
||||
|
||||
Single source of truth for tenant membership and role assignment.
|
||||
``role`` is a built-in role string (admin/editor/viewer).
|
||||
``role_id`` links to a custom Role record for granular RBAC.
|
||||
``status`` tracks membership lifecycle (active/invited/disabled).
|
||||
"""
|
||||
|
||||
__tablename__ = "user_tenants"
|
||||
|
||||
@@ -55,12 +53,19 @@ class UserTenant(Base):
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user