Files
consentos/apps/api/src/main.py
Kunthawat Greethong 27a3e777ae
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
fix: wildcard CORS for public banner API endpoints
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.
2026-06-15 21:12:59 +07:00

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()