Handle data privacy #15
Some checks failed
Test API before pr merge / test-lint (pull_request) Successful in 11s
Test before pr merge / test-translation-lint (pull_request) Failing after 41s
Test before pr merge / test-lint (pull_request) Failing after 44s
Test before pr merge / test-before-merge (pull_request) Successful in 1m43s

This commit is contained in:
Sven Heidemann 2025-04-29 19:07:04 +02:00
parent 653fce9729
commit 11598cb160
17 changed files with 796 additions and 607 deletions

View File

@ -53,7 +53,7 @@ class RouteUserExtension:
user_id = cls._get_user_id_from_token(request)
if not user_id:
return None
return await cls.get_dev_user()
return await userDao.find_by_keycloak_id(user_id)

View File

@ -1,5 +1,5 @@
from asyncio import iscoroutinefunction
from typing import Self, Type
from typing import Self, Type, Union
from ariadne.types import Resolver
@ -58,7 +58,11 @@ class MutationFieldBuilder(FieldBuilderABC):
self._resolver = resolver_wrapper
return self
def with_input(self, input_type: Type[InputABC], input_key: str = "input") -> Self:
def with_input(
self,
input_type: Type[Union[InputABC, str, int, bool]],
input_key: str = "input",
) -> Self:
self._input_type = input_type
self._input_key = input_key
return self

View File

@ -11,4 +11,6 @@ type Mutation {
setting: SettingMutation
userSetting: UserSettingMutation
featureFlag: FeatureFlagMutation
privacy: PrivacyMutation
}

View File

@ -0,0 +1,5 @@
type PrivacyMutation {
exportData(userId: Int!): String
anonymizeData(userId: Int!): String
deleteData(userId: Int!): String
}

View File

@ -86,3 +86,7 @@ class Mutation(MutationABC):
Permissions.administrator,
],
)
self.add_mutation_type(
"privacy",
"Privacy",
)

View File

@ -0,0 +1,63 @@
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from api_graphql.service.exceptions import UnauthorizedException, AccessDenied
from core.logger import APILogger
from service.data_privacy_service import DataPrivacyService
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
class PrivacyMutation(MutationABC):
def __init__(self):
MutationABC.__init__(self, "Privacy")
self.field(
MutationFieldBuilder("exportData")
.with_resolver(self.resolve_export_data)
.with_input(int, "userId")
.with_public(True)
)
self.field(
MutationFieldBuilder("anonymizeData")
.with_resolver(self.resolve_anonymize_data)
.with_input(int, "userId")
.with_public(True)
)
self.field(
MutationFieldBuilder("deleteData")
.with_resolver(self.resolve_delete_data)
.with_input(int, "userId")
.with_public(True)
)
@staticmethod
async def _permission_check(user_id: int):
user = await Route.get_user()
if user is None:
raise UnauthorizedException()
if user.id != user_id and not user.has_permission(Permissions.administrator):
raise AccessDenied()
@staticmethod
async def resolve_export_data(user_id: int, *_):
logger.debug(f"export data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.export_user_data(user_id)
@staticmethod
async def resolve_anonymize_data(user_id: int, *_):
logger.debug(f"anonymize data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.anonymize_user(user_id)
@staticmethod
async def resolve_delete_data(user_id: int, *_):
logger.debug(f"delete data for user: {user_id}")
await PrivacyMutation._permission_check(user_id)
return await DataPrivacyService.delete_user_data(user_id)

View File

@ -2,7 +2,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.setting_input import SettingInput
from core.logger import APILogger
from data.schemas.system.setting import Setting
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
@ -22,11 +22,11 @@ class SettingMutation(MutationABC):
async def resolve_change(obj: SettingInput, *_):
logger.debug(f"create new setting: {input}")
setting = await settingsDao.find_single_by({Setting.key: obj.key})
setting = await settingDao.find_single_by({Setting.key: obj.key})
if setting is None:
raise ValueError(f"Setting with key {obj.key} not found")
setting.value = obj.value
await settingsDao.update(setting)
await settingDao.update(setting)
return await settingsDao.get_by_id(setting.id)
return await settingDao.get_by_id(setting.id)

View File

@ -5,8 +5,8 @@ from api_graphql.input.user_setting_input import UserSettingInput
from core.logger import APILogger
from core.string import first_to_lower
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.system.setting_dao import settingsDao
from data.schemas.public.user_setting_dao import userSettingDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
logger = APILogger(__name__)
@ -37,13 +37,13 @@ class UserSettingMutation(MutationABC):
logger.debug("user not authorized")
return None
setting = await userSettingsDao.find_single_by(
setting = await userSettingDao.find_single_by(
[{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}]
)
if setting is None:
await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value))
await userSettingDao.create(UserSetting(0, user.id, obj.key, obj.value))
else:
setting.value = obj.value
await userSettingsDao.update(setting)
await userSettingDao.update(setting)
return await userSettingsDao.find_by_key(user, obj.key)
return await userSettingDao.find_by_key(user, obj.key)

View File

@ -32,9 +32,9 @@ from data.schemas.public.group_dao import groupDao
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from data.schemas.public.user_setting import UserSetting
from data.schemas.public.user_setting_dao import userSettingsDao
from data.schemas.public.user_setting_dao import userSettingDao
from data.schemas.system.feature_flag_dao import featureFlagDao
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
from service.permission.permissions_enum import Permissions
@ -206,8 +206,8 @@ class Query(QueryABC):
@staticmethod
async def _resolve_settings(*args, **kwargs):
if "key" in kwargs:
return [await settingsDao.find_by_key(kwargs["key"])]
return await settingsDao.get_all()
return [await settingDao.find_by_key(kwargs["key"])]
return await settingDao.get_all()
@staticmethod
async def _resolve_user_settings(*args, **kwargs):
@ -216,10 +216,10 @@ class Query(QueryABC):
return None
if "key" in kwargs:
return await userSettingsDao.find_by(
return await userSettingDao.find_by(
[{UserSetting.user_id: user.id}, {UserSetting.key: kwargs["key"]}]
)
return await userSettingsDao.find_by({UserSetting.user_id: user.id})
return await userSettingDao.find_by({UserSetting.user_id: user.id})
@staticmethod
async def _resolve_feature_flags(*args, **kwargs):

File diff suppressed because it is too large Load Diff

View File

@ -39,13 +39,13 @@ class DBContext:
return await self._pool.select_map(statement, args)
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
uid = uuid.uuid4()
raise Exception(
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
)
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
self._fails += 1
try:
logger.debug("Retry select")
@ -54,7 +54,7 @@ class DBContext:
pass
return []
except Exception as e:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
raise e
async def select(
@ -65,13 +65,13 @@ class DBContext:
return await self._pool.select(statement, args)
except (OperationalError, PoolTimeout) as e:
if self._fails >= 3:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
uid = uuid.uuid4()
raise Exception(
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
)
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
self._fails += 1
try:
logger.debug("Retry select")
@ -80,5 +80,5 @@ class DBContext:
pass
return []
except Exception as e:
logger.error(f"Database error caused by {statement}", e)
logger.error(f"Database error caused by `{statement}`", e)
raise e

View File

@ -0,0 +1,150 @@
from typing import Optional
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
class SQLSelectBuilder:
def __init__(self, table_name: str, primary_key: str):
self._table_name = table_name
self._primary_key = primary_key
self._temp_tables: dict[str, ExternalDataTempTableBuilder] = {}
self._to_use_temp_tables: list[str] = []
self._attributes: list[str] = []
self._tables: list[str] = [table_name]
self._joins: dict[str, (str, str)] = {}
self._conditions: list[str] = []
self._order_by: str = ""
self._limit: Optional[int] = None
self._offset: Optional[int] = None
def with_temp_table(
self, temp_table: ExternalDataTempTableBuilder
) -> "SQLSelectBuilder":
self._temp_tables[temp_table.table_name] = temp_table
return self
def use_temp_table(self, temp_table_name: str):
if temp_table_name not in self._temp_tables:
raise ValueError(f"Temp table {temp_table_name} not found.")
self._to_use_temp_tables.append(temp_table_name)
def with_attribute(self, attr: str, ignore_table_name=False) -> "SQLSelectBuilder":
if not ignore_table_name and not attr.startswith(self._table_name):
attr = f"{self._table_name}.{attr}"
self._attributes.append(attr)
return self
def with_foreign_attribute(self, attr: str) -> "SQLSelectBuilder":
self._attributes.append(attr)
return self
def with_table(self, table_name: str) -> "SQLSelectBuilder":
self._tables.append(table_name)
return self
def _check_prefix(self, attr: str) -> str:
assert attr is not None
valid_prefixes = [
"levenshtein",
self._table_name,
*self._joins.keys(),
*self._temp_tables.keys(),
]
if not any(attr.startswith(f"{prefix}.") for prefix in valid_prefixes):
attr = f"{self._table_name}.{attr}"
return attr
def with_value_condition(
self, attr: str, operator: str, value: str
) -> "SQLSelectBuilder":
attr = self._check_prefix(attr)
self._conditions.append(f"{attr} {operator} {value}")
return self
def with_levenshtein_condition(self, condition: str) -> "SQLSelectBuilder":
self._conditions.append(condition)
return self
def with_condition(self, attr: str, operator: str) -> "SQLSelectBuilder":
attr = self._check_prefix(attr)
self._conditions.append(f"{attr} {operator}")
return self
def with_grouped_conditions(self, conditions: list[str]) -> "SQLSelectBuilder":
self._conditions.append(f"({' AND '.join(conditions)})")
return self
def with_left_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "LEFT")
self._joins[table] = (on, "LEFT")
return self
def with_inner_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "INNER")
self._joins[table] = (on, "INNER")
return self
def with_right_join(self, table: str, on: str) -> "SQLSelectBuilder":
if table in self._joins:
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "RIGHT")
self._joins[table] = (on, "RIGHT")
return self
def with_limit(self, limit: int) -> "SQLSelectBuilder":
self._limit = limit
return self
def with_offset(self, offset: int) -> "SQLSelectBuilder":
self._offset = offset
return self
def with_order_by(self, column: str, direction: str = "ASC") -> "SQLSelectBuilder":
self._order_by = f"{column} {direction}"
return self
async def _handle_temp_table_use(self, query) -> str:
new_query = ""
for temp_table_name in self._to_use_temp_tables:
temp_table = self._temp_tables[temp_table_name]
new_query += await self._temp_tables[temp_table_name].build()
self.with_left_join(
temp_table.table_name,
f"{temp_table.join_ref_table}.{self._primary_key} = {temp_table.table_name}.{temp_table.primary_key}",
)
return f"{new_query} {query}" if new_query != "" else query
async def build(self) -> str:
query = await self._handle_temp_table_use("")
attributes = ", ".join(self._attributes) if self._attributes else "*"
query += f"SELECT {attributes} FROM {", ".join(self._tables)}"
for join in self._joins:
query += f" {self._joins[join][1]} JOIN {join} ON {self._joins[join][0]}"
if self._conditions:
query += " WHERE " + " AND ".join(self._conditions)
if self._order_by:
query += f" ORDER BY {self._order_by}"
if self._limit is not None:
query += f" LIMIT {self._limit}"
if self._offset is not None:
query += f" OFFSET {self._offset}"
return query

View File

@ -21,4 +21,4 @@ class UserSettingDao(DbModelDaoABC[UserSetting]):
)
userSettingsDao = UserSettingDao()
userSettingDao = UserSettingDao()

View File

@ -17,4 +17,4 @@ class SettingDao(DbModelDaoABC[Setting]):
return await self.find_single_by({Setting.key: key})
settingsDao = SettingDao()
settingDao = SettingDao()

View File

@ -3,7 +3,7 @@ from typing import Any
from core.logger import DBLogger
from data.abc.data_seeder_abc import DataSeederABC
from data.schemas.system.setting import Setting
from data.schemas.system.setting_dao import settingsDao
from data.schemas.system.setting_dao import settingDao
logger = DBLogger(__name__)
@ -18,8 +18,8 @@ class SettingsSeeder(DataSeederABC):
@staticmethod
async def _seed_if_not_exists(key: str, value: Any):
existing = await settingsDao.find_by_key(key)
existing = await settingDao.find_by_key(key)
if existing is not None:
return
await settingsDao.create(Setting(0, key, str(value)))
await settingDao.create(Setting(0, key, str(value)))

View File

@ -0,0 +1,119 @@
import importlib
import json
from typing import Type
from api.auth.keycloak_client import Keycloak
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
from core.string import first_to_lower
from data.schemas.administration.user_dao import userDao
logger = Logger("DataPrivacy")
class DataPrivacyService:
@staticmethod
def _dynamic_import_dao(dao_class: Type[DataAccessObjectABC]):
"""
Dynamically import a DAO class and its instance.
:param dao_class: The DAO class to be imported.
:return: The DAO instance.
"""
module = importlib.import_module(dao_class.__module__)
dao_instance = getattr(
module, first_to_lower(first_to_lower(dao_class.__name__))
)
return dao_instance
@classmethod
async def _collect_user_relevant_dao(cls):
"""
Collect all DAO classes that are relevant for data privacy.
:return: List of relevant DAO classes.
"""
# This method should return a list of DAOs that are relevant for data privacy
# For example, it could return a list of DAOs that contain user data
classes: list[DataAccessObjectABC] = [
cls._dynamic_import_dao(dao) for dao in DbModelDaoABC.__subclasses__()
]
return [x for x in classes if x.has_attribute("user_id")]
@classmethod
async def export_user_data(cls, user_id: int):
"""
Export user data from the database.
:param user_id: ID of the user whose data is to be exported.
:return: User data in a structured format.
"""
# Logic to export user data
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
collected_data = [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])
return json.dumps(collected_data, default=str)
@staticmethod
async def anonymize_user(user_id: int):
"""
Anonymize user data in the database.
:param user_id: ID of the user to be anonymized.
"""
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
keycloak_id = user.keycloak_id
# Anonymize internal data
user.keycloak_id = "ANONYMIZED"
userDao.update(user)
# Anonymize external data
try:
Keycloak.admin.delete_user(keycloak_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
@classmethod
async def delete_user_data(cls, user_id: int):
"""
Delete user data from the database.
:param user_id: ID of the user whose data is to be deleted.
"""
user = await userDao.find_by_id(user_id)
if user is None:
raise ValueError("User not found")
keycloak_id = user.keycloak_id
daos = await cls._collect_user_relevant_dao()
for dao in daos:
data = await dao.find_by([{"userid": user_id}])
try:
await dao.delete_many(data, hard_delete=True)
except Exception as e:
logger.error(f"Failed to delete data for user {user_id}", e)
raise ValueError("Failed to delete data") from e
try:
await userDao.delete(user)
except Exception as e:
logger.error(f"Failed to delete user {user_id}", e)
raise ValueError("Failed to delete user") from e
# Delete external data
try:
Keycloak.admin.delete_user(keycloak_id)
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

View File

@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MenuItem, PrimeNGConfig } from 'primeng/api';
import { MenuItem, MenuItemCommandEvent, PrimeNGConfig } from 'primeng/api';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
@ -11,6 +11,7 @@ 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 { SettingsService } from 'src/app/service/settings.service';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-header',
@ -48,11 +49,12 @@ 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();
this.user = user;
this.guiService.loadedGuiSettings$.next(true);
});
@ -117,14 +119,41 @@ export class HeaderComponent implements OnInit, OnDestroy {
visible: !!this.user,
},
{
separator: true,
label: this.translateService.instant('header.privacy'),
items: [
{
label: this.translateService.instant('privacy.export_data'),
command: () => {},
icon: 'pi pi-download',
},
{
label: this.translateService.instant('privacy.delete_data'),
command: () => {},
icon: 'pi pi-trash',
},
],
},
{
label: this.translateService.instant('header.logout'),
command: async () => {
await this.auth.logout();
},
icon: 'pi pi-sign-out',
label: this.translateService.instant('header.profile'),
items: [
{
label: this.translateService.instant('header.edit_profile'),
command: () => {
window.open(
`${this.config.settings.keycloak.url}/realms/${this.config.settings.keycloak.realm}/account`,
'_blank'
);
},
icon: 'pi pi-user-edit',
},
{
label: this.translateService.instant('header.logout'),
command: async () => {
await this.auth.logout();
},
icon: 'pi pi-sign-out',
},
],
},
];
}