M1+M2+M10 (CC2 R3): JWT auth + admin users + GDPR backend
- auth/auth_v2.py: JWT login/refresh/logout/me + bcrypt + tenant_id/role/tier claims - auth/admin_users.py: /api/admin/users CRUD + invite/role/suspend + bulk CSV - auth/gdpr.py: cookie consent + Art.20 export + Art.17 erasure + admin queue - auth/seed_demo.py: 3 demo tenants + 4 users (damir@pgz.hr / PGZ2026!) - Removed legacy /api/auth/login + /api/auth/me from pgz_sport_api.py - Wired auth/admin/gdpr routers into FastAPI 5/5 live curl tests pass: damir@pgz.hr login → JWT with tenant_id=1, role=pgz_admin, tier=0
This commit is contained in:
+230
@@ -0,0 +1,230 @@
|
||||
#!/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 .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"])
|
||||
|
||||
# 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 ───────────────────────────
|
||||
@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}
|
||||
Reference in New Issue
Block a user