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:
125
apps/api/src/routers/consent.py
Normal file
125
apps/api/src/routers/consent.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user