feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten
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:
@@ -57,6 +57,9 @@ class User(Base):
|
|||||||
# Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an.
|
# Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an.
|
||||||
notification_prefs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
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 auth
|
||||||
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
|
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
|
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||||
|
|||||||
@@ -232,6 +232,18 @@ async def reactivate_user(
|
|||||||
return UserOut.model_validate(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)
|
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
|
||||||
async def set_kiosk_pin(
|
async def set_kiosk_pin(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class UserOut(BaseModel):
|
|||||||
work_schedule_id: uuid.UUID | None = None
|
work_schedule_id: uuid.UUID | None = None
|
||||||
entry_date: date | None = None
|
entry_date: date | None = None
|
||||||
exit_date: date | None = None
|
exit_date: date | None = None
|
||||||
|
anonymized_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class NotificationTypeOut(BaseModel):
|
class NotificationTypeOut(BaseModel):
|
||||||
|
|||||||
@@ -335,9 +335,57 @@ class UserService:
|
|||||||
|
|
||||||
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
|
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)
|
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
|
user.is_active = True
|
||||||
return user
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Kiosk ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
|
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""DSGVO Art. 17: users.anonymized_at
|
||||||
|
|
||||||
|
Revision ID: 0040
|
||||||
|
Revises: 0039
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
|
||||||
|
Markiert anonymisierte Konten (Personenbezug entfernt, Zeile + Referenzen bleiben
|
||||||
|
für die gesetzliche Aufbewahrung erhalten). NULL = aktives Konto.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0040"
|
||||||
|
down_revision = "0039"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS anonymized_at TIMESTAMPTZ")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS anonymized_at")
|
||||||
@@ -126,3 +126,38 @@ async def test_kiosk_pin_must_be_numeric(client: AsyncClient, registered_user):
|
|||||||
json={"pin": "abcd"},
|
json={"pin": "abcd"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_anonymize_user(client: AsyncClient, registered_user):
|
||||||
|
"""DSGVO Art. 17: Anonymisierung entfernt den Personenbezug, Konto wird inaktiv."""
|
||||||
|
inv = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||||||
|
"email": "gdpr@test.de", "first_name": "Max", "last_name": "Muster",
|
||||||
|
"role": "EMPLOYEE", "initial_password": "Secret123",
|
||||||
|
})
|
||||||
|
user_id = inv.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["anonymized_at"] is not None
|
||||||
|
assert body["first_name"] == "Anonymisiert"
|
||||||
|
assert body["email"] == f"anon-{user_id}@deleted.local"
|
||||||
|
assert body["is_active"] is False
|
||||||
|
|
||||||
|
# Login mit alter E-Mail unmöglich
|
||||||
|
login = await client.post("/api/v1/auth/login", json={"email": "gdpr@test.de", "password": "Secret123"})
|
||||||
|
assert login.status_code == 401
|
||||||
|
|
||||||
|
# Reaktivierung eines anonymisierten Kontos wird abgelehnt
|
||||||
|
react = await client.post(f"{USERS_URL}/{user_id}/reactivate", headers=auth(registered_user["tokens"]))
|
||||||
|
assert react.status_code == 409
|
||||||
|
|
||||||
|
# Erneutes Anonymisieren wird abgelehnt
|
||||||
|
again = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||||||
|
assert again.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cannot_anonymize_self(client: AsyncClient, registered_user):
|
||||||
|
user_id = registered_user["user"]["id"]
|
||||||
|
resp = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface UserOut {
|
|||||||
can_manual_time_entry: boolean
|
can_manual_time_entry: boolean
|
||||||
entry_date: string | null
|
entry_date: string | null
|
||||||
exit_date: string | null
|
exit_date: string | null
|
||||||
|
anonymized_at: string | null
|
||||||
last_login: string | null
|
last_login: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -221,6 +222,21 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAnonymize(user: UserOut) {
|
||||||
|
if (!confirm(
|
||||||
|
`DSGVO-Anonymisierung von ${user.full_name}?\n\n` +
|
||||||
|
`Name und E-Mail werden unwiderruflich entfernt, das Konto wird deaktiviert. ` +
|
||||||
|
`Zeit-, Abwesenheits- und Auszahlungsdaten bleiben (gesetzliche Aufbewahrung), ` +
|
||||||
|
`aber ohne Personenbezug. Nicht umkehrbar.`
|
||||||
|
)) return
|
||||||
|
try {
|
||||||
|
await api.post(`/users/${user.id}/anonymize`, {})
|
||||||
|
load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Fehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleEditRole() {
|
async function handleEditRole() {
|
||||||
if (!editUser) return
|
if (!editUser) return
|
||||||
setEditLoading(true)
|
setEditLoading(true)
|
||||||
@@ -415,14 +431,19 @@ export function UsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3'>
|
<td className='px-4 py-3'>
|
||||||
<Badge
|
<Badge
|
||||||
text={u.is_active ? 'Aktiv' : 'Inaktiv'}
|
text={u.anonymized_at ? 'Anonymisiert' : u.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||||
color={u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
|
color={u.anonymized_at ? 'bg-gray-200 text-gray-500' : u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3 text-gray-500'>
|
<td className='px-4 py-3 text-gray-500'>
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-3'>
|
<td className='px-4 py-3'>
|
||||||
|
{u.anonymized_at ? (
|
||||||
|
<div className='flex justify-end text-xs text-gray-400 italic'>
|
||||||
|
anonymisiert am {new Date(u.anonymized_at).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className='flex gap-2 justify-end'>
|
<div className='flex gap-2 justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -450,7 +471,15 @@ export function UsersPage() {
|
|||||||
Reaktivieren
|
Reaktivieren
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnonymize(u)}
|
||||||
|
className='text-xs text-gray-400 hover:text-red-600 hover:underline'
|
||||||
|
title='DSGVO Art. 17 – Personenbezug entfernen (nicht umkehrbar)'
|
||||||
|
>
|
||||||
|
Anonymisieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user