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
+21 -174
View File
@@ -1,200 +1,47 @@
"""FastAPI application entry point.
Wires up:
- CORS middleware (whitelist, NO wildcard)
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
- Startup/shutdown events (DB connection check, log level)
- Versioned API router mounts under /api/v1
- Root-level /health endpoint for Coolify healthcheck
"""
"""FastAPI application - LeoCRM backend."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app import __version__
from app.api.v1 import (
accounts,
activities,
auth,
contacts,
dashboard,
deals,
health,
notes,
tags,
users,
)
from app.core.config import get_settings
from app.core.db import AsyncSessionLocal, dispose_engine
logger = logging.getLogger(__name__)
# === Lifespan ===
from app.config import get_settings
from app.core.middleware import CSRFMiddleware
from app.core.db import close_engine
from app.routes import auth, users, roles, tenants, health, notifications, companies
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks."""
settings = get_settings()
logging.basicConfig(level=settings.LOG_LEVEL)
logger.info(
"Starting CRM System v%s in %s mode",
__version__,
settings.ENVIRONMENT,
)
# Verify DB connection on startup
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
except Exception as e:
logger.error("Database connection FAILED on startup: %s", e)
# Don't crash — let /health report the issue so Coolify can restart
"""Application lifespan: startup and shutdown."""
yield
# Shutdown
logger.info("Shutting down CRM System")
await dispose_engine()
# === App ===
await close_engine()
def create_app() -> FastAPI:
"""Application factory (allows easy testing override)."""
"""Create and configure the FastAPI application."""
settings = get_settings()
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
app = FastAPI(
title="CRM System",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
# === CORS ===
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
max_age=3600,
)
app.add_middleware(CSRFMiddleware)
# === Security Headers ===
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
response = await call_next(request)
settings_local = get_settings()
if settings_local.is_production:
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
csp = (
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
else:
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
response.headers["Content-Security-Policy"] = csp
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
if settings_local.is_production:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# === Exception Handlers ===
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""Format HTTPException responses consistently."""
# Distinguish token-expired for FR-1.7 acceptance criterion
if exc.status_code == 401:
detail = exc.detail
if detail == "Could not validate credentials":
# Token invalid or expired — callers can detect this
return JSONResponse(
status_code=exc.status_code,
content={"detail": "token_expired_or_invalid"},
headers=exc.headers,
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Format Pydantic validation errors consistently."""
from fastapi.encoders import jsonable_encoder
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
"""Log DB errors and return a 500 without leaking internals."""
logger.exception("Database error on %s %s", request.method, request.url)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Database error"},
)
# === Routers ===
# Health is mounted at both /health (root) and /api/v1/health (versioned)
app.include_router(health.router)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
# Phase 4b business routers
app.include_router(accounts.router, prefix="/api/v1")
app.include_router(contacts.router, prefix="/api/v1")
app.include_router(deals.router, prefix="/api/v1")
app.include_router(activities.router, prefix="/api/v1")
app.include_router(notes.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
# === Static Files (Frontend) ===
# Mount app/webui/ on root AFTER all routers, so /api/v1/* still matches first.
# StaticFiles mit html=True servt index.html on `/` und alle .html files direkt.
webui_dir = Path(__file__).parent / "webui"
if webui_dir.exists():
app.mount("/", StaticFiles(directory=str(webui_dir), html=True), name="webui")
else:
logger.warning("webui directory not found at %s — frontend not served", webui_dir)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(roles.router)
app.include_router(tenants.router)
app.include_router(notifications.router)
app.include_router(companies.router)
return app