T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 6520e88d53
commit 3ab4925783
137 changed files with 3866 additions and 10195 deletions
+34 -33
View File
@@ -1,48 +1,49 @@
"""User model with role enum and soft-delete."""
"""User and UserTenant models."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.org import Org
from app.core.db import Base, TenantMixin
class UserRole(str, Enum):
"""User role for RBAC."""
class User(Base, TenantMixin):
"""User entity — belongs to a tenant, can be member of multiple tenants."""
admin = "admin"
sales_manager = "sales_manager"
sales_rep = "sales_rep"
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
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)
name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(
String(32),
nullable=False,
default=UserRole.sales_rep,
server_default=UserRole.sales_rep.value,
)
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
email_notifications: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1"
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
# Relationship back to org (string reference avoids circular import at runtime)
org: Mapped[Org] = relationship(back_populates="users", lazy="joined")
def __repr__(self) -> str:
return f"<User id={self.id} email={self.email!r} role={self.role}>"
class UserTenant(Base):
"""N:M association — user membership in tenants."""
__tablename__ = "user_tenants"
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)