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
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:
parent
333169a190
commit
f8bf670479
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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})
|
||||
|
@ -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';
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
77
web/src/app/service/data-privacy.service.ts
Normal file
77
web/src/app/service/data-privacy.service.ts
Normal 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));
|
||||
}
|
||||
}
|
25
web/src/app/service/translation-preloader.service.ts
Normal file
25
web/src/app/service/translation-preloader.service.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
@ -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)"
|
||||
},
|
||||
|
@ -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)"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user