184 lines
6.1 KiB
Python
184 lines
6.1 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from sqlalchemy import func, 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,
|
|
ConsentRecordListResponse,
|
|
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("/", response_model=ConsentRecordListResponse)
|
|
async def list_consent_records(
|
|
site_id: uuid.UUID = Query(..., description="Filter by site"),
|
|
visitor_id: str | None = Query(None, description="Filter by visitor ID (exact match)"),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""List consent records for a site, with optional visitor_id filter.
|
|
|
|
Tenant-isolated — the site must belong to the caller's organisation.
|
|
Returns newest records first.
|
|
"""
|
|
# Verify site belongs to the caller's org.
|
|
site = (
|
|
await db.execute(
|
|
select(Site).where(
|
|
Site.id == site_id,
|
|
Site.organisation_id == current_user.organisation_id,
|
|
Site.deleted_at.is_(None),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if site is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
|
|
|
base = select(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
|
count_base = (
|
|
select(func.count()).select_from(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
|
)
|
|
|
|
if visitor_id:
|
|
base = base.where(ConsentRecord.visitor_id == visitor_id)
|
|
count_base = count_base.where(ConsentRecord.visitor_id == visitor_id)
|
|
|
|
total = await db.scalar(count_base) or 0
|
|
items = (
|
|
(
|
|
await db.execute(
|
|
base.order_by(ConsentRecord.consented_at.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"items": list(items),
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
|
|
|
|
@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,
|
|
}
|