feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

210
apps/api/src/main.py Normal file
View File

@@ -0,0 +1,210 @@
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.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
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 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()