Files
pgz-sport/auth/gdpr.py
T
Damir Radulić a0db65fc31 CC2 R4 #4: /api/users/me/gdpr-export alias
- New auth.gdpr.me_router prefix /api/users/me with:
  - GET/POST /gdpr-export → Art.20 JSON download with Content-Disposition
  - POST /gdpr-erase → Art.17 erasure request
  - GET /gdpr-consent → consent history for caller
- jsonable_encoder fixes datetime serialisation in JSONResponse
- admin_users.html: 'Izvezi moje podatke' now POSTs to alias and uses
  filename from Content-Disposition header
- 401 enforced on no-auth, 200 on valid Bearer (verified live)
2026-05-05 00:47:22 +02:00

258 lines
12 KiB
Python

#!/usr/bin/env python3
# gdpr.py — GDPR endpoints: export, erasure, consent, audit (M10)
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
"""
GET /api/gdpr/export (current user — Art. 20 portability)
POST /api/gdpr/erase (current user — Art. 17 erasure request)
POST /api/gdpr/consent (cookie / processing consent log)
GET /api/gdpr/consent
GET /api/gdpr/policy (returns text URL/markdown)
GET /api/admin/gdpr/erasure-requests (PGŽ admin)
POST /api/admin/gdpr/erasure-requests/{id}/process
"""
import json
from datetime import datetime
from typing import Optional, Dict, List
from fastapi import APIRouter, HTTPException, Depends, Request, Body
from pydantic import BaseModel
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from .auth_v2 import (
db_query, db_one, db_exec,
require_user, audit, _client,
)
from .admin_users import _is_pgz_admin
router = APIRouter(prefix="/api/gdpr", tags=["gdpr"])
admin_router = APIRouter(prefix="/api/admin/gdpr", tags=["gdpr_admin"])
me_router = APIRouter(prefix="/api/users/me", tags=["users_me_gdpr"])
# Ensure GDPR tables exist (idempotent)
def _ensure_tables():
try:
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_consent (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
session_id TEXT,
ip TEXT,
necessary BOOLEAN DEFAULT true,
analytics BOOLEAN DEFAULT false,
marketing BOOLEAN DEFAULT false,
consent_at TIMESTAMPTZ DEFAULT now(),
policy_version TEXT DEFAULT 'v1',
user_agent TEXT
)""")
db_exec("""CREATE INDEX IF NOT EXISTS idx_gdpr_consent_user ON pgz_sport.gdpr_consent(user_id)""")
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_erasure_requests (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
email TEXT,
requested_at TIMESTAMPTZ DEFAULT now(),
reason TEXT,
status TEXT DEFAULT 'pending', -- pending|approved|denied|completed
processed_by INTEGER REFERENCES pgz_sport.users(id),
processed_at TIMESTAMPTZ,
note TEXT
)""")
db_exec("""ALTER TABLE pgz_sport.users
ADD COLUMN IF NOT EXISTS gdpr_consent_at TIMESTAMPTZ""")
except Exception as e:
print(f"[GDPR migration WARN] {e}")
_ensure_tables()
POLICY_VERSION = "v1"
# ─────────────────────────── Cookie / consent ───────────────────────────
class ConsentReq(BaseModel):
necessary: bool = True
analytics: bool = False
marketing: bool = False
session_id: Optional[str] = None
policy_version: Optional[str] = None
@router.post("/consent")
def post_consent(req: ConsentReq, request: Request):
"""Record a consent event. Works for anonymous (session_id only) or logged-in users."""
user = None
auth = request.headers.get("authorization")
if auth:
from .auth_v2 import get_current_user
user = get_current_user(authorization=auth)
ip, ua = _client(request)
uid = user["id"] if user else None
db_exec("""INSERT INTO pgz_sport.gdpr_consent
(user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(uid, req.session_id, ip, req.necessary, req.analytics, req.marketing,
req.policy_version or POLICY_VERSION, ua))
if uid:
db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=now() WHERE id=%s", (uid,))
audit(uid, "gdpr.consent", meta={
"necessary": req.necessary, "analytics": req.analytics,
"marketing": req.marketing, "session_id": req.session_id}, ip=ip, ua=ua)
return {"status": "ok", "policy_version": POLICY_VERSION}
@router.get("/consent")
def get_consent(user = Depends(require_user)):
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version, ip, session_id
FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
return {"current": rows[0] if rows else None, "history": rows}
@router.get("/policy")
def get_policy():
return {
"version": POLICY_VERSION,
"url": "https://api.rinet.one/sport/static/privacy.html",
"rights": [
"Art. 15 — Pravo na pristup",
"Art. 16 — Pravo na ispravak",
"Art. 17 — Pravo na brisanje",
"Art. 18 — Pravo na ograničenje obrade",
"Art. 20 — Pravo na prenosivost podataka",
"Art. 21 — Pravo na prigovor",
],
"controller": "Primorsko-goranska županija — Odjel za sport",
"contact": "gdpr@pgz.hr",
"dpo": "Damir Radulić (damir@rinet.one)",
}
# ─────────────────────────── Article 20 — data export ───────────────────────────
@router.get("/export")
def export_my_data(user = Depends(require_user)):
"""Return all data we hold about the calling user — JSON dump."""
uid = user["id"]
profile = db_one("""SELECT id, email, full_name, ime, prezime, oib, telefon, phone,
user_type, klub_id, savez_id, status, aktivan, last_login, created_at,
preferred_language, gdpr_consent_at
FROM pgz_sport.users WHERE id=%s""", (uid,))
sessions = db_query("""SELECT id, device_info, ip_address::text AS ip,
created_at, expires_at, revoked
FROM pgz_sport.user_sessions WHERE user_id=%s ORDER BY created_at DESC""", (uid,))
audit_rows = db_query("""SELECT id, action, resource_type, resource_id,
ts AS created_at, ip_address::text AS ip, user_agent, meta
FROM pgz_sport.audit_events WHERE user_id=%s ORDER BY ts DESC LIMIT 1000""", (uid,))
consent = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC""", (uid,))
klub_links = db_query("""SELECT klub_id, savez_id, link_type, role,
primary_klub, granted_at, od_datuma, do_datuma
FROM pgz_sport.user_klub_links WHERE user_id=%s""", (uid,))
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id,
ur.granted_at, ur.expires_at, ur.active
FROM pgz_sport.user_roles ur
JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s""", (uid,))
audit(uid, "gdpr.export")
return {
"exported_at": datetime.utcnow().isoformat() + "Z",
"policy_version": POLICY_VERSION,
"subject": profile,
"sessions": sessions,
"audit_events": audit_rows,
"consent_history": consent,
"klub_links": klub_links,
"roles": roles,
}
# ─────────────────────────── Article 17 — erasure request ───────────────────────────
class EraseReq(BaseModel):
reason: Optional[str] = None
confirm_email: Optional[str] = None
@router.post("/erase")
def request_erasure(req: EraseReq, request: Request, user = Depends(require_user)):
if req.confirm_email and req.confirm_email.lower().strip() != user["email"].lower():
raise HTTPException(400, "confirm_email se ne poklapa")
ip, ua = _client(request)
new_id = db_one("""INSERT INTO pgz_sport.gdpr_erasure_requests
(user_id, email, reason, status) VALUES (%s,%s,%s,'pending') RETURNING id""",
(user["id"], user["email"], req.reason))["id"]
audit(user["id"], "gdpr.erasure.request", "user", user["id"],
{"reason": req.reason}, ip, ua)
return {"status": "ok", "request_id": new_id,
"message": "Vaš zahtjev je zaprimljen i bit će obrađen unutar 30 dana."}
# ─────────────────────────── Admin: erasure queue ───────────────────────────
# ─────────────────────────── /api/users/me alias (R4 #4) ───────────────────────────
@me_router.get("/gdpr-export")
@me_router.post("/gdpr-export")
def me_gdpr_export(user = Depends(require_user)):
"""GDPR Art. 20 — JSON export of all data we hold about the caller.
Same payload as GET /api/gdpr/export, exposed at user-friendly path.
Returns Content-Disposition: attachment so browsers offer a download."""
payload = export_my_data(user=user)
fn = f"pgz_data_export_{user['id']}_{int(datetime.utcnow().timestamp())}.json"
return JSONResponse(jsonable_encoder(payload),
headers={"Content-Disposition": f'attachment; filename="{fn}"'})
@me_router.post("/gdpr-erase")
def me_gdpr_erase(req: 'EraseReq', request: Request, user = Depends(require_user)):
return request_erasure(req=req, request=request, user=user)
@me_router.get("/gdpr-consent")
def me_gdpr_consent(user = Depends(require_user)):
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
policy_version, ip, session_id
FROM pgz_sport.gdpr_consent WHERE user_id=%s
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
return {"current": rows[0] if rows else None, "history": rows}
# ─────────────────────────── Admin: erasure queue ───────────────────────────
@admin_router.get("/erasure-requests")
def list_erasure_requests(status: Optional[str] = None,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
raise HTTPException(403, "PGŽ admin only")
where, args = ["1=1"], []
if status: where.append("er.status=%s"); args.append(status)
rows = db_query(f"""SELECT er.id, er.user_id, er.email, er.requested_at,
er.reason, er.status, er.processed_by, er.processed_at, er.note,
u.full_name
FROM pgz_sport.gdpr_erasure_requests er
LEFT JOIN pgz_sport.users u ON u.id=er.user_id
WHERE {' AND '.join(where)}
ORDER BY er.requested_at DESC""", tuple(args))
return {"count": len(rows), "results": rows}
class ProcessEraseReq(BaseModel):
decision: str # 'approve' | 'deny'
note: Optional[str] = None
anonymize: bool = True
@admin_router.post("/erasure-requests/{rid}/process")
def process_erasure(rid: int, req: ProcessEraseReq, request: Request,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
raise HTTPException(403, "PGŽ admin only")
er = db_one("SELECT * FROM pgz_sport.gdpr_erasure_requests WHERE id=%s", (rid,))
if not er: raise HTTPException(404, "Request not found")
if er["status"] != "pending":
raise HTTPException(400, f"Already {er['status']}")
if req.decision == "approve":
if req.anonymize and er["user_id"]:
db_exec("""UPDATE pgz_sport.users SET
email = CONCAT('erased-', id, '@anonymous.gdpr'),
full_name = 'Erased',
ime = NULL, prezime = NULL, oib = NULL,
telefon = NULL, phone = NULL, password_hash = NULL,
aktivan = false, status = 'erased',
google_sub = NULL, google_picture = NULL,
updated_at = now()
WHERE id=%s""", (er["user_id"],))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
(er["user_id"],))
new_status = "completed"
else:
new_status = "denied"
db_exec("""UPDATE pgz_sport.gdpr_erasure_requests
SET status=%s, processed_by=%s, processed_at=now(), note=%s
WHERE id=%s""", (new_status, actor["id"], req.note, rid))
ip, ua = _client(request)
audit(actor["id"], "gdpr.erasure.process", "user", er["user_id"] or 0,
{"request_id": rid, "decision": req.decision, "note": req.note}, ip, ua)
return {"status": new_status, "id": rid}