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

View File

@@ -0,0 +1,125 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
from src.extensions.registry import get_registry
from src.models.consent import ConsentRecord
from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.consent import (
ConsentRecordCreate,
ConsentRecordResponse,
ConsentVerifyResponse,
)
from src.services.dependencies import require_role
from src.services.pseudonymisation import pseudonymise
router = APIRouter(prefix="/consent", tags=["consent"])
@router.post("/", response_model=ConsentRecordResponse, status_code=status.HTTP_201_CREATED)
async def record_consent(
body: ConsentRecordCreate,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ConsentRecord:
"""Record a consent event from the banner. Public endpoint (no auth required)."""
# Pseudonymise IP and user agent with HMAC so the resulting values
# cannot be reversed without the server-side secret.
client_ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
record = ConsentRecord(
site_id=body.site_id,
visitor_id=body.visitor_id,
ip_hash=pseudonymise(client_ip),
user_agent_hash=pseudonymise(user_agent),
action=body.action,
categories_accepted=body.categories_accepted,
categories_rejected=body.categories_rejected,
tc_string=body.tc_string,
gcm_state=body.gcm_state,
page_url=body.page_url,
country_code=body.country_code,
region_code=body.region_code,
)
db.add(record)
await db.flush()
await db.refresh(record)
# Invoke any registered post-record hooks (EE consent receipts, etc.)
for hook in get_registry().consent_record_hooks:
await hook(db, record)
return record
async def _load_record_for_org(
consent_id: uuid.UUID,
current_user: CurrentUser,
db: AsyncSession,
) -> ConsentRecord:
"""Load a consent record and enforce tenant isolation.
The record's site must belong to the caller's organisation. A record
from another tenant returns 404 rather than 403 so we don't leak
existence across tenants.
"""
stmt = (
select(ConsentRecord)
.join(Site, Site.id == ConsentRecord.site_id)
.where(
ConsentRecord.id == consent_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
record = (await db.execute(stmt)).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Consent record not found",
)
return record
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
async def get_consent(
consent_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> ConsentRecord:
"""Retrieve a consent record by ID.
Requires authentication and tenant membership. Consent records
contain PII-adjacent data (hashed IP, page URL, category decisions)
and must not be readable by anyone holding a record UUID.
"""
return await _load_record_for_org(consent_id, current_user, db)
@router.get("/verify/{consent_id}", response_model=ConsentVerifyResponse)
async def verify_consent(
consent_id: uuid.UUID,
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verify that a consent record exists (audit proof).
Same tenant-scoped auth as :func:`get_consent` — proof of consent
is only meaningful to the organisation that owns the site, and
leaking existence to arbitrary callers enables enumeration.
"""
record = await _load_record_for_org(consent_id, current_user, db)
return {
"id": record.id,
"site_id": record.site_id,
"visitor_id": record.visitor_id,
"action": record.action,
"categories_accepted": record.categories_accepted,
"consented_at": record.consented_at,
"valid": True,
}