Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
Replace the fragile per-site dynamic CORS middleware with a public banner CORS middleware that allows non-credentialed wildcard CORS only for banner endpoints: - /api/v1/config/sites/* - /api/v1/translations/* - /api/v1/consent/ Admin/auth endpoints remain governed by the normal ALLOWED_ORIGINS based CORSMiddleware. Add regression tests for public GET/preflight behavior and for avoiding wildcard CORS on non-public endpoints.
217 lines
7.3 KiB
Python
217 lines
7.3 KiB
Python
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from src.config.edition import edition_name
|
|
from src.config.logging import setup_logging
|
|
from src.config.settings import get_settings
|
|
from src.extensions.registry import discover_extensions, get_registry
|
|
from src.middleware.public_banner_cors import PublicBannerCORSMiddleware
|
|
from src.middleware.rate_limit import RateLimitMiddleware
|
|
from src.middleware.security_headers import SecurityHeadersMiddleware
|
|
from src.routers import (
|
|
auth,
|
|
compliance,
|
|
config,
|
|
consent,
|
|
cookies,
|
|
org_config,
|
|
organisations,
|
|
scanner,
|
|
site_group_config,
|
|
site_groups,
|
|
sites,
|
|
translations,
|
|
users,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""Application startup and shutdown lifecycle."""
|
|
settings = get_settings()
|
|
setup_logging(settings.log_level)
|
|
yield
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""Application factory."""
|
|
settings = get_settings()
|
|
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.app_version,
|
|
description=(
|
|
"Multi-tenant cookie consent management platform API. "
|
|
"Provides consent collection, cookie scanning, auto-blocking, "
|
|
"compliance checking, and analytics across multiple sites."
|
|
),
|
|
debug=settings.debug,
|
|
lifespan=lifespan,
|
|
openapi_tags=[
|
|
{
|
|
"name": "auth",
|
|
"description": "Authentication — login, token refresh, and current user.",
|
|
},
|
|
{
|
|
"name": "config",
|
|
"description": (
|
|
"Site configuration — public endpoints for the banner script "
|
|
"to fetch config, GeoIP-resolved config, and CDN publishing."
|
|
),
|
|
},
|
|
{
|
|
"name": "consent",
|
|
"description": (
|
|
"Consent recording and retrieval — public endpoints called "
|
|
"by the banner script to record visitor consent decisions."
|
|
),
|
|
},
|
|
{
|
|
"name": "sites",
|
|
"description": "Site and site config CRUD — manage domains and settings.",
|
|
},
|
|
{
|
|
"name": "cookies",
|
|
"description": (
|
|
"Cookie management — categories, discovered cookies, allow-list, "
|
|
"known cookies database, and auto-classification."
|
|
),
|
|
},
|
|
{
|
|
"name": "scanner",
|
|
"description": (
|
|
"Cookie scanner — trigger scans, view results, and receive "
|
|
"client-side cookie reports from the banner script."
|
|
),
|
|
},
|
|
{
|
|
"name": "compliance",
|
|
"description": (
|
|
"Compliance checking — run checks against GDPR, CNIL, CCPA, "
|
|
"ePrivacy, and LGPD frameworks."
|
|
),
|
|
},
|
|
{
|
|
"name": "organisations",
|
|
"description": "Organisation management — multi-tenant root entities.",
|
|
},
|
|
{
|
|
"name": "users",
|
|
"description": "User management — org-scoped users with role-based access.",
|
|
},
|
|
],
|
|
)
|
|
|
|
# Security headers
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
# Rate limiting (must be added before CORS to count requests correctly)
|
|
if settings.rate_limit_enabled:
|
|
app.add_middleware(
|
|
RateLimitMiddleware,
|
|
redis_url=settings.redis_url,
|
|
requests_per_minute=settings.rate_limit_per_minute,
|
|
auth_requests_per_minute=10,
|
|
)
|
|
|
|
# CORS for admin/auth endpoints. Public banner endpoints get wildcard,
|
|
# non-credentialed CORS from PublicBannerCORSMiddleware below.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.allowed_origins_list,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Add this AFTER CORSMiddleware so it becomes the outermost middleware and
|
|
# can override/remove credentialed CORS headers for public banner endpoints.
|
|
app.add_middleware(PublicBannerCORSMiddleware)
|
|
|
|
# Core routers
|
|
api_prefix = "/api/v1"
|
|
app.include_router(auth.router, prefix=api_prefix)
|
|
app.include_router(config.router, prefix=api_prefix)
|
|
app.include_router(consent.router, prefix=api_prefix)
|
|
app.include_router(scanner.router, prefix=api_prefix)
|
|
app.include_router(compliance.router, prefix=api_prefix)
|
|
app.include_router(organisations.router, prefix=api_prefix)
|
|
app.include_router(org_config.router, prefix=api_prefix)
|
|
app.include_router(users.router, prefix=api_prefix)
|
|
app.include_router(site_groups.router, prefix=api_prefix)
|
|
app.include_router(site_group_config.router, prefix=api_prefix)
|
|
app.include_router(sites.router, prefix=api_prefix)
|
|
app.include_router(cookies.router, prefix=api_prefix)
|
|
app.include_router(translations.router, prefix=api_prefix)
|
|
app.include_router(translations.public_router, prefix=api_prefix)
|
|
|
|
# Discover and mount enterprise extensions (no-op in CE mode)
|
|
discover_extensions()
|
|
registry = get_registry()
|
|
registry.apply(app)
|
|
|
|
@app.get("/health", tags=["health"])
|
|
async def health() -> dict[str, str]:
|
|
"""Shallow liveness check.
|
|
|
|
Answers "is the process running?". Suitable for orchestrator
|
|
liveness probes. For deployment readiness, use
|
|
``/health/ready`` which verifies downstream dependencies.
|
|
"""
|
|
return {"status": "ok", "edition": edition_name()}
|
|
|
|
@app.get("/health/ready", tags=["health"])
|
|
async def health_ready() -> dict[str, object]:
|
|
"""Deep readiness check — verifies database and Redis.
|
|
|
|
Returns HTTP 503 if either dependency is unreachable so load
|
|
balancers route traffic away from broken instances.
|
|
"""
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import text
|
|
|
|
from src.db.session import engine as db_engine
|
|
|
|
checks: dict[str, str] = {}
|
|
overall_ok = True
|
|
|
|
# Database
|
|
try:
|
|
async with db_engine.connect() as conn:
|
|
await conn.execute(text("SELECT 1"))
|
|
checks["database"] = "ok"
|
|
except Exception as exc:
|
|
checks["database"] = f"error: {type(exc).__name__}"
|
|
overall_ok = False
|
|
|
|
# Redis
|
|
try:
|
|
import redis.asyncio as aioredis
|
|
|
|
r = aioredis.from_url(settings.redis_url, decode_responses=True)
|
|
pong = await r.ping()
|
|
checks["redis"] = "ok" if pong else "error: ping failed"
|
|
if not pong:
|
|
overall_ok = False
|
|
await r.aclose()
|
|
except Exception as exc:
|
|
checks["redis"] = f"error: {type(exc).__name__}"
|
|
overall_ok = False
|
|
|
|
payload = {
|
|
"status": "ok" if overall_ok else "degraded",
|
|
"edition": edition_name(),
|
|
"checks": checks,
|
|
}
|
|
if not overall_ok:
|
|
raise HTTPException(status_code=503, detail=payload)
|
|
return payload
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|