feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

Statt Hard-Delete (kollidiert mit Lohn-/Steuer-Aufbewahrung) werden bei
DSGVO-Löschanfrage die personenbezogenen Felder der bestehenden User-Zeile
überschrieben; referenzierte Daten (Zeit/Abwesenheit/Auszahlung/AuditLog)
bleiben entkoppelt erhalten.

- users.anonymized_at (Migration 0040); UserOut.anonymized_at.
- user_service.anonymize_user: überschreibt Name/E-Mail (eindeutig anon-<id>@deleted.local),
  entfernt Auth-Material (Passwort/PIN/QR/NFC/TOTP/LDAP-DN), is_active=False, löscht Sessions,
  AuditLog 'user_anonymized'. Personalnummer bleibt reserviert (kein Personenbezug mehr).
  Selbst-Anonymisierung (400), Doppelanonymisierung/Reaktivierung anonymisierter Konten (409).
- POST /users/{id}/anonymize (COMPANY_ADMIN/SUPER_ADMIN, mandantengeprüft).
- UsersPage: "Anonymisieren"-Button mit Sicherheitsabfrage + "Anonymisiert"-Badge.

193/193 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:31:14 +02:00
co-authored by Claude Opus 4.8
parent fe00eb4f2c
commit 2af226990a
7 changed files with 153 additions and 2 deletions
+3
View File
@@ -57,6 +57,9 @@ class User(Base):
# Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an.
notification_prefs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
# DSGVO Art. 17: Zeitpunkt der Anonymisierung (Personenbezug entfernt). NULL = aktiv.
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
+12
View File
@@ -232,6 +232,18 @@ async def reactivate_user(
return UserOut.model_validate(user)
@router.post("/{user_id}/anonymize", response_model=UserOut)
async def anonymize_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""DSGVO Art. 17: Personenbezug des Kontos entfernen (nicht umkehrbar).
Referenzierte Daten (Zeit/Abwesenheit/Auszahlung) bleiben entkoppelt erhalten."""
user = await user_service.anonymize_user(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
async def set_kiosk_pin(
user_id: UUID,
+1
View File
@@ -30,6 +30,7 @@ class UserOut(BaseModel):
work_schedule_id: uuid.UUID | None = None
entry_date: date | None = None
exit_date: date | None = None
anonymized_at: datetime | None = None
class NotificationTypeOut(BaseModel):
+48
View File
@@ -335,9 +335,57 @@ class UserService:
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
if user.anonymized_at is not None:
raise HTTPException(status_code=409, detail="Anonymisierte Konten können nicht reaktiviert werden.")
user.is_active = True
return user
async def anonymize_user(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
"""DSGVO Art. 17: Personenbezug eines Users entfernen (statt Hard-Delete).
Die User-Zeile + alle Referenzen (Zeit, Abwesenheit, Auszahlungen, AuditLog)
bleiben für die gesetzliche Aufbewahrung erhalten, verlieren aber den Personenbezug.
Personalnummer bleibt reserviert. Nicht umkehrbar.
"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Das eigene Konto kann nicht anonymisiert werden.")
user = await self.get_by_id(user_id, current_user.company_id, db)
if user.anonymized_at is not None:
raise HTTPException(status_code=409, detail="Konto ist bereits anonymisiert.")
old = {"first_name": user.first_name, "last_name": user.last_name, "email": user.email}
# Personenbezogene Felder überschreiben / Auth-Material entfernen
user.first_name = "Anonymisiert"
user.last_name = str(user.id)[:8]
user.email = f"anon-{user.id}@deleted.local"
user.password_hash = None
user.kiosk_pin_hash = None
user.kiosk_qr_token = None
user.kiosk_nfc_uid = None
user.totp_secret = None
user.totp_enabled = False
user.ldap_dn = None
user.kuerzel = None
user.invite_token_hash = None
user.is_active = False
user.anonymized_at = datetime.now(timezone.utc)
# personnel_number bleibt erhalten (reserviert, kein Personenbezug mehr)
# Aktive Sessions des Users beenden (Refresh-Tokens entwerten)
await db.execute(text("DELETE FROM sessions WHERE user_id = :uid"), {"uid": str(user.id)})
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_anonymized",
entity_type="user",
entity_id=user.id,
old_value=old,
new_value={"anonymized_at": user.anonymized_at.isoformat(), "target_user_id": str(user.id)},
))
return user
# ── Kiosk ─────────────────────────────────────────────────────────────────
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None: