feat: GDPR compliance — anonymization, unsubscribe, consent tracking
- Add consent_given_at and anonymized_at fields + Alembic migration (002) - Add GET /waitlist/unsubscribe endpoint (HMAC token, anonymizes PII) - Add cleanup.py: cron-able script to anonymize unconfirmed entries after 48h - Clear IP address on email confirmation (no longer needed) - Add unsubscribe link in confirmation email footer - Record consent timestamp on signup - Add 4 new tests (unsubscribe, consent timestamp) - Update .env.example, schemas
This commit is contained in:
25
alembic/versions/002_add_gdpr_fields.py
Normal file
25
alembic/versions/002_add_gdpr_fields.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""add consent_given_at and anonymized_at columns
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-04-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("waitlist_entries", sa.Column("consent_given_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("waitlist_entries", sa.Column("anonymized_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("waitlist_entries", "anonymized_at")
|
||||||
|
op.drop_column("waitlist_entries", "consent_given_at")
|
||||||
@@ -24,7 +24,7 @@ def _headers() -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def send_confirmation_email(email: str, confirm_url: str) -> bool:
|
async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url: str = "") -> bool:
|
||||||
"""Send a double opt-in confirmation email. Returns True on success."""
|
"""Send a double opt-in confirmation email. Returns True on success."""
|
||||||
if not settings.brevo_configured:
|
if not settings.brevo_configured:
|
||||||
logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3])
|
logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3])
|
||||||
@@ -37,7 +37,7 @@ async def send_confirmation_email(email: str, confirm_url: str) -> bool:
|
|||||||
},
|
},
|
||||||
"to": [{"email": email}],
|
"to": [{"email": email}],
|
||||||
"subject": "Confirm your spot on the adiuvAI waitlist",
|
"subject": "Confirm your spot on the adiuvAI waitlist",
|
||||||
"htmlContent": _confirmation_html(confirm_url),
|
"htmlContent": _confirmation_html(confirm_url, unsubscribe_url),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,7 +78,7 @@ async def add_contact_to_list(email: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _confirmation_html(confirm_url: str) -> str:
|
def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
|
||||||
"""Email template aligned with the adiuvAI landing page brand."""
|
"""Email template aligned with the adiuvAI landing page brand."""
|
||||||
return f"""\
|
return f"""\
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -211,6 +211,7 @@ def _confirmation_html(confirm_url: str) -> str:
|
|||||||
<p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;">
|
<p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;">
|
||||||
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
|
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
|
||||||
text-underline-offset:2px;">adiuvai.com</a>
|
text-underline-offset:2px;">adiuvai.com</a>
|
||||||
|
{f' · <a href="{unsubscribe_url}" style="color:#8a8ea9;text-decoration:underline;text-underline-offset:2px;">Unsubscribe</a>' if unsubscribe_url else ''}
|
||||||
</p>
|
</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
|
|||||||
58
app/cleanup.py
Normal file
58
app/cleanup.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Periodic cleanup: anonymize unconfirmed waitlist entries older than CONFIRM_TOKEN_EXPIRY_HOURS.
|
||||||
|
|
||||||
|
Run as a cron job:
|
||||||
|
python -m app.cleanup
|
||||||
|
|
||||||
|
Or integrate into docker-compose with a one-shot service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import async_session
|
||||||
|
from app.models import WaitlistEntry
|
||||||
|
from app.routes import _anonymize_entry
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def anonymize_expired() -> int:
|
||||||
|
"""Anonymize all unconfirmed entries past the token expiry window. Returns count."""
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
||||||
|
hours=settings.CONFIRM_TOKEN_EXPIRY_HOURS
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WaitlistEntry).where(
|
||||||
|
and_(
|
||||||
|
WaitlistEntry.confirmed == False, # noqa: E712
|
||||||
|
WaitlistEntry.anonymized_at == None, # noqa: E711
|
||||||
|
WaitlistEntry.created_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entries = result.scalars().all()
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
_anonymize_entry(entry)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
logger.info("Anonymized %d expired unconfirmed entries", len(entries))
|
||||||
|
else:
|
||||||
|
logger.info("No expired unconfirmed entries to anonymize")
|
||||||
|
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
count = asyncio.run(anonymize_expired())
|
||||||
|
raise SystemExit(0)
|
||||||
@@ -21,6 +21,12 @@ class WaitlistEntry(Base):
|
|||||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0"))
|
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0"))
|
||||||
|
consent_given_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
|
anonymized_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -31,6 +32,7 @@ async def join_waitlist(
|
|||||||
- Duplicate emails: idempotent — returns success without error.
|
- Duplicate emails: idempotent — returns success without error.
|
||||||
- Stores the Cloudflare-resolved client IP for analytics (not exposed).
|
- Stores the Cloudflare-resolved client IP for analytics (not exposed).
|
||||||
- Sends a confirmation email via Brevo (fire-and-forget).
|
- Sends a confirmation email via Brevo (fire-and-forget).
|
||||||
|
- Records consent timestamp (GDPR Art. 7).
|
||||||
"""
|
"""
|
||||||
# Honeypot — bots fill hidden fields; silently "succeed"
|
# Honeypot — bots fill hidden fields; silently "succeed"
|
||||||
if body.website:
|
if body.website:
|
||||||
@@ -38,6 +40,7 @@ async def join_waitlist(
|
|||||||
|
|
||||||
email = body.email.lower().strip()
|
email = body.email.lower().strip()
|
||||||
ip = _get_client_ip(request)
|
ip = _get_client_ip(request)
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
# Check for existing entry — idempotent
|
# Check for existing entry — idempotent
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
@@ -46,14 +49,20 @@ async def join_waitlist(
|
|||||||
entry = existing.scalar_one_or_none()
|
entry = existing.scalar_one_or_none()
|
||||||
|
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
# Re-send confirmation if not yet confirmed
|
# Re-send confirmation if not yet confirmed (and not anonymized)
|
||||||
if not entry.confirmed and settings.brevo_configured:
|
if not entry.confirmed and not entry.anonymized_at and settings.brevo_configured:
|
||||||
token = generate_token(email)
|
token = generate_token(email)
|
||||||
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
||||||
asyncio.create_task(send_confirmation_email(email, confirm_url))
|
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
|
||||||
|
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
|
||||||
return WaitlistResponse()
|
return WaitlistResponse()
|
||||||
|
|
||||||
entry = WaitlistEntry(email=email, ip_address=ip, source="website")
|
entry = WaitlistEntry(
|
||||||
|
email=email,
|
||||||
|
ip_address=ip,
|
||||||
|
source="website",
|
||||||
|
consent_given_at=now,
|
||||||
|
)
|
||||||
db.add(entry)
|
db.add(entry)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -63,7 +72,8 @@ async def join_waitlist(
|
|||||||
if settings.brevo_configured:
|
if settings.brevo_configured:
|
||||||
token = generate_token(email)
|
token = generate_token(email)
|
||||||
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
|
||||||
asyncio.create_task(send_confirmation_email(email, confirm_url))
|
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
|
||||||
|
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
|
||||||
|
|
||||||
return WaitlistResponse()
|
return WaitlistResponse()
|
||||||
|
|
||||||
@@ -92,6 +102,8 @@ async def confirm_email(
|
|||||||
|
|
||||||
if not entry.confirmed:
|
if not entry.confirmed:
|
||||||
entry.confirmed = True
|
entry.confirmed = True
|
||||||
|
# Clear IP now that consent is confirmed — no longer needed
|
||||||
|
entry.ip_address = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Email confirmed: %s***", email[:3])
|
logger.info("Email confirmed: %s***", email[:3])
|
||||||
|
|
||||||
@@ -102,9 +114,62 @@ async def confirm_email(
|
|||||||
return HTMLResponse(content=_result_page(success=True))
|
return HTMLResponse(content=_result_page(success=True))
|
||||||
|
|
||||||
|
|
||||||
def _result_page(*, success: bool) -> str:
|
@router.get("/waitlist/unsubscribe", response_class=HTMLResponse)
|
||||||
"""Branded HTML response for confirmation result, matching the adiuvAI landing page."""
|
async def unsubscribe(
|
||||||
if success:
|
token: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
GDPR erasure (Art. 17) — anonymize the entry.
|
||||||
|
Uses the same HMAC token system as confirmation.
|
||||||
|
"""
|
||||||
|
email = verify_token(token)
|
||||||
|
if email is None:
|
||||||
|
return HTMLResponse(
|
||||||
|
content=_result_page(success=False, variant="unsubscribe"),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(WaitlistEntry).where(WaitlistEntry.email == email)
|
||||||
|
)
|
||||||
|
entry = result.scalar_one_or_none()
|
||||||
|
if entry is None:
|
||||||
|
# Already gone — show success anyway (idempotent)
|
||||||
|
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
|
||||||
|
|
||||||
|
_anonymize_entry(entry)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Waitlist entry anonymized (unsubscribe)")
|
||||||
|
|
||||||
|
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
|
||||||
|
|
||||||
|
|
||||||
|
def _anonymize_entry(entry: WaitlistEntry) -> None:
|
||||||
|
"""Strip all PII from a waitlist entry, keeping only anonymous analytics."""
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
entry.email = f"anon-{entry.id}@removed.invalid"
|
||||||
|
entry.ip_address = None
|
||||||
|
entry.consent_given_at = None
|
||||||
|
entry.anonymized_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def _result_page(*, success: bool, variant: str = "confirm") -> str:
|
||||||
|
"""Branded HTML response for confirmation/unsubscribe result."""
|
||||||
|
if variant == "unsubscribe":
|
||||||
|
if success:
|
||||||
|
title = "Data removed"
|
||||||
|
message = "Your personal data has been anonymized. You will not receive any further emails from us."
|
||||||
|
icon_bg = "rgba(251,200,129,0.12)"
|
||||||
|
icon_border = "rgba(251,200,129,0.25)"
|
||||||
|
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||||
|
else:
|
||||||
|
title = "Invalid or expired link"
|
||||||
|
message = "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help."
|
||||||
|
icon_bg = "rgba(220,38,38,0.08)"
|
||||||
|
icon_border = "rgba(220,38,38,0.18)"
|
||||||
|
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
||||||
|
elif success:
|
||||||
title = "You’re confirmed!"
|
title = "You’re confirmed!"
|
||||||
message = "Your email has been verified. We’ll notify you when adiuvAI is ready."
|
message = "Your email has been verified. We’ll notify you when adiuvAI is ready."
|
||||||
icon_bg = "rgba(251,200,129,0.12)"
|
icon_bg = "rgba(251,200,129,0.12)"
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ async def test_token_tampered():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_confirm_valid_token(client, db_session):
|
async def test_confirm_valid_token(client, db_session):
|
||||||
"""GET /confirm with valid token marks email as confirmed."""
|
"""GET /confirm with valid token marks email as confirmed and clears IP."""
|
||||||
# Seed an unconfirmed entry
|
# Seed an unconfirmed entry
|
||||||
entry = WaitlistEntry(email="confirm@example.com", source="website")
|
entry = WaitlistEntry(email="confirm@example.com", source="website", ip_address="1.2.3.4")
|
||||||
db_session.add(entry)
|
db_session.add(entry)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
@@ -168,7 +168,9 @@ async def test_confirm_valid_token(client, db_session):
|
|||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
|
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
|
||||||
)
|
)
|
||||||
assert result.scalar_one().confirmed is True
|
confirmed_entry = result.scalar_one()
|
||||||
|
assert confirmed_entry.confirmed is True
|
||||||
|
assert confirmed_entry.ip_address is None # IP cleared on confirm
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -224,3 +226,64 @@ async def test_signup_triggers_confirmation_email(client, db_session):
|
|||||||
call_args = mock_send.call_args
|
call_args = mock_send.call_args
|
||||||
assert call_args[0][0] == "brevo@example.com"
|
assert call_args[0][0] == "brevo@example.com"
|
||||||
assert "confirm" in call_args[0][1]
|
assert "confirm" in call_args[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unsubscribe / anonymize tests ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_anonymizes_entry(client, db_session):
|
||||||
|
"""GET /unsubscribe with valid token anonymizes the entry."""
|
||||||
|
entry = WaitlistEntry(email="unsub@example.com", source="website", confirmed=True)
|
||||||
|
db_session.add(entry)
|
||||||
|
await db_session.commit()
|
||||||
|
entry_id = entry.id
|
||||||
|
|
||||||
|
token = generate_token("unsub@example.com")
|
||||||
|
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "removed" in resp.text.lower() or "anonymized" in resp.text.lower()
|
||||||
|
|
||||||
|
# Verify anonymization
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(WaitlistEntry).where(WaitlistEntry.id == entry_id)
|
||||||
|
)
|
||||||
|
anon = result.scalar_one()
|
||||||
|
assert anon.email == f"anon-{entry_id}@removed.invalid"
|
||||||
|
assert anon.ip_address is None
|
||||||
|
assert anon.consent_given_at is None
|
||||||
|
assert anon.anonymized_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_invalid_token(client):
|
||||||
|
"""GET /unsubscribe with invalid token returns 400."""
|
||||||
|
resp = await client.get("/api/v1/waitlist/unsubscribe?token=garbage")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_already_gone(client):
|
||||||
|
"""GET /unsubscribe for non-existent entry returns 200 (idempotent)."""
|
||||||
|
token = generate_token("gone@example.com")
|
||||||
|
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Consent timestamp tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signup_records_consent_timestamp(client, db_session):
|
||||||
|
"""New signup records consent_given_at."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/waitlist",
|
||||||
|
json={"email": "consent@example.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(WaitlistEntry).where(WaitlistEntry.email == "consent@example.com")
|
||||||
|
)
|
||||||
|
entry = result.scalar_one()
|
||||||
|
assert entry.consent_given_at is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user