67372d6c58
- auth/gdpr.py: dodan @me_router.post('/request-deletion') alias
koji proxy-a na request_erasure (Art. 17). Koristi pravi EraseReq pydantic.
- static/app.html: obrisana placeholder profileDeleteAccount funkcija
na liniji 944 (M10 mock alert) — sada samo real implementacija na 1902.
- E2E verified: damir@pgz.hr → POST /users/me/request-deletion → 200,
DB row pgz_sport.gdpr_erasure_requests #1 pending.
Tag: P0-demo-fix
263 lines
12 KiB
Python
263 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.post("/request-deletion")
|
|
def me_request_deletion(req: EraseReq, request: Request, user = Depends(require_user)):
|
|
"""Frontend alias for /gdpr-erase (R7 — Art. 17 erasure request)."""
|
|
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}
|