diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 38d2470..6bcb939 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 041c686..14db172 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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, diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 7c3a327..39dfd64 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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): diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 44dac9e..62a27f2 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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: diff --git a/backend/migrations/versions/0040_user_anonymization.py b/backend/migrations/versions/0040_user_anonymization.py new file mode 100644 index 0000000..826ca4c --- /dev/null +++ b/backend/migrations/versions/0040_user_anonymization.py @@ -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") diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index dea2396..3caae1f 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -126,3 +126,38 @@ async def test_kiosk_pin_must_be_numeric(client: AsyncClient, registered_user): json={"pin": "abcd"}, ) 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 diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index a52d4c4..66d7518 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -20,6 +20,7 @@ interface UserOut { can_manual_time_entry: boolean entry_date: string | null exit_date: string | null + anonymized_at: string | null last_login: string | null 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() { if (!editUser) return setEditLoading(true) @@ -415,14 +431,19 @@ export function UsersPage() {