Added data privacy
All checks were successful
Test API before pr merge / test-lint (pull_request) Successful in 12s
Test before pr merge / test-translation-lint (pull_request) Successful in 41s
Test before pr merge / test-lint (pull_request) Successful in 46s
Test before pr merge / test-before-merge (pull_request) Successful in 1m48s

This commit is contained in:
Sven Heidemann 2025-04-30 16:42:15 +02:00
parent 333169a190
commit f8bf670479
17 changed files with 256 additions and 25 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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:

View File

@ -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)

View File

@ -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})

View File

@ -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';

View File

@ -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

View File

@ -17,7 +17,7 @@
<app-footer></app-footer>
<p-toast></p-toast>
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000">
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000" styleClass="max-w-md">
<ng-template pTemplate="footer">
<div class="flex gap-2.5 items-center justify-end">
<p-button

View File

@ -32,6 +32,7 @@ import { ConfigService } from 'src/app/service/config.service';
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
import { SpinnerService } from 'src/app/service/spinner.service';
import { AuthService } from 'src/app/service/auth.service';
import { TranslationPreloaderService } from 'src/app/service/translation-preloader.service';
if (environment.production) {
Logger.enableProductionMode();
@ -39,8 +40,12 @@ if (environment.production) {
Logger.enableDevMode();
}
export function preloadTranslations(preloader: TranslationPreloaderService) {
return () => 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,

View File

@ -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<void> = new EventEmitter<void>();
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() {

View File

@ -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 {

View File

@ -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<void> {
return new Promise((resolve, reject) => {
this.http
.get<AppSettings>(`/assets/config/${environment.config}`)
.get<AppSettingsFromConfig>(`/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();
});
});

View File

@ -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<string> {
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<boolean> {
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<boolean> {
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));
}
}

View File

@ -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<void> {
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(() => {});
}
}

View File

@ -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<br><br>Du bist dabei, dein Konto dauerhaft zu löschen.<br>Damit werden deine personenbezogenen Daten unkenntlich gemacht.<br>Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.<br><br>Hinweis:<br>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.<br><br>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)"
},

View File

@ -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<br><br>You are about to permanently delete your account.<br>Your personal data will be anonymized and cannot be recovered.<br>It will no longer be possible to restore your account or associated data.<br><br>Note:<br>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.<br><br>Please confirm that you understand this and wish to proceed with the deletion.",
"export_data": "Export data"
},
"role": {
"count_header": "Role(s)"
},