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:
156
apps/api/src/services/config_resolver.py
Normal file
156
apps/api/src/services/config_resolver.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Configuration hierarchy resolver.
|
||||
|
||||
Resolves site configuration by merging:
|
||||
System Defaults → Org Defaults → Site Group Defaults → Site Config → Regional Overrides
|
||||
|
||||
Produces a fully resolved public config suitable for the banner script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# System-level defaults (hard-coded, lowest priority)
|
||||
SYSTEM_DEFAULTS: dict[str, Any] = {
|
||||
"blocking_mode": "opt_in",
|
||||
"tcf_enabled": False,
|
||||
"gpp_enabled": True,
|
||||
"gpp_supported_apis": ["usnat"],
|
||||
"gpc_enabled": True,
|
||||
"gpc_jurisdictions": ["US-CA", "US-CO", "US-CT", "US-TX", "US-MT"],
|
||||
"gpc_global_honour": False,
|
||||
"gcm_enabled": True,
|
||||
"shopify_privacy_enabled": False,
|
||||
"gcm_default": {
|
||||
"ad_storage": "denied",
|
||||
"ad_user_data": "denied",
|
||||
"ad_personalization": "denied",
|
||||
"analytics_storage": "denied",
|
||||
"functionality_storage": "denied",
|
||||
"personalization_storage": "denied",
|
||||
"security_storage": "granted",
|
||||
},
|
||||
"banner_config": None,
|
||||
"privacy_policy_url": None,
|
||||
"terms_url": None,
|
||||
"consent_expiry_days": 365,
|
||||
}
|
||||
|
||||
|
||||
def resolve_config(
|
||||
site_config: dict[str, Any],
|
||||
org_defaults: dict[str, Any] | None = None,
|
||||
group_defaults: dict[str, Any] | None = None,
|
||||
region: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve the full configuration by merging layers.
|
||||
|
||||
Args:
|
||||
site_config: Site-specific configuration from the database.
|
||||
org_defaults: Organisation-level default overrides (optional).
|
||||
group_defaults: Site-group-level default overrides (optional).
|
||||
region: ISO region code for regional mode override (optional).
|
||||
|
||||
Returns:
|
||||
Fully resolved configuration dictionary.
|
||||
"""
|
||||
# Start with system defaults
|
||||
resolved = {**SYSTEM_DEFAULTS}
|
||||
|
||||
# Apply organisation defaults (if any)
|
||||
if org_defaults:
|
||||
_merge_non_none(resolved, org_defaults)
|
||||
|
||||
# Apply site group defaults (if any)
|
||||
if group_defaults:
|
||||
_merge_non_none(resolved, group_defaults)
|
||||
|
||||
# Apply site-specific config
|
||||
_merge_non_none(resolved, site_config)
|
||||
|
||||
# Apply regional blocking mode override
|
||||
if region and site_config.get("regional_modes"):
|
||||
regional_modes = site_config["regional_modes"]
|
||||
if isinstance(regional_modes, dict):
|
||||
# Try exact match first, then fall back to DEFAULT
|
||||
regional_mode = regional_modes.get(region) or regional_modes.get("DEFAULT")
|
||||
if regional_mode:
|
||||
resolved["blocking_mode"] = regional_mode
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def build_public_config(
|
||||
site_id: str,
|
||||
resolved: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a public configuration JSON for the banner script.
|
||||
|
||||
Strips internal fields and adds the site_id for identification.
|
||||
"""
|
||||
return {
|
||||
"id": resolved.get("id", ""),
|
||||
"site_id": site_id,
|
||||
"blocking_mode": resolved["blocking_mode"],
|
||||
"regional_modes": resolved.get("regional_modes"),
|
||||
"tcf_enabled": resolved["tcf_enabled"],
|
||||
"gpp_enabled": resolved["gpp_enabled"],
|
||||
"gpp_supported_apis": resolved.get("gpp_supported_apis"),
|
||||
"gpc_enabled": resolved["gpc_enabled"],
|
||||
"gpc_jurisdictions": resolved.get("gpc_jurisdictions"),
|
||||
"gpc_global_honour": resolved["gpc_global_honour"],
|
||||
"gcm_enabled": resolved["gcm_enabled"],
|
||||
"gcm_default": resolved.get("gcm_default"),
|
||||
"shopify_privacy_enabled": resolved["shopify_privacy_enabled"],
|
||||
"banner_config": resolved.get("banner_config"),
|
||||
"privacy_policy_url": resolved.get("privacy_policy_url"),
|
||||
"terms_url": resolved.get("terms_url"),
|
||||
"consent_expiry_days": resolved["consent_expiry_days"],
|
||||
"consent_group_id": resolved.get("consent_group_id"),
|
||||
"ab_test": resolved.get("ab_test"),
|
||||
}
|
||||
|
||||
|
||||
CONFIG_FIELDS = (
|
||||
"blocking_mode",
|
||||
"regional_modes",
|
||||
"tcf_enabled",
|
||||
"tcf_publisher_cc",
|
||||
"gpp_enabled",
|
||||
"gpp_supported_apis",
|
||||
"gpc_enabled",
|
||||
"gpc_jurisdictions",
|
||||
"gpc_global_honour",
|
||||
"gcm_enabled",
|
||||
"gcm_default",
|
||||
"shopify_privacy_enabled",
|
||||
"banner_config",
|
||||
"privacy_policy_url",
|
||||
"terms_url",
|
||||
"consent_expiry_days",
|
||||
)
|
||||
|
||||
|
||||
def orm_to_config_dict(obj: Any, *, include_id: bool = False) -> dict[str, Any]:
|
||||
"""Convert a SiteConfig or OrgConfig ORM object to a dict of config fields.
|
||||
|
||||
Only includes fields that are explicitly set (not NULL). This allows the
|
||||
hierarchy to work correctly: unset fields at higher-priority layers don't
|
||||
block inheritance from lower-priority layers.
|
||||
"""
|
||||
d: dict[str, Any] = {}
|
||||
if include_id and hasattr(obj, "id"):
|
||||
d["id"] = str(obj.id)
|
||||
for field in CONFIG_FIELDS:
|
||||
if hasattr(obj, field):
|
||||
value = getattr(obj, field)
|
||||
if value is not None:
|
||||
d[field] = value
|
||||
return d
|
||||
|
||||
|
||||
def _merge_non_none(target: dict[str, Any], source: dict[str, Any]) -> None:
|
||||
"""Merge source into target, skipping None values in source."""
|
||||
for key, value in source.items():
|
||||
if value is not None:
|
||||
target[key] = value
|
||||
Reference in New Issue
Block a user