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.
198 lines
6.1 KiB
Python
198 lines
6.1 KiB
Python
"""Extension registry for the open-core architecture.
|
|
|
|
Provides registration hooks that allow enterprise/commercial code to inject
|
|
routers, model modules, startup tasks, and OpenAPI tags into the core
|
|
application — without the core needing any direct knowledge of the
|
|
extensions.
|
|
|
|
In community edition (CE) mode, ``discover_extensions()`` is a no-op
|
|
because the ``ee`` package is not present.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, FastAPI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class OpenAPITag:
|
|
"""Metadata for a FastAPI OpenAPI tag."""
|
|
|
|
name: str
|
|
description: str
|
|
|
|
|
|
@dataclass
|
|
class RouterEntry:
|
|
"""A router registered by an extension."""
|
|
|
|
router: APIRouter
|
|
prefix: str = "/api/v1"
|
|
tags: list[OpenAPITag] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class ExtensionRegistry:
|
|
"""Central registry for extension-contributed components.
|
|
|
|
Extensions call the module-level helper functions (``register_router``,
|
|
``register_model_module``, etc.) which delegate to the singleton
|
|
instance stored in ``_registry``.
|
|
"""
|
|
|
|
routers: list[RouterEntry] = field(default_factory=list)
|
|
model_modules: list[str] = field(default_factory=list)
|
|
startup_hooks: list[Callable[[FastAPI], Coroutine[Any, Any, None]]] = field(
|
|
default_factory=list,
|
|
)
|
|
config_enrichers: list[Callable] = field(default_factory=list)
|
|
consent_record_hooks: list[Callable] = field(default_factory=list)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Registration helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def add_router(
|
|
self,
|
|
router: APIRouter,
|
|
*,
|
|
prefix: str = "/api/v1",
|
|
tags: list[OpenAPITag] | None = None,
|
|
) -> None:
|
|
self.routers.append(RouterEntry(router=router, prefix=prefix, tags=tags or []))
|
|
|
|
def add_model_module(self, module_path: str) -> None:
|
|
self.model_modules.append(module_path)
|
|
|
|
def add_startup_hook(
|
|
self,
|
|
hook: Callable[[FastAPI], Coroutine[Any, Any, None]],
|
|
) -> None:
|
|
self.startup_hooks.append(hook)
|
|
|
|
def add_config_enricher(self, enricher: Callable) -> None:
|
|
self.config_enrichers.append(enricher)
|
|
|
|
def add_consent_record_hook(self, hook: Callable) -> None:
|
|
self.consent_record_hooks.append(hook)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Application wiring
|
|
# ------------------------------------------------------------------
|
|
|
|
def apply(self, app: FastAPI) -> None:
|
|
"""Mount all registered routers and tags onto *app*."""
|
|
for entry in self.routers:
|
|
# Inject OpenAPI tags
|
|
for tag in entry.tags:
|
|
existing = app.openapi_tags or []
|
|
if not any(t["name"] == tag.name for t in existing):
|
|
existing.append(
|
|
{"name": tag.name, "description": tag.description},
|
|
)
|
|
app.openapi_tags = existing
|
|
|
|
app.include_router(entry.router, prefix=entry.prefix)
|
|
|
|
if self.routers:
|
|
logger.info(
|
|
"Registered %d extension router(s)",
|
|
len(self.routers),
|
|
)
|
|
|
|
# Import model modules so SQLAlchemy picks them up
|
|
for mod in self.model_modules:
|
|
importlib.import_module(mod)
|
|
|
|
if self.model_modules:
|
|
logger.info(
|
|
"Registered %d extension model module(s)",
|
|
len(self.model_modules),
|
|
)
|
|
|
|
|
|
# Singleton ------------------------------------------------------------------
|
|
|
|
_registry = ExtensionRegistry()
|
|
|
|
|
|
def get_registry() -> ExtensionRegistry:
|
|
"""Return the global extension registry."""
|
|
return _registry
|
|
|
|
|
|
# Convenience module-level API -----------------------------------------------
|
|
|
|
|
|
def register_router(
|
|
router: APIRouter,
|
|
*,
|
|
prefix: str = "/api/v1",
|
|
tags: list[OpenAPITag] | None = None,
|
|
) -> None:
|
|
"""Register an API router to be mounted at startup."""
|
|
_registry.add_router(router, prefix=prefix, tags=tags)
|
|
|
|
|
|
def register_model_module(module_path: str) -> None:
|
|
"""Register a dotted module path whose SQLAlchemy models should be imported."""
|
|
_registry.add_model_module(module_path)
|
|
|
|
|
|
def register_startup_hook(
|
|
hook: Callable[[FastAPI], Coroutine[Any, Any, None]],
|
|
) -> None:
|
|
"""Register an async callable to run during application startup."""
|
|
_registry.add_startup_hook(hook)
|
|
|
|
|
|
def register_config_enricher(enricher: Callable) -> None:
|
|
"""Register a callable that enriches published config.
|
|
|
|
The callable signature is ``async (site_id: UUID, db: AsyncSession, config: dict) -> None``.
|
|
It should mutate *config* in-place to add extension-specific data
|
|
(e.g. A/B test variants).
|
|
"""
|
|
_registry.add_config_enricher(enricher)
|
|
|
|
|
|
def register_consent_record_hook(hook: Callable) -> None:
|
|
"""Register a callable invoked after a consent record is persisted.
|
|
|
|
The callable signature is ``async (db: AsyncSession, consent_record) -> None``.
|
|
It is called from ``POST /api/v1/consent`` after the record has been
|
|
flushed to the database. Typical use: generating a consent receipt
|
|
(EE), writing audit logs, firing webhooks.
|
|
"""
|
|
_registry.add_consent_record_hook(hook)
|
|
|
|
|
|
# Discovery ------------------------------------------------------------------
|
|
|
|
|
|
def discover_extensions() -> None:
|
|
"""Import the EE registration module if installed.
|
|
|
|
Enterprise edition is distributed as a separate ``consent-enterprise``
|
|
package. When installed in the same environment, importing
|
|
``ee.api.src.register`` triggers its side-effect registrations. In
|
|
community edition the import simply fails and we carry on.
|
|
"""
|
|
try:
|
|
import ee.api.src.register # noqa: F401
|
|
|
|
logger.info("Enterprise extensions loaded")
|
|
except ImportError:
|
|
logger.debug("No enterprise extensions found (CE mode)")
|