feat(dsgvo): Löschkonzept/Aufbewahrungsfristen (Auto-Purge)
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

Neuer retention_service.py: Lohn-/zeitrelevante Daten (time_entries,
hours_payouts) werden nach konfigurierbarer Frist gelöscht
(company.settings.retention_lohn_years, Default 10 Jahre). Technische
Tabellen mit fester Frist: audit_logs (3 Jahre), abgelaufene
sessions/password_resets (sofort).

Täglicher Scheduler-Job (03:00 Uhr, Redis-Tageslock analog Reminder-Jobs)
plus manuelle Trigger: POST /companies/me/run-retention-purge
(COMPANY_ADMIN/HR, nur eigene Firma) und POST /admin/run-retention-purge
(SUPER_ADMIN, global inkl. technischer Tabellen).

Letzter offener Punkt aus dem DSGVO-Löschkonzept (Art. 15/17 bereits erledigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
This commit is contained in:
2026-08-27 09:04:27 +02:00
co-authored by Claude Sonnet 5
parent 71e3e82a4e
commit c60c0d6c90
6 changed files with 271 additions and 1 deletions
+15
View File
@@ -52,6 +52,21 @@ async def run_reminders_now(
return {"sent": result} return {"sent": result}
@router.post("/me/run-retention-purge")
async def run_retention_purge_now(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Löscht Zeiterfassung/Auszahlungen der eigenen Firma, die älter als die
konfigurierte Aufbewahrungsfrist (company.settings.retention_lohn_years,
Default 10 Jahre) sind. Technische Tabellen (audit_logs/sessions) laufen
nur im globalen Scheduler-Job, nicht firmenscoped."""
from app.services.retention_service import run_retention_purge
result = await run_retention_purge(db, company_id=current_user.company_id)
await db.commit()
return {"deleted": result}
@router.patch("/me", response_model=CompanyOut) @router.patch("/me", response_model=CompanyOut)
async def update_my_company( async def update_my_company(
data: CompanyUpdate, data: CompanyUpdate,
+15
View File
@@ -217,3 +217,18 @@ async def update_reseller(
last_name=reseller.last_name, is_active=reseller.is_active, last_name=reseller.last_name, is_active=reseller.is_active,
created_at=reseller.created_at, company_count=counts.get(reseller.id, 0), created_at=reseller.created_at, company_count=counts.get(reseller.id, 0),
) )
@router.post("/run-retention-purge")
async def run_global_retention_purge(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
"""Globaler DSGVO-Purge über alle Firmen: Zeiterfassung/Auszahlungen je Firma
(Frist konfigurierbar) plus firmenübergreifende technische Tabellen
(audit_logs > 3 Jahre, abgelaufene sessions/password_resets). Gleiche Logik
wie der tägliche Scheduler-Job."""
from app.services.retention_service import run_retention_purge
result = await run_retention_purge(db, company_id=None)
await db.commit()
return {"deleted": result}
+3 -1
View File
@@ -13,12 +13,14 @@ class CompanySettingsUpdate(BaseModel):
Nur bekannte Top-Level-Keys sind erlaubt (extra="forbid"). Nur bekannte Top-Level-Keys sind erlaubt (extra="forbid").
Derzeit genutzte Keys: carryover_expires_month, carryover_expires_day Derzeit genutzte Keys: carryover_expires_month, carryover_expires_day
(gelesen in absences.py für Resturlaub-Verfall-Berechnung). (gelesen in absences.py für Resturlaub-Verfall-Berechnung), retention_lohn_years
(gelesen in retention_service.py für Aufbewahrungsfrist Zeiterfassung/Auszahlungen).
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
carryover_expires_month: int | None = Field(None, ge=1, le=12) carryover_expires_month: int | None = Field(None, ge=1, le=12)
carryover_expires_day: int | None = Field(None, ge=1, le=31) carryover_expires_day: int | None = Field(None, ge=1, le=31)
retention_lohn_years: int | None = Field(None, ge=1, le=30)
class CompanyOut(BaseModel): class CompanyOut(BaseModel):
+111
View File
@@ -0,0 +1,111 @@
"""DSGVO-Löschkonzept: Aufbewahrungsfristen / Auto-Purge.
Zwei Kategorien:
- Lohn-/zeitrelevante Daten (time_entries, hours_payouts): gesetzliche
Aufbewahrungspflicht, Frist pro Firma konfigurierbar über
company.settings["retention_lohn_years"] (Default RETENTION_LOHN_DEFAULT_YEARS).
Nach Ablauf werden die Zeilen hart gelöscht (keine Anonymisierung nötig,
da nach Fristablauf keine Aufbewahrungspflicht mehr besteht und die Daten
für den ursprünglichen Zweck nicht mehr benötigt werden).
- Rein technische Daten (audit_logs, sessions, password_resets): feste,
nicht pro Firma konfigurierbare Fristen. AuditLog RETENTION_AUDITLOG_YEARS,
Sessions/Password-Resets werden gelöscht sobald abgelaufen (keine
Aufbewahrungspflicht für Alt-Sessions).
Läuft wie die Reminder-Jobs (scheduler_service.py) mit eigener DB-Session und
RLS-Bypass, da firmenübergreifend bzw. ohne Tenant-Kontext gearbeitet wird.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company
from app.models.audit_log import AuditLog
from app.models.hours_payout import HoursPayout
from app.models.password_reset import PasswordReset
from app.models.session import Session
from app.models.time_entry import TimeEntry
from app.models.user import User
log = logging.getLogger(__name__)
RETENTION_LOHN_DEFAULT_YEARS = 10
RETENTION_AUDITLOG_YEARS = 3
def _lohn_retention_years(company: Company) -> int:
s = company.settings or {}
years = s.get("retention_lohn_years")
return int(years) if years else RETENTION_LOHN_DEFAULT_YEARS
async def _active_companies(db: AsyncSession, company_id=None) -> list[Company]:
q = select(Company).where(Company.is_active.is_(True))
if company_id:
q = q.where(Company.id == company_id)
return list((await db.scalars(q)).all())
async def purge_company_lohn_data(db: AsyncSession, company: Company) -> dict:
"""Löscht time_entries + hours_payouts der Firma, die älter als die
konfigurierte Aufbewahrungsfrist sind."""
years = _lohn_retention_years(company)
cutoff_date = date.today() - timedelta(days=years * 365)
cutoff_dt = datetime.now(timezone.utc) - timedelta(days=years * 365)
user_ids_q = select(User.id).where(User.company_id == company.id)
te_result = await db.execute(
delete(TimeEntry)
.where(TimeEntry.user_id.in_(user_ids_q), TimeEntry.date < cutoff_date)
)
hp_result = await db.execute(
delete(HoursPayout)
.where(HoursPayout.company_id == company.id, HoursPayout.created_at < cutoff_dt)
)
return {"time_entries": te_result.rowcount or 0, "hours_payouts": hp_result.rowcount or 0}
async def purge_audit_logs(db: AsyncSession) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=RETENTION_AUDITLOG_YEARS * 365)
result = await db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
return result.rowcount or 0
async def purge_expired_sessions(db: AsyncSession) -> int:
now = datetime.now(timezone.utc)
result = await db.execute(delete(Session).where(Session.expires_at < now))
return result.rowcount or 0
async def purge_expired_password_resets(db: AsyncSession) -> int:
now = datetime.now(timezone.utc)
result = await db.execute(
delete(PasswordReset).where(
(PasswordReset.expires_at < now) | (PasswordReset.used_at.isnot(None))
)
)
return result.rowcount or 0
async def run_retention_purge(db: AsyncSession, company_id=None) -> dict:
"""Orchestriert den vollständigen Purge-Lauf. Liefert Zähler je Kategorie."""
totals = {"time_entries": 0, "hours_payouts": 0}
for company in await _active_companies(db, company_id):
counts = await purge_company_lohn_data(db, company)
totals["time_entries"] += counts["time_entries"]
totals["hours_payouts"] += counts["hours_payouts"]
# Technische Tabellen sind firmenübergreifend nur beim globalen Lauf mitziehen,
# nicht wenn ein einzelner Company-Admin gezielt nur seine eigene Firma anstößt.
if company_id is None:
totals["audit_logs"] = await purge_audit_logs(db)
totals["sessions"] = await purge_expired_sessions(db)
totals["password_resets"] = await purge_expired_password_resets(db)
log.info("Retention-Purge abgeschlossen: %s", totals)
return totals
+19
View File
@@ -23,6 +23,7 @@ from app.models.user import User, UserRole
from app.models.vacation_balance import VacationBalance from app.models.vacation_balance import VacationBalance
from app.services.absence_service import absence_service from app.services.absence_service import absence_service
from app.services.email_service import email_service from app.services.email_service import email_service
from app.services.retention_service import run_retention_purge
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -188,6 +189,22 @@ async def _run_job(name: str, fn) -> None:
log.exception("Reminder-Job %s fehlgeschlagen: %s", name, exc) log.exception("Reminder-Job %s fehlgeschlagen: %s", name, exc)
async def _run_purge_job(name: str, fn) -> None:
if not _acquire_daily_lock(name):
log.info("Purge-Job %s bereits von anderem Prozess übernommen.", name)
return
from sqlalchemy import text
from app.core.database import AsyncSessionLocal
try:
async with AsyncSessionLocal() as db:
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
result = await fn(db)
await db.commit()
log.info("Purge-Job %s: %s", name, result)
except Exception as exc:
log.exception("Purge-Job %s fehlgeschlagen: %s", name, exc)
def start() -> None: def start() -> None:
global _scheduler global _scheduler
if _scheduler is not None: if _scheduler is not None:
@@ -207,6 +224,8 @@ def start() -> None:
args=["carryover_expiry", run_carryover_expiry], id="carryover_expiry") args=["carryover_expiry", run_carryover_expiry], id="carryover_expiry")
_scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=20), _scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=20),
args=["certificate_overdue", run_certificate_overdue], id="certificate_overdue") args=["certificate_overdue", run_certificate_overdue], id="certificate_overdue")
_scheduler.add_job(_run_purge_job, CronTrigger(hour=3, minute=0),
args=["retention_purge", run_retention_purge], id="retention_purge")
_scheduler.start() _scheduler.start()
log.info("Reminder-Scheduler gestartet (täglich ab %02d:00 Uhr).", hour) log.info("Reminder-Scheduler gestartet (täglich ab %02d:00 Uhr).", hour)
+108
View File
@@ -0,0 +1,108 @@
"""Tests für DSGVO-Löschkonzept (retention_service.py)."""
from datetime import date, datetime, timedelta, timezone
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.retention_service import run_retention_purge
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def retention_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"company_name": "Retention GmbH",
"first_name": "Rita",
"last_name": "Tention",
"email": "admin@retentiongmbh.de",
"password": "Secret123",
})
assert resp.status_code == 201, resp.text
data = resp.json()
return {"Authorization": f"Bearer {data['access_token']}"}, data["user"]["company_id"]
@pytest.mark.asyncio(loop_scope="session")
async def test_purge_deletes_old_time_entries_respects_retention(
client: AsyncClient, db_session: AsyncSession, retention_headers
):
headers, company_id = retention_headers
me = await client.get("/api/v1/users/me", headers=headers)
user_id = me.json()["id"]
old_date = date.today() - timedelta(days=11 * 365) # älter als Default 10 Jahre
recent_date = date.today() - timedelta(days=30)
await db_session.execute(text(
"INSERT INTO time_entries (id, user_id, date, start_time, end_time, status, source) "
"VALUES (gen_random_uuid(), :uid, :d, '08:00', '16:00', 'approved', 'web')"
), {"uid": user_id, "d": old_date})
await db_session.execute(text(
"INSERT INTO time_entries (id, user_id, date, start_time, end_time, status, source) "
"VALUES (gen_random_uuid(), :uid, :d, '08:00', '16:00', 'approved', 'web')"
), {"uid": user_id, "d": recent_date})
await db_session.commit()
result = await run_retention_purge(db_session, company_id=company_id)
await db_session.commit()
assert result["time_entries"] == 1
remaining = await db_session.execute(text(
"SELECT date FROM time_entries WHERE user_id = :uid"
), {"uid": user_id})
dates = [r[0] for r in remaining]
assert old_date not in dates
assert recent_date in dates
@pytest.mark.asyncio(loop_scope="session")
async def test_purge_configurable_retention_years(
client: AsyncClient, db_session: AsyncSession, retention_headers
):
headers, company_id = retention_headers
up = await client.patch("/api/v1/companies/me",
json={"settings": {"retention_lohn_years": 2}}, headers=headers)
assert up.status_code == 200, up.text
me = await client.get("/api/v1/users/me", headers=headers)
user_id = me.json()["id"]
three_years_ago = date.today() - timedelta(days=3 * 365)
await db_session.execute(text(
"INSERT INTO time_entries (id, user_id, date, start_time, end_time, status, source) "
"VALUES (gen_random_uuid(), :uid, :d, '08:00', '16:00', 'approved', 'web')"
), {"uid": user_id, "d": three_years_ago})
await db_session.commit()
result = await run_retention_purge(db_session, company_id=company_id)
await db_session.commit()
assert result["time_entries"] >= 1
@pytest.mark.asyncio(loop_scope="session")
async def test_purge_expired_sessions_and_audit_logs_global(
client: AsyncClient, db_session: AsyncSession, retention_headers
):
headers, company_id = retention_headers
me = await client.get("/api/v1/users/me", headers=headers)
user_id = me.json()["id"]
expired = datetime.now(timezone.utc) - timedelta(days=1)
old_audit = datetime.now(timezone.utc) - timedelta(days=4 * 365)
await db_session.execute(text(
"INSERT INTO sessions (id, user_id, refresh_token_hash, expires_at) "
"VALUES (gen_random_uuid(), :uid, :h, :exp)"
), {"uid": user_id, "h": "expired-hash-retention-test", "exp": expired})
await db_session.execute(text(
"INSERT INTO audit_logs (id, company_id, user_id, action, created_at) "
"VALUES (gen_random_uuid(), :cid, :uid, 'test_old_action', :ts)"
), {"cid": company_id, "uid": user_id, "ts": old_audit})
await db_session.commit()
result = await run_retention_purge(db_session, company_id=None)
await db_session.commit()
assert result["sessions"] >= 1
assert result["audit_logs"] >= 1