feat(g1): DSGVO Art.15/17 funktionsfaehig — fehlender process_dsar Worker-Job implementiert

Root-Cause: POST /dsar/{user_id} queued einen Job der nirgends implementiert war — DSAR-Requests verschwanden im Nirvana. Implementiert in app/core/jobs.py nach Hausmuster: _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art.15/20), _dsar_execute_deletion fuehrt Art.17 aus (contacts soft-delete respektiert Audit-Pflichten, notifications hard-delete, User anonymisiert+deaktiviert mit FK-Integritaet fuer Audit-Zeilen, dsar_erasure-Audit-Eintrag), process_dsar dispatcht access/deletion/rectification.

Beweis: test_g1_dsar 4/4 gruen; ruff clean.
This commit is contained in:
Agent Zero
2026-08-25 23:48:22 +02:00
parent 38b73f5d4d
commit f4a5937a4b
2 changed files with 413 additions and 0 deletions
+275
View File
@@ -153,3 +153,278 @@ async def send_password_reset_email(
from app.core.job_registry import register_job # noqa: E402
register_job("send_password_reset_email", send_password_reset_email)
# ── DSAR Processing Job (G1 DSGVO: Art. 15 Auskunft / Art. 17 Löschung) ─────
async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict[str, Any]:
"""Collect every data category the dsgvo-export route promises.
Shared by type=access (full export) so both paths stay consistent.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import select as sa_select
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
export_data: dict[str, Any] = {
"user_id": user_id,
"exported_at": datetime.now(UTC).isoformat(),
"legal_basis": "GDPR Art. 15 (access) / Art. 20 (portability)",
"data": {},
}
# Profile
user = (
await db.execute(sa_select(User).where(User.id == uid))
).scalar_one_or_none()
if user:
export_data["data"]["profile"] = {
"email": user.email,
"name": user.name,
"is_active": user.is_active,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
# Contacts owned by the user
contacts = (
await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
).scalars().all()
export_data["data"]["contacts"] = [
{
"id": str(c.id),
"type": c.type,
"displayname": c.displayname,
"email_1": c.email_1,
"email_2": c.email_2,
}
for c in contacts
]
# Audit trail entries by/about the user (bounded to keep payloads sane)
audit_entries = (
await db.execute(
sa_select(AuditLog).where(
AuditLog.tenant_id == tid,
AuditLog.user_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["audit_log"] = [
{
"action": a.action,
"entity_type": a.entity_type,
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
}
for a in audit_entries
]
# Notifications addressed to the user
notifications = (
await db.execute(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
).limit(1000)
)
).scalars().all()
export_data["data"]["notifications"] = [
{
"id": str(n.id),
"type": getattr(n, "type", None),
"title": getattr(n, "title", None),
"created_at": n.created_at.isoformat() if n.created_at else None,
}
for n in notifications
]
return export_data
async def _dsar_execute_deletion(db: Any, tenant_id: str, user_id: str) -> dict[str, int]:
"""Execute GDPR Art. 17 erasure for a user within one tenant.
Strategy (respects retention duties):
- Contacts owned by the user → soft-delete via deleted_at
(audit history must remain intact — it is not personal data of the
subject but business record; retention policy governs its cleanup)
- Notifications owned by the user → hard delete
- User account → deactivate (is_active=False), clear personal fields,
scramble password hash and email (keeps FK integrity for audit rows)
Returns counters for the audit entry.
"""
from datetime import UTC, datetime
from uuid import UUID as PyUUID
from sqlalchemy import select as sa_select
from sqlalchemy import update as sa_update
from app.core.audit import log_audit
from app.models.contact import Contact
from app.models.notification import Notification
from app.models.user import User
uid = PyUUID(user_id)
tid = PyUUID(tenant_id)
counts: dict[str, int] = {}
# 1. Soft-delete contacts owned by the user
contact_result = await db.execute(
sa_select(Contact).where(
Contact.tenant_id == tid,
Contact.owner_id == uid,
Contact.deleted_at.is_(None),
)
)
contacts = contact_result.scalars().all()
for c in contacts:
c.deleted_at = datetime.now(UTC)
counts["contacts_soft_deleted"] = len(contacts)
# 2. Hard-delete notifications owned by the user
notif_result = await db.execute(
sa_select(Notification).where(
Notification.tenant_id == tid,
Notification.owner_id == uid,
)
)
notifications = notif_result.scalars().all()
for n in notifications:
await db.delete(n)
counts["notifications_deleted"] = len(notifications)
# 3. Anonymize + deactivate the account (FK integrity for audit rows kept)
await db.execute(
sa_update(User)
.where(User.id == uid)
.values(
email=f"erased.{uid.hex[:16]}@anonymized.invalid",
name="[gelöscht gemäß DSGVO Art. 17]",
first_name=None,
last_name=None,
avatar_url=None,
password_hash="!dsar-erased",
is_active=False,
preferences={},
)
)
counts["user_anonymized"] = 1
# 4. Audit the erasure itself (who/what/when — required by Art. 17 recital)
await log_audit(
db,
tid,
user_id,
"dsar_erasure",
"user",
uid,
{"target_user": user_id, **counts},
)
return counts
async def process_dsar(
ctx: dict[str, Any],
*,
user_id: str,
tenant_id: str,
request_type: str,
) -> dict[str, Any]:
"""Process a GDPR Data Subject Access Request (DSAR).
ARQ worker function registered as "process_dsar".
request_type:
- "access": collect all data categories (Art. 15/20) and post a system
message that the export is ready (served via the existing dsgvo-export
endpoint).
- "deletion": execute Art. 17 erasure (soft-delete contacts, hard-delete
notifications, anonymize+deactivate account) and audit it.
- "rectification": post a system message asking admins to handle the
correction manually.
Returns a summary dict for the job result.
"""
import logging
import uuid as uuid_module
from app.core.db import get_worker_session_factory
from app.core.notifications import post_system_message
logger = logging.getLogger(__name__)
tid = uuid_module.UUID(tenant_id)
uid = uuid_module.UUID(user_id)
factory = get_worker_session_factory()
async with factory() as db:
try:
if request_type == "access":
data = await _dsar_collect_user_data(db, tenant_id, user_id)
await db.commit()
categories = list(data.get("data", {}).keys())
await post_system_message(
db,
tid,
uid,
"dsar_access_ready",
"DSGVO-Auskunft bereit",
f"Datenkategorien: {', '.join(categories)}",
severity="info",
)
await db.commit()
logger.info("DSAR access processed for user %s", user_id)
return {"type": request_type, "status": "completed", "categories": categories}
if request_type == "deletion":
counts = await _dsar_execute_deletion(db, tenant_id, user_id)
await db.commit()
await post_system_message(
db,
tid,
uid,
"dsar_deletion_done",
"DSGVO-Löschung ausgeführt",
f"Kontakten soft-gelöscht: {counts.get('contacts_soft_deleted', 0)}; Konto anonymisiert.",
severity="info",
)
await db.commit()
logger.info("DSAR deletion executed for user %s: %s", user_id, counts)
return {"type": request_type, "status": "completed", **counts}
if request_type == "rectification":
await post_system_message(
db,
tid,
uid,
"dsar_rectification_requested",
"DSGVO-Berichtigung angefordert",
f"Manuelle Bearbeitung für User {user_id} erforderlich.",
severity="warning",
)
await db.commit()
logger.info("DSAR rectification requested for user %s", user_id)
return {"type": request_type, "status": "queued_for_manual_handling"}
logger.warning("Unknown DSAR request_type '%s' for user %s", request_type, user_id)
return {"type": request_type, "status": "unknown_type"}
except Exception:
await db.rollback()
raise
register_job("process_dsar", process_dsar)