Files
consentos/apps/api/src/extensions/registry.py
James Cottrill fbf26453f2 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.
2026-04-14 09:18:18 +00:00

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