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:
+21
-174
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user