From f8bf67047915d12a1481726ffe269fcb54c0b282 Mon Sep 17 00:00:00 2001 From: edraft Date: Wed, 30 Apr 2025 16:42:15 +0200 Subject: [PATCH] Added data privacy --- api/src/api/route_user_extension.py | 2 +- api/src/api_graphql/graphql/privacy.gql | 4 +- .../database/abc/data_access_object_abc.py | 8 ++ api/src/data/schemas/administration/user.py | 13 ++++ .../data/schemas/administration/user_dao.py | 15 +++- ...5-04-30-15-30-keycloak-id-anonymizable.sql | 7 ++ api/src/service/data_privacy_service.py | 15 +++- web/src/app/app.component.html | 2 +- web/src/app/app.module.ts | 13 +++- .../app/components/header/header.component.ts | 48 ++++++++++-- web/src/app/model/config/app-settings.ts | 12 +++ web/src/app/service/config.service.ts | 18 +++-- web/src/app/service/data-privacy.service.ts | 77 +++++++++++++++++++ .../service/translation-preloader.service.ts | 25 ++++++ ...gs.service.ts => user-settings.service.ts} | 0 web/src/assets/i18n/de.json | 11 ++- web/src/assets/i18n/en.json | 11 ++- 17 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql create mode 100644 web/src/app/service/data-privacy.service.ts create mode 100644 web/src/app/service/translation-preloader.service.ts rename web/src/app/service/{user_settings.service.ts => user-settings.service.ts} (100%) diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index 9efee96..585390d 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -53,7 +53,7 @@ class RouteUserExtension: user_id = cls._get_user_id_from_token(request) if not user_id: - return await cls.get_dev_user() + return None return await userDao.find_by_keycloak_id(user_id) diff --git a/api/src/api_graphql/graphql/privacy.gql b/api/src/api_graphql/graphql/privacy.gql index 0a1410c..b1f6960 100644 --- a/api/src/api_graphql/graphql/privacy.gql +++ b/api/src/api_graphql/graphql/privacy.gql @@ -1,5 +1,5 @@ type PrivacyMutation { exportData(userId: Int!): String - anonymizeData(userId: Int!): String - deleteData(userId: Int!): String + anonymizeData(userId: Int!): Boolean + deleteData(userId: Int!): Boolean } \ No newline at end of file diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py index 4fe4874..a3003db 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -179,6 +179,14 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): value_map[attr_name] = value + for ex_fname in self._external_fields: + ex_field = self._external_fields[ex_fname] + for ex_attr in ex_field.fields: + if ex_attr == self.__primary_key: + continue + + value_map[ex_attr] = getattr(obj, ex_attr, None) + return value_map async def count(self, filters: AttributeFilters = None) -> int: diff --git a/api/src/data/schemas/administration/user.py b/api/src/data/schemas/administration/user.py index 3a44626..b17738b 100644 --- a/api/src/data/schemas/administration/user.py +++ b/api/src/data/schemas/administration/user.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime from typing import Optional @@ -28,10 +29,16 @@ class User(DbModelABC): @property def username(self): + if self._keycloak_id == str(uuid.UUID(int=0)): + return "ANONYMOUS" + return Keycloak.admin.get_user(self._keycloak_id).get("username") @property def email(self): + if self._keycloak_id == str(uuid.UUID(int=0)): + return "ANONYMOUS" + return Keycloak.admin.get_user(self._keycloak_id).get("email") @async_property @@ -50,3 +57,9 @@ class User(DbModelABC): from data.schemas.administration.user_dao import userDao return await userDao.has_permission(self.id, permission) + + async def anonymize(self): + from data.schemas.administration.user_dao import userDao + + self._keycloak_id = str(uuid.UUID(int=0)) + await userDao.update(self) diff --git a/api/src/data/schemas/administration/user_dao.py b/api/src/data/schemas/administration/user_dao.py index 7759dd3..4649708 100644 --- a/api/src/data/schemas/administration/user_dao.py +++ b/api/src/data/schemas/administration/user_dao.py @@ -1,6 +1,7 @@ from typing import Optional, Union from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder from core.logger import DBLogger from data.schemas.administration.user import User from data.schemas.permission.permission_dao import permissionDao @@ -14,7 +15,19 @@ class UserDao(DbModelDaoABC[User]): def __init__(self): DbModelDaoABC.__init__(self, __name__, User, "administration.users") - self.attribute(User.keycloak_id, str) + self.attribute(User.keycloak_id, str, aliases=["keycloakId"]) + + async def get_users(): + return [(x.id, x.username, x.email) for x in await self.get_all()] + + self.use_external_fields( + ExternalDataTempTableBuilder() + .with_table_name(self._table_name) + .with_field("id", "int", True) + .with_field("username", "text") + .with_field("email", "text") + .with_value_getter(get_users) + ) async def get_by_keycloak_id(self, keycloak_id: str) -> User: return await self.get_single_by({User.keycloak_id: keycloak_id}) diff --git a/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql b/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql new file mode 100644 index 0000000..8c77c31 --- /dev/null +++ b/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql @@ -0,0 +1,7 @@ +-- Drop the existing unique constraint +ALTER TABLE administration.users DROP CONSTRAINT IF EXISTS UC_KeycloakId; + +-- Add a partial unique index to allow guid-0 duplicates +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_keycloakid + ON administration.users (KeycloakId) + WHERE KeycloakId != '00000000-0000-0000-0000-000000000000'; \ No newline at end of file diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py index 974f0d6..e0d6d82 100644 --- a/api/src/service/data_privacy_service.py +++ b/api/src/service/data_privacy_service.py @@ -3,6 +3,7 @@ import json from typing import Type from api.auth.keycloak_client import Keycloak +from api.broadcast import broadcast from core.database.abc.data_access_object_abc import DataAccessObjectABC from core.database.abc.db_model_dao_abc import DbModelDaoABC from core.logger import Logger @@ -52,12 +53,14 @@ class DataPrivacyService: if user is None: raise ValueError("User not found") - collected_data = [userDao.to_dict(await userDao.find_by_id(user_id))] + collected_data = {"user": userDao.to_dict(await userDao.find_by_id(user_id))} daos = await cls._collect_user_relevant_dao() for dao in daos: data = await dao.find_by([{"userid": user_id}]) - collected_data.append([dao.to_dict(x) for x in data]) + collected_data[first_to_lower(type(dao).__name__.replace("Dao", "s"))] = [ + dao.to_dict(x) for x in data + ] return json.dumps(collected_data, default=str) @@ -74,16 +77,18 @@ class DataPrivacyService: keycloak_id = user.keycloak_id # Anonymize internal data - user.keycloak_id = "ANONYMIZED" - userDao.update(user) + await user.anonymize() # Anonymize external data try: Keycloak.admin.delete_user(keycloak_id) + await broadcast.publish("userLogout", user.id) except Exception as e: logger.error(f"Failed to anonymize external data for user {user_id}", e) raise ValueError("Failed to anonymize external data") from e + return True + @classmethod async def delete_user_data(cls, user_id: int): """ @@ -117,3 +122,5 @@ class DataPrivacyService: except Exception as e: logger.error(f"Failed to delete external data for user {user_id}", e) raise ValueError("Failed to delete external data") from e + + return True diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index b3cf78b..ea278c4 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -17,7 +17,7 @@ - +
preloader.preloadLanguages(); +} + export function HttpLoaderFactory(http: HttpClient) { - return new TranslateHttpLoader(http); + return new TranslateHttpLoader(http, '/assets/i18n/', '.json'); } export function appInitializerFactory( @@ -89,6 +94,12 @@ export function appInitializerFactory( AppRoutingModule, ], providers: [ + { + provide: APP_INITIALIZER, + useFactory: preloadTranslations, + deps: [TranslationPreloaderService], + multi: true, + }, MessageService, ConfirmationService, DialogService, diff --git a/web/src/app/components/header/header.component.ts b/web/src/app/components/header/header.component.ts index 6a0a3b0..d1055f3 100644 --- a/web/src/app/components/header/header.component.ts +++ b/web/src/app/components/header/header.component.ts @@ -1,5 +1,5 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { MenuItem, MenuItemCommandEvent, PrimeNGConfig } from 'primeng/api'; +import { Component, EventEmitter, OnDestroy, OnInit } from '@angular/core'; +import { MenuItem, PrimeNGConfig } from 'primeng/api'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; @@ -9,9 +9,13 @@ import { AuthService } from 'src/app/service/auth.service'; import { MenuElement } from 'src/app/model/view/menu-element'; import { SidebarService } from 'src/app/service/sidebar.service'; import { ConfigService } from 'src/app/service/config.service'; -import { UserSettingsService } from 'src/app/service/user_settings.service'; +import { UserSettingsService } from 'src/app/service/user-settings.service'; import { SettingsService } from 'src/app/service/settings.service'; -import { environment } from 'src/environments/environment'; +import { DataPrivacyService } from 'src/app/service/data-privacy.service'; +import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service'; +import { Logger } from 'src/app/service/logger.service'; + +const logger = new Logger('Header'); @Component({ selector: 'app-header', @@ -29,6 +33,8 @@ export class HeaderComponent implements OnInit, OnDestroy { menu: MenuElement[] = []; + private langChange: EventEmitter = new EventEmitter(); + constructor( private translateService: TranslateService, private ngConfig: PrimeNGConfig, @@ -37,8 +43,14 @@ export class HeaderComponent implements OnInit, OnDestroy { private sidebarService: SidebarService, private config: ConfigService, private settings: SettingsService, - private userSettings: UserSettingsService + private userSettings: UserSettingsService, + private dataPrivacyService: DataPrivacyService, + private confirmation: ConfirmationDialogService ) { + this.langChange.subscribe(async () => { + await this.initUserMenuList(); + }); + this.guiService.isMobile$ .pipe(takeUntil(this.unsubscribe$)) .subscribe(isMobile => { @@ -51,7 +63,6 @@ export class HeaderComponent implements OnInit, OnDestroy { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { this.user = user; - await this.initMenuLists(); await this.loadTheme(); await this.loadLang(); @@ -123,12 +134,31 @@ export class HeaderComponent implements OnInit, OnDestroy { items: [ { label: this.translateService.instant('privacy.export_data'), - command: () => {}, + command: () => { + if (!this.user) { + return; + } + this.dataPrivacyService.downloadDataExportJson(this.user?.id); + }, icon: 'pi pi-download', }, { label: this.translateService.instant('privacy.delete_data'), - command: () => {}, + command: () => { + this.confirmation.confirmDialog({ + header: 'privacy.delete_data_header', + message: 'privacy.delete_data_message', + accept: () => { + if (!this.user) { + return; + } + + this.dataPrivacyService + .anonymizeData(this.user.id) + .subscribe(() => {}); + }, + }); + }, icon: 'pi pi-trash', }, ], @@ -159,10 +189,12 @@ export class HeaderComponent implements OnInit, OnDestroy { } translate(lang: string) { + logger.debug('translate', lang); this.translateService.use(lang); this.translateService .get('primeng') .subscribe(res => this.ngConfig.setTranslation(res)); + this.langChange.next(); } async loadTheme() { diff --git a/web/src/app/model/config/app-settings.ts b/web/src/app/model/config/app-settings.ts index 690d6be..3aeb1a9 100644 --- a/web/src/app/model/config/app-settings.ts +++ b/web/src/app/model/config/app-settings.ts @@ -1,5 +1,16 @@ import { Theme } from 'src/app/model/view/theme'; +export interface AppSettingsFromConfig { + termsUrl: string; + privacyURL: string; + imprintURL: string; + themes: Theme[]; + loadingScreen: LoadingScreenSettings; + keycloak: KeycloakSettings; + api: ApiSettings; + languages?: string[]; +} + export interface AppSettings { termsUrl: string; privacyURL: string; @@ -8,6 +19,7 @@ export interface AppSettings { loadingScreen: LoadingScreenSettings; keycloak: KeycloakSettings; api: ApiSettings; + languages: string[]; } export interface LoadingScreenSettings { diff --git a/web/src/app/service/config.service.ts b/web/src/app/service/config.service.ts index 2a853ed..8d67a54 100644 --- a/web/src/app/service/config.service.ts +++ b/web/src/app/service/config.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@angular/core'; import { throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { AppSettings } from 'src/app/model/config/app-settings'; +import { + AppSettings, + AppSettingsFromConfig, +} from 'src/app/model/config/app-settings'; import { HttpClient } from '@angular/common/http'; import { environment } from 'src/environments/environment'; @@ -33,6 +36,7 @@ export class ConfigService { realm: '', clientId: '', }, + languages: ['en', 'de'], }; constructor(private http: HttpClient) {} @@ -40,7 +44,7 @@ export class ConfigService { loadSettings(): Promise { return new Promise((resolve, reject) => { this.http - .get(`/assets/config/${environment.config}`) + .get(`/assets/config/${environment.config}`) .pipe( catchError(error => { reject(error); @@ -48,13 +52,17 @@ export class ConfigService { }) ) .subscribe(settings => { - this.settings = settings; - if (this.settings.themes.length === 0) { - this.settings.themes.push({ + if (settings.themes.length === 0) { + settings.themes.push({ label: 'Maxlan', name: 'maxlan', }); } + if (settings.languages?.length === 0) { + settings.languages = ['en', 'de']; + } + + this.settings = settings as AppSettings; resolve(); }); }); diff --git a/web/src/app/service/data-privacy.service.ts b/web/src/app/service/data-privacy.service.ts new file mode 100644 index 0000000..5b8ab8b --- /dev/null +++ b/web/src/app/service/data-privacy.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { Apollo, gql } from 'apollo-angular'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class DataPrivacyService { + constructor(private apollo: Apollo) {} + + downloadDataExportJson(userId: number): void { + this.exportDataPrivacy(userId).subscribe(dataString => { + const jsonData = JSON.stringify(JSON.parse(dataString), null, 2); // Format nicely + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'exported-data.json'; + anchor.click(); + + window.URL.revokeObjectURL(url); + }); + } + + exportDataPrivacy(userId: number): Observable { + return this.apollo + .mutate<{ privacy: { exportData: string } }>({ + mutation: gql` + mutation exportDataPrivacy($userId: Int!) { + privacy { + exportData(userId: $userId) + } + } + `, + variables: { + userId, + }, + }) + .pipe(map(result => result.data?.privacy.exportData ?? '')); + } + + anonymizeData(userId: number): Observable { + return this.apollo + .mutate<{ privacy: { anonymizeData: boolean } }>({ + mutation: gql` + mutation anonymizeDataPrivacy($userId: Int!) { + privacy { + anonymizeData(userId: $userId) + } + } + `, + variables: { + userId, + }, + }) + .pipe(map(result => result.data?.privacy.anonymizeData ?? false)); + } + + deleteData(userId: number): Observable { + return this.apollo + .mutate<{ privacy: { deleteData: boolean } }>({ + mutation: gql` + mutation deleteDataPrivacy($userId: Int!) { + privacy { + deleteData(userId: $userId) + } + } + `, + variables: { + userId, + }, + }) + .pipe(map(result => result.data?.privacy.deleteData ?? false)); + } +} diff --git a/web/src/app/service/translation-preloader.service.ts b/web/src/app/service/translation-preloader.service.ts new file mode 100644 index 0000000..be74ea4 --- /dev/null +++ b/web/src/app/service/translation-preloader.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { lastValueFrom } from 'rxjs'; +import { ConfigService } from 'src/app/service/config.service'; + +@Injectable({ providedIn: 'root' }) +export class TranslationPreloaderService { + constructor( + private translate: TranslateService, + private http: HttpClient, + private config: ConfigService + ) {} + + preloadLanguages(): Promise { + const loadRequests = this.config.settings.languages.map(async lang => { + const translations = await lastValueFrom( + this.http.get(`/assets/i18n/${lang}.json`) + ); + this.translate.setTranslation(lang, translations, true); // merge = true + }); + + return Promise.all(loadRequests).then(() => {}); + } +} diff --git a/web/src/app/service/user_settings.service.ts b/web/src/app/service/user-settings.service.ts similarity index 100% rename from web/src/app/service/user_settings.service.ts rename to web/src/app/service/user-settings.service.ts diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index 839c5ad..aa96be0 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -76,7 +76,10 @@ "count_header": "Gruppe(n)" }, "header": { - "logout": "Ausloggen" + "edit_profile": "Profil bearbeiten", + "logout": "Ausloggen", + "privacy": "Datenschutz", + "profile": "Profil" }, "history": { "header": "Historie" @@ -211,6 +214,12 @@ "weak": "Woche", "weekHeader": "Wk" }, + "privacy": { + "delete_data": "Daten löschen", + "delete_data_header": "Bestätigung zur Löschung deiner personenbezogenen Daten", + "delete_data_message": "Bestätigung zur Löschung deiner personenbezogenen Daten

Du bist dabei, dein Konto dauerhaft zu löschen.
Damit werden deine personenbezogenen Daten unkenntlich gemacht.
Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.

Hinweis:
Aus rechtlichen oder betrieblichen Gründen (z. B. gesetzliche Aufbewahrungspflichten) können bestimmte Daten weiterhin in anonymisierter oder pseudonymisierter Form gespeichert bleiben. Diese enthalten keine Informationen mehr, die dich als Person identifizierbar machen.

Bitte bestätige, dass du dies verstanden hast und mit der Löschung fortfahren möchtest.", + "export_data": "Daten exportieren" + }, "role": { "count_header": "Rolle(n)" }, diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index bc91320..4f95c94 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -76,7 +76,10 @@ "count_header": "Group(s)" }, "header": { - "logout": "Logout" + "edit_profile": "Edit profile", + "logout": "Logout", + "privacy": "Privacy", + "profile": "Profile" }, "history": { "header": "History" @@ -211,6 +214,12 @@ "weak": "Weak", "weekHeader": "Wk" }, + "privacy": { + "delete_data": "Delete data", + "delete_data_header": "Confirmation of deletion of your personal data", + "delete_data_message": "Confirmation of deletion of your personal data

You are about to permanently delete your account.
Your personal data will be anonymized and cannot be recovered.
It will no longer be possible to restore your account or associated data.

Note:
For legal or operational reasons (e.g. statutory retention obligations), certain data may continue to be stored in anonymized or pseudonymized form. This data no longer contains any information that can identify you as a person.

Please confirm that you understand this and wish to proceed with the deletion.", + "export_data": "Export data" + }, "role": { "count_header": "Role(s)" },