diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index f61f99004..4a94a6df5 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -12,6 +12,7 @@ class App.ControllerGenericNew extends App.ControllerModal params: @item screen: @screen || 'edit' autofocus: true + handlers: @handlers ) @controller.form @@ -57,10 +58,11 @@ class App.ControllerGenericEdit extends App.ControllerModal @head = @pageData.head || @pageData.object @controller = new App.ControllerForm( - model: App[ @genericObject ] - params: @item - screen: @screen || 'edit' - autofocus: true + model: App[ @genericObject ] + params: @item + screen: @screen || 'edit' + autofocus: true + handlers: @handlers ) @controller.form diff --git a/app/assets/javascripts/app/controllers/data_privacy.coffee b/app/assets/javascripts/app/controllers/data_privacy.coffee new file mode 100644 index 000000000..3976f3db7 --- /dev/null +++ b/app/assets/javascripts/app/controllers/data_privacy.coffee @@ -0,0 +1,267 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'admin.data_privacy' + header: 'Data Privacy' + events: + 'click .js-new': 'new' + 'click .js-description': 'description' + 'click .js-toggle-tickets': 'toggleTickets' + + constructor: -> + super + @load() + @subscribeDataPrivacyTaskId = App.DataPrivacyTask.subscribe(@render) + + load: => + callback = => + @stopLoading() + @render() + @startLoading() + App.DataPrivacyTask.fetchFull( + callback + clear: true + ) + + show: (params) => + for key, value of params + if key isnt 'el' && key isnt 'shown' && key isnt 'match' + @[key] = value + + if params.integration + + # we reuse the integration parameter + # because there is no own route possible + # (see manage.coffee) + @user_id = params.integration + @navigate '#system/data_privacy' + return + + if @user_id + @new(false, @user_id) + @user_id = undefined + + render: => + runningTasks = App.DataPrivacyTask.search( + filter: + state: 'in process' + order: 'DESC' + ) + runningTasksHTML = App.view('data_privacy/tasks')( + tasks: runningTasks + ) + + failedTasks = App.DataPrivacyTask.search( + filter: + state: 'failed' + order: 'DESC' + ) + failedTasksHTML = App.view('data_privacy/tasks')( + tasks: failedTasks + ) + + completedTasks = App.DataPrivacyTask.search( + filter: + state: 'completed' + order: 'DESC' + ) + completedTasksHTML = App.view('data_privacy/tasks')( + tasks: completedTasks + ) + + # show description button, only if content exists + description = marked(App.DataPrivacyTask.description) + + @html App.view('data_privacy/index')( + taskCount: ( runningTasks.length + failedTasks.length + completedTasks.length ) + runningTaskCount: runningTasks.length + failedTaskCount: failedTasks.length + completedTaskCount: completedTasks.length + runningTasksHTML: runningTasksHTML + failedTasksHTML: failedTasksHTML + completedTasksHTML: completedTasksHTML + description: description + ) + + release: => + if @subscribeDataPrivacyTaskId + App.DataPrivacyTask.unsubscribe(@subscribeDataPrivacyTaskId) + + new: (e, user_id = undefined) -> + if e + e.preventDefault() + + new TaskNew( + pageData: + head: 'Deletion Task' + title: 'Deletion Task' + object: 'DataPrivacyTask' + objects: 'DataPrivacyTasks' + genericObject: 'DataPrivacyTask' + container: @el.closest('.content') + callback: @load + large: true + handlers: [@formHandler] + item: + 'deletable_id': user_id + ) + + toggleTickets: (e) -> + e.preventDefault() + + id = $(e.target).data('id') + type = $(e.target).data('type') + expanded = $(e.target).hasClass('expanded') + return if !id + + new_expanded = '' + text = 'See more' + if !expanded + new_expanded = ' expanded' + text = 'See less' + + task = App.DataPrivacyTask.find(id) + + list = clone(task.preferences[type]) + if expanded + list = list.slice(0, 50) + list.push('...') + list = list.join(', ') + + $(e.target).closest('div.ticket-list').html(list + '
' + App.i18n.translateInline(text) + '
') + + description: (e) => + new App.ControllerGenericDescription( + description: App.DataPrivacyTask.description + container: @el.closest('.content') + ) + + formHandler: (params, attribute, attributes, classname, form, ui) -> + return if !attribute + + userID = params['deletable_id'] + if userID + $('body').find('.js-TaskNew').removeClass('hidden') + else + $('body').find('.js-TaskNew').addClass('hidden') + form.find('.js-preview').remove() + + return if !userID + + conditionCustomer = + 'condition': + 'ticket.customer_id': + 'operator': 'is' + 'pre_condition':'specific' + 'value': userID + + conditionOwner = + 'condition': + 'ticket.owner_id': + 'operator': 'is' + 'pre_condition':'specific' + 'value': userID + + App.Ajax.request( + id: 'ticket_selector' + type: 'POST' + url: "#{App.Config.get('api_path')}/tickets/selector" + data: JSON.stringify(conditionCustomer) + processData: true, + success: (dataCustomer, status, xhr) -> + App.Collection.loadAssets(dataCustomer.assets) + + App.Ajax.request( + id: 'ticket_selector' + type: 'POST' + url: "#{App.Config.get('api_path')}/tickets/selector" + data: JSON.stringify(conditionOwner) + processData: true, + success: (dataOwner, status, xhr) -> + App.Collection.loadAssets(dataOwner.assets) + + user = App.User.find(userID) + deleteOrganization = '' + if user.organization_id + organization = App.Organization.find(user.organization_id) + if organization && organization.member_ids.length < 2 + attribute = { name: 'preferences::delete_organization', display: 'Delete organization?', tag: 'boolean', default: true, translate: true } + deleteOrganization = ui.formGenItem(attribute, classname, form).html() + + sure_attribute = { name: 'preferences::sure', display: 'Are you sure?', tag: 'input', translate: false, placeholder: App.i18n.translateInline('delete').toUpperCase() } + sureInput = ui.formGenItem(sure_attribute, classname, form).html() + + preview_html = App.view('data_privacy/preview')( + customer_count: dataCustomer.ticket_count || 0 + owner_count: dataOwner.ticket_count || 0 + delete_organization_html: deleteOrganization + sure_html: sureInput + user_id: userID + ) + + if form.find('.js-preview').length < 1 + form.append(preview_html) + else + form.find('.js-preview').replaceWith(preview_html) + + new App.TicketList( + tableId: 'ticket-selector' + el: form.find('.js-previewTableCustomer') + ticket_ids: dataCustomer.ticket_ids + ) + new App.TicketList( + tableId: 'ticket-selector' + el: form.find('.js-previewTableOwner') + ticket_ids: dataOwner.ticket_ids + ) + ) + ) + +class TaskNew extends App.ControllerGenericNew + buttonSubmit: 'Delete' + buttonClass: 'btn--danger js-TaskNew hidden' + + content: -> + if @item['deletable_id'] + @buttonClass = 'btn--danger js-TaskNew' + else + @buttonClass = 'btn--danger js-TaskNew hidden' + + super + + onSubmit: (e) -> + params = @formParam(e.target) + params['deletable_type'] = 'User' + + object = new App[ @genericObject ] + object.load(params) + + # validate + errors = object.validate() + if params['preferences']['sure'] isnt App.i18n.translateInline('delete').toUpperCase() + if !errors + errors = {} + errors['preferences::sure'] = 'invalid' + + if errors + @log 'error', errors + @formValidate( form: e.target, errors: errors ) + return false + + # disable form + @formDisable(e) + + # save object + ui = @ + object.save( + done: -> + if ui.callback + item = App[ ui.genericObject ].fullLocal(@id) + ui.callback(item) + ui.close() + + fail: (settings, details) -> + ui.log 'errors', details + ui.formEnable(e) + ui.controller.showAlert(details.error_human || details.error || 'Unable to create object!') + ) + +App.Config.set('DataPrivacy', { prio: 3600, name: 'Data Privacy', parent: '#system', target: '#system/data_privacy', controller: Index, permission: ['admin.data_privacy'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/manage.coffee b/app/assets/javascripts/app/controllers/manage.coffee index ecbd3a1f4..417bbe8d9 100644 --- a/app/assets/javascripts/app/controllers/manage.coffee +++ b/app/assets/javascripts/app/controllers/manage.coffee @@ -30,4 +30,4 @@ App.Config.set('system/:target/:integration', ManageRouter, 'Routes') App.Config.set('Manage', { prio: 1000, name: 'Manage', target: '#manage', permission: ['admin.*'] }, 'NavBarAdmin') App.Config.set('Channels', { prio: 2500, name: 'Channels', target: '#channels', permission: ['admin.*'] }, 'NavBarAdmin') App.Config.set('Settings', { prio: 7000, name: 'Settings', target: '#settings', permission: ['admin.*'] }, 'NavBarAdmin') -App.Config.set('System', { prio: 8000, name: 'System', target: '#system', permission: ['admin.*'] }, 'NavBarAdmin') \ No newline at end of file +App.Config.set('System', { prio: 8000, name: 'System', target: '#system', permission: ['admin.*'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/taskbar_widget.coffee b/app/assets/javascripts/app/controllers/taskbar_widget.coffee index 74f33b954..7d566799d 100644 --- a/app/assets/javascripts/app/controllers/taskbar_widget.coffee +++ b/app/assets/javascripts/app/controllers/taskbar_widget.coffee @@ -14,6 +14,17 @@ class App.TaskbarWidget extends App.CollectionController constructor: -> super + App.Event.bind( + 'Taskbar:destroy' + (data, event) => + task = App.Taskbar.find(data.id) + return if !task + return if !task.key + + @removeTask(task.key) + 'Collection::Subscribe::Taskbar' + ) + dndOptions = tolerance: 'pointer' distance: 15 @@ -80,6 +91,10 @@ class App.TaskbarWidget extends App.CollectionController event: e ) return + @removeTask(key) + + removeTask: (key = false) => + return if !key # check if active task is closed currentTask = App.TaskManager.get(key) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee index d217d30ce..f5bc0a7f5 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee @@ -25,6 +25,15 @@ class SidebarCustomer extends App.Controller name: 'customer-edit' callback: @editCustomer } + + if @permissionCheck('admin.data_privacy') + @item.sidebarActions.push { + title: 'Delete Customer' + name: 'customer-delete' + callback: => + @navigate "#system/data_privacy/#{@ticket.customer_id}" + } + @item metaBadge: (user) => diff --git a/app/assets/javascripts/app/controllers/user_profile.coffee b/app/assets/javascripts/app/controllers/user_profile.coffee index 1fbfcf5c3..f8989e481 100644 --- a/app/assets/javascripts/app/controllers/user_profile.coffee +++ b/app/assets/javascripts/app/controllers/user_profile.coffee @@ -149,6 +149,14 @@ class ActionRow extends App.ObserverActionRow callback: @resendVerificationEmail }) + if @permissionCheck('admin.data_privacy') + actions.push { + title: 'Delete' + name: 'delete' + callback: => + @navigate "#system/data_privacy/#{user.id}" + } + actions class Object extends App.ObserverController diff --git a/app/assets/javascripts/app/controllers/users.coffee b/app/assets/javascripts/app/controllers/users.coffee index 60a4f28ef..a7b160350 100644 --- a/app/assets/javascripts/app/controllers/users.coffee +++ b/app/assets/javascripts/app/controllers/users.coffee @@ -55,28 +55,6 @@ class Index extends App.ControllerSubContent renderResult: (user_ids = []) -> @stopLoading() - callbackHeader = (header) -> - attribute = - name: 'switch_to' - display: 'Action' - className: 'actionCell' - translation: true - width: '250px' - displayWidth: 250 - unresizable: true - header.push attribute - header - - callbackAttributes = (value, object, attribute, header) -> - text = App.i18n.translateInline('View from user\'s perspective') - value = ' ' - attribute.raw = ' ' + App.Utils.icon('switchView') + '' + text + '' - attribute.class = '' - attribute.parentClass = 'actionCell no-padding' - attribute.link = '' - attribute.title = App.i18n.translateInline('Switch to') - value - switchTo = (id,e) => e.preventDefault() e.stopPropagation() @@ -129,15 +107,38 @@ class Index extends App.ControllerSubContent model: App.User objects: users class: 'user-list' - callbackHeader: [callbackHeader] - callbackAttributes: - switch_to: [ - callbackAttributes - ] - bindCol: - switch_to: - events: - 'click': switchTo + customActions: [ + { + name: 'switchTo' + display: 'View from user\'s perspective' + icon: 'switchView ' + class: 'create js-switchTo' + callback: (id) => + @disconnectClient() + $('#app').hide().attr('style', 'display: none!important') + @delay( + => + App.Auth._logout(false) + @ajax( + id: 'user_switch' + type: 'GET' + url: "#{@apiPath}/sessions/switch/#{id}" + success: (data, status, xhr) => + location = "#{window.location.protocol}//#{window.location.host}#{data.location}" + @windowReload(undefined, location) + ) + 800 + ) + } + { + name: 'delete' + display: 'Delete' + icon: 'trash' + class: 'delete' + callback: (id) => + @navigate "#system/data_privacy/#{id}" + }, + ] bindRow: events: 'click': edit diff --git a/app/assets/javascripts/app/models/data_privacy_task.coffee b/app/assets/javascripts/app/models/data_privacy_task.coffee new file mode 100644 index 000000000..eedec22f4 --- /dev/null +++ b/app/assets/javascripts/app/models/data_privacy_task.coffee @@ -0,0 +1,30 @@ +class App.DataPrivacyTask extends App.Model + @configure 'DataPrivacyTask', 'name', 'state', 'deletable_id', 'deletable_type', 'preferences' + @extend Spine.Model.Ajax + @url: @apiPath + '/data_privacy_tasks' + @configure_attributes = [ + { name: 'deletable_id', display: 'User', tag: 'autocompletion_ajax', relation: 'User', do_not_log: true }, + { name: 'state', display: 'State', tag: 'input', readonly: 1 }, + { name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 }, + { name: 'created_at', display: 'Created', tag: 'datetime', readonly: 1 }, + { name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 }, + { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 }, + ] + @configure_overview = [] + + @description = ''' +** Data Privacy **, helps you to delete and verify the removal of existing data of the system. + +It can be used to delete tickets, organizations and users. The owner assignment will be unset in case the deleted user is an agent. + +Data Privacy tasks will be executed every 10 minutes. The execution might take some additional time depending of the number of objects that should get deleted. +''' + + activityMessage: (item) -> + if item.type is 'create' + return App.i18n.translateContent('%s created data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id) + else if item.type is 'update' + return App.i18n.translateContent('%s updated data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id) + else if item.type is 'completed' + return App.i18n.translateContent('%s completed data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id) + return "Unknow action for (#{@objectDisplayName()}/#{item.type}), extend activityMessage() of model." diff --git a/app/assets/javascripts/app/views/data_privacy/index.jst.eco b/app/assets/javascripts/app/views/data_privacy/index.jst.eco new file mode 100644 index 000000000..a5398250b --- /dev/null +++ b/app/assets/javascripts/app/views/data_privacy/index.jst.eco @@ -0,0 +1,33 @@ + + +
+<% if @taskCount < 1: %> +
+ <%- @description %> +
+ <% else: %> + <% if @runningTaskCount: %> +

<%- @T('Running Tasks') %>

+ <%- @runningTasksHTML %> + <% end %> + <% if @failedTaskCount: %> +
+

<%- @T('Failed Tasks') %>

+ <%- @failedTasksHTML %> + <% end %> + <% if @completedTaskCount: %> +
+

<%- @T('Completed Tasks') %>

+ <%- @completedTasksHTML %> + <% end %> +<% end %> +
diff --git a/app/assets/javascripts/app/views/data_privacy/preview.jst.eco b/app/assets/javascripts/app/views/data_privacy/preview.jst.eco new file mode 100644 index 000000000..02856323a --- /dev/null +++ b/app/assets/javascripts/app/views/data_privacy/preview.jst.eco @@ -0,0 +1,20 @@ +
+
+ <%- @delete_organization_html %> +
+
+

<%- @T('Preview customer tickets') %> (<%= @customer_count %> <%- @T('matches') %>)

+

<%- @T('Customer tickets of the user will get deleted on execution of the task. No rollback possible.') %>

+
+ <% if @owner_count > 0: %> +

<%- @T('Preview owner tickets') %> <%= @owner_count %> <%- @T('matches') %>

+

<%- @T('Owner tickets of the user will not get deleted. The owner will be mapped to the system user (ID 1).') %>

+
+ <% end %> +
+
+

<%- @T('Warning') %>

+

<%- @T('There is no rollback of this deletion possible. If you are absolutely sure to do this, then type in "%s" into the input.', App.i18n.translateInline('delete').toUpperCase()) %>

+ <%- @sure_html %> +
+
diff --git a/app/assets/javascripts/app/views/data_privacy/tasks.jst.eco b/app/assets/javascripts/app/views/data_privacy/tasks.jst.eco new file mode 100644 index 000000000..c55830b98 --- /dev/null +++ b/app/assets/javascripts/app/views/data_privacy/tasks.jst.eco @@ -0,0 +1,54 @@ +<% if @tasks.length > 0: %> +<% for task in @tasks: %> + <% if task.preferences.user: %> +
+
+
+

<%- @T('Delete User') %>

+
+
+
+
+
<%- @T('User (censored)') %>:
+ <%= task.preferences.user.firstname %> <%= task.preferences.user.lastname %> (<%= task.preferences.user.email %>) + <% if task.preferences.user.organization && task.preferences.delete_organization: %> +

+
<%- @T('Deleted Organization') %>:
+ <%= task.preferences.user.organization %> + <% end %> +

+
<%- @T('Started') %>
+ <%- @humanTime(task.created_at) %> +

+
<%- @T('State') %>
+ <% if task.state: %><%= task.state %><% else: %><%- @T('in process') %><% end %> + <% if task.preferences.error: %> (<%= task.preferences.error %>)<% end %> +
+
+
<%- @T('Deleted tickets (%s in total)', task.preferences.customer_tickets.length) %>:
+
+ <% if task.preferences.customer_tickets.length > 0: %> + <%= task.preferences.customer_tickets.slice(0, 50).join(', ') %><% if task.preferences.customer_tickets.length > 50: %>, ...
<%- @T('See more') %>
<% end %> + <% else: %> + - + <% end %> +
+

+
<%- @T('Previously owned tickets (%s in total)', task.preferences.owner_tickets.length) %>:
+
+ <% if task.preferences.owner_tickets.length > 0: %> + <%= task.preferences.owner_tickets.slice(0, 50).join(', ') %><% if task.preferences.owner_tickets.length > 50: %>, ...
<%- @T('See more') %>
<% end %> + <% else: %> + - + <% end %> +
+
+
+
+ <% end %> +<% end %> +<% else: %> +
+ <%- @T('None') %> +
+<% end %> diff --git a/app/assets/javascripts/app/views/generic/ticket_selector.jst.eco b/app/assets/javascripts/app/views/generic/ticket_selector.jst.eco index 67e96abad..ef912c1c2 100644 --- a/app/assets/javascripts/app/views/generic/ticket_selector.jst.eco +++ b/app/assets/javascripts/app/views/generic/ticket_selector.jst.eco @@ -1,6 +1,6 @@
-

<%- @T('Preview') %> ? <%- @T('matches') %>

+

<%- @T('Preview') %> (? <%- @T('matches') %>)

\ No newline at end of file diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 3ddc75481..47774006a 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -4,6 +4,7 @@ $ok-color: hsl(41,100%,49%); $bad-color: hsl(30,93%,50%); $superbad-color: hsl(19,90%,51%); $ghost-color: hsl(0,0%,80%); +$danger-color: hsl(0,65%,55%); $task-state-closed-color: $supergood-color; $task-state-pending-color: hsl(206,7%,28%); @@ -584,15 +585,15 @@ pre code.hljs { &--danger { color: white; - background: hsl(0,65%,55%); + background: $danger-color; &:active { - background: hsl(0,65%,45%); + background: darken($danger-color, 10%); } &.btn--secondary { background: white; - color: hsl(0,65%,55%); + color: $danger-color; &:active { background: hsl(0,0%,98%); @@ -658,7 +659,7 @@ pre code.hljs { } &.btn--danger { - color: hsl(0,65%,55%); + color: $danger-color; &:active { color: hsl(0,65%,40%); @@ -5092,6 +5093,7 @@ footer { .ok-color { fill: $ok-color; } .bad-color { fill: $bad-color; } .superbad-color { fill: $superbad-color; } +.danger-color { color: $danger-color; } .stat-widgets { margin: -7px -7px 20px; @@ -7563,7 +7565,7 @@ footer { .dropdown-menu > li.danger:hover, .dropdown-menu > li.danger.is-active { - background: hsl(0,65%,55%); + background: $danger-color; } .dropdown-menu > li.create:hover, @@ -9470,7 +9472,7 @@ output { flex-wrap: wrap; padding: 10px; margin-bottom: 17px; - + &.is-inactive { background: none; box-shadow: none; @@ -9483,6 +9485,17 @@ output { } } + &--placeholder { + padding: 30px; + align-items: center; + justify-content: center; + color: hsl(206,9%,69%); + box-shadow: none; + background: none; + border-style: dashed; + font-style: italic; + } + &-alert { width: calc(100% + 20px); margin: -10px -10px 10px; diff --git a/app/controllers/data_privacy_tasks_controller.rb b/app/controllers/data_privacy_tasks_controller.rb new file mode 100644 index 000000000..c018d3716 --- /dev/null +++ b/app/controllers/data_privacy_tasks_controller.rb @@ -0,0 +1,26 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class DataPrivacyTasksController < ApplicationController + prepend_before_action { authentication_check && authorize! } + + def index + model_index_render(DataPrivacyTask, params) + end + + def show + model_show_render(DataPrivacyTask, params) + end + + def create + model_create_render(DataPrivacyTask, params) + end + + def update + model_update_render(DataPrivacyTask, params) + end + + def destroy + model_destroy_render(DataPrivacyTask, params) + end + +end diff --git a/app/controllers/monitoring_controller.rb b/app/controllers/monitoring_controller.rb index af27c92fd..153590b00 100644 --- a/app/controllers/monitoring_controller.rb +++ b/app/controllers/monitoring_controller.rb @@ -162,6 +162,11 @@ curl http://localhost/api/v1/monitoring/health_check?token=XXX issues.push "Stuck import backend '#{backend}' detected. Last update: #{job.updated_at}" end + # stuck data privacy tasks + DataPrivacyTask.where.not(state: 'completed').where('updated_at <= ?', 30.minutes.ago).find_each do |task| + issues.push "Stuck data privacy task (ID #{task.id}) detected. Last update: #{task.updated_at}" + end + token = Setting.get('monitoring_token') if issues.blank? diff --git a/app/jobs/data_privacy_task_job.rb b/app/jobs/data_privacy_task_job.rb new file mode 100644 index 000000000..c71244f16 --- /dev/null +++ b/app/jobs/data_privacy_task_job.rb @@ -0,0 +1,7 @@ +class DataPrivacyTaskJob < ApplicationJob + include HasActiveJobLock + + def perform + DataPrivacyTask.where(state: 'in process').find_each(&:perform) + end +end diff --git a/app/models/application_model.rb b/app/models/application_model.rb index 309d0784a..6d5b4f601 100644 --- a/app/models/application_model.rb +++ b/app/models/application_model.rb @@ -1,6 +1,8 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class ApplicationModel < ActiveRecord::Base + include ActiveModel::Validations + include ApplicationModel::CanActivityStreamLog include ApplicationModel::HasCache include ApplicationModel::CanLookup diff --git a/app/models/application_model/checks_user_columns_fillup.rb b/app/models/application_model/checks_user_columns_fillup.rb index a7b640197..3563e8f7d 100644 --- a/app/models/application_model/checks_user_columns_fillup.rb +++ b/app/models/application_model/checks_user_columns_fillup.rb @@ -3,8 +3,13 @@ module ApplicationModel::ChecksUserColumnsFillup extend ActiveSupport::Concern included do - before_create :fill_up_user_create - before_update :fill_up_user_update + before_validation :fill_up_user_validate + end + + def fill_up_user_validate + return fill_up_user_create if new_record? + + fill_up_user_update end =begin diff --git a/app/models/chat/agent.rb b/app/models/chat/agent.rb index b543482e3..e584c9e94 100644 --- a/app/models/chat/agent.rb +++ b/app/models/chat/agent.rb @@ -1,5 +1,8 @@ class Chat::Agent < ApplicationModel + belongs_to :created_by, class_name: 'User' + belongs_to :updated_by, class_name: 'User' + def seads_available concurrent - active_chat_count end diff --git a/app/models/concerns/checks_client_notification.rb b/app/models/concerns/checks_client_notification.rb index dea1cae57..79ba45e3c 100644 --- a/app/models/concerns/checks_client_notification.rb +++ b/app/models/concerns/checks_client_notification.rb @@ -1,4 +1,5 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + module ChecksClientNotification extend ActiveSupport::Concern @@ -9,134 +10,132 @@ module ChecksClientNotification after_destroy :notify_clients_after_destroy end -=begin + def notify_clients_data(event) + class_name = self.class.name + class_name.gsub!(/::/, '') -notify_clients_after_create after model got created + { + message: { + event: "#{class_name}:#{event}", + data: { id: id, updated_at: updated_at } + }, + type: 'authenticated', + } + end -used as callback in model file + def notify_clients_send(data) + return notify_clients_send_to(data[:message]) if client_notification_send_to.present? -class OwnModel < ApplicationModel - after_create :notify_clients_after_create - after_update :notify_clients_after_update - after_touch :notify_clients_after_touch - after_destroy :notify_clients_after_destroy + PushMessages.send(data) + end - [...] - -=end + def notify_clients_send_to(data) + client_notification_send_to.each do |user_id| + PushMessages.send_to(send(user_id), data) + end + end def notify_clients_after_create # return if we run import mode return if Setting.get('import_mode') + # skip if ignored + return if client_notification_events_ignored.include?(:create) + logger.debug { "#{self.class.name}.find(#{id}) notify created #{created_at}" } - class_name = self.class.name - class_name.gsub!(/::/, '') - PushMessages.send( - message: { - event: class_name + ':create', - data: { id: id, updated_at: updated_at } - }, - type: 'authenticated', - ) + + data = notify_clients_data(:create) + notify_clients_send(data) end -=begin - -notify_clients_after_update after model got updated - -used as callback in model file - -class OwnModel < ApplicationModel - after_create :notify_clients_after_create - after_update :notify_clients_after_update - after_touch :notify_clients_after_touch - after_destroy :notify_clients_after_destroy - - [...] - -=end - def notify_clients_after_update # return if we run import mode return if Setting.get('import_mode') + # skip if ignored + return if client_notification_events_ignored.include?(:update) + logger.debug { "#{self.class.name}.find(#{id}) notify UPDATED #{updated_at}" } - class_name = self.class.name - class_name.gsub!(/::/, '') - PushMessages.send( - message: { - event: class_name + ':update', - data: { id: id, updated_at: updated_at } - }, - type: 'authenticated', - ) + + data = notify_clients_data(:update) + notify_clients_send(data) end -=begin - -notify_clients_after_touch after model got touched - -used as callback in model file - -class OwnModel < ApplicationModel - after_create :notify_clients_after_create - after_update :notify_clients_after_update - after_touch :notify_clients_after_touch - after_destroy :notify_clients_after_destroy - - [...] - -=end - def notify_clients_after_touch # return if we run import mode return if Setting.get('import_mode') + # skip if ignored + return if client_notification_events_ignored.include?(:touch) + logger.debug { "#{self.class.name}.find(#{id}) notify TOUCH #{updated_at}" } - class_name = self.class.name - class_name.gsub!(/::/, '') - PushMessages.send( - message: { - event: class_name + ':touch', - data: { id: id, updated_at: updated_at } - }, - type: 'authenticated', - ) + + data = notify_clients_data(:touch) + notify_clients_send(data) end -=begin - -notify_clients_after_destroy after model got destroyed - -used as callback in model file - -class OwnModel < ApplicationModel - after_create :notify_clients_after_create - after_update :notify_clients_after_update - after_touch :notify_clients_after_touch - after_destroy :notify_clients_after_destroy - - [...] - -=end def notify_clients_after_destroy # return if we run import mode return if Setting.get('import_mode') + # skip if ignored + return if client_notification_events_ignored.include?(:destroy) + logger.debug { "#{self.class.name}.find(#{id}) notify DESTOY #{updated_at}" } - class_name = self.class.name - class_name.gsub!(/::/, '') - PushMessages.send( - message: { - event: class_name + ':destroy', - data: { id: id, updated_at: updated_at } - }, - type: 'authenticated', - ) + + data = notify_clients_data(:destroy) + notify_clients_send(data) end + + private + + def client_notification_events_ignored + @client_notification_events_ignored ||= self.class.instance_variable_get(:@client_notification_events_ignored) || [] + end + + def client_notification_send_to + @client_notification_send_to ||= self.class.instance_variable_get(:@client_notification_send_to) || [] + end + + # methods defined here are going to extend the class, not the instance of it + class_methods do + +=begin + +serve method to ignore events + +class Model < ApplicationModel + include ChecksClientNotification + client_notification_events_ignored :create, :update, :touch +end + +=end + + def client_notification_events_ignored(*attributes) + @client_notification_events_ignored ||= [] + @client_notification_events_ignored |= attributes + end + +=begin + +serve method to define recipient user ids + +class Model < ApplicationModel + include ChecksClientNotification + client_notification_send_to :user_id +end + +=end + + def client_notification_send_to(*attributes) + @client_notification_send_to ||= [] + @client_notification_send_to |= attributes + end + + end + end diff --git a/app/models/concerns/has_object_manager_attributes_validation.rb b/app/models/concerns/has_object_manager_attributes_validation.rb index 60e19c2b0..6cb1d82a1 100644 --- a/app/models/concerns/has_object_manager_attributes_validation.rb +++ b/app/models/concerns/has_object_manager_attributes_validation.rb @@ -3,7 +3,6 @@ module HasObjectManagerAttributesValidation extend ActiveSupport::Concern included do - include ActiveModel::Validations validates_with ObjectManager::Attribute::Validation, on: %i[create update] end end diff --git a/app/models/concerns/has_taskbars.rb b/app/models/concerns/has_taskbars.rb new file mode 100644 index 000000000..07844893b --- /dev/null +++ b/app/models/concerns/has_taskbars.rb @@ -0,0 +1,22 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module HasTaskbars + extend ActiveSupport::Concern + + included do + before_destroy :destroy_taskbars + end + +=begin + +destroy all taskbars for the class object id + + model = Model.find(123) + model.destroy + +=end + + def destroy_taskbars + Taskbar.where(key: "#{self.class}-#{id}").destroy_all + end + +end diff --git a/app/models/data_privacy_task.rb b/app/models/data_privacy_task.rb new file mode 100644 index 000000000..9f13923ee --- /dev/null +++ b/app/models/data_privacy_task.rb @@ -0,0 +1,83 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class DataPrivacyTask < ApplicationModel + include DataPrivacyTask::HasActivityStreamLog + include ChecksClientNotification + + store :preferences + + belongs_to :created_by, class_name: 'User' + belongs_to :updated_by, class_name: 'User' + + # optional because related data will get deleted and it would + # cause validation errors if e.g. the created_by_id of the task + # would need to get mapped by a deletion + belongs_to :deletable, polymorphic: true, optional: true + + before_create :prepare_deletion_preview + + validates_with DataPrivacyTask::Validation + + def perform + return if deletable.blank? + + prepare_deletion_preview + save! + + if delete_organization? + deletable.organization.destroy + else + deletable.destroy + end + + update!(state: 'completed') + rescue => e + handle_exception(e) + end + + def handle_exception(e) + Rails.logger.error e + preferences[:error] = "ERROR: #{e.inspect}" + self.state = 'failed' + save! + end + + def delete_organization? + return false if preferences[:delete_organization].blank? + return false if preferences[:delete_organization] != 'true' + return false if !deletable.organization + return false if deletable.organization.members.count != 1 + + true + end + + def prepare_deletion_preview + prepare_deletion_preview_tickets + prepare_deletion_preview_user + prepare_deletion_preview_organization + prepare_deletion_preview_anonymize + end + + def prepare_deletion_preview_tickets + preferences[:owner_tickets] = deletable.owner_tickets.order(id: 'DESC').map(&:number) + preferences[:customer_tickets] = deletable.customer_tickets.order(id: 'DESC').map(&:number) + end + + def prepare_deletion_preview_user + preferences[:user] = { + firstname: deletable.firstname, + lastname: deletable.lastname, + email: deletable.email, + } + end + + def prepare_deletion_preview_organization + return if !deletable.organization + + preferences[:user][:organization] = deletable.organization.name + end + + def prepare_deletion_preview_anonymize + preferences[:user] = Pseudonymisation.of_hash(preferences[:user]) + end +end diff --git a/app/models/data_privacy_task/has_activity_stream_log.rb b/app/models/data_privacy_task/has_activity_stream_log.rb new file mode 100644 index 000000000..a22ba912f --- /dev/null +++ b/app/models/data_privacy_task/has_activity_stream_log.rb @@ -0,0 +1,18 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module DataPrivacyTask::HasActivityStreamLog + extend ActiveSupport::Concern + + included do + include ::HasActivityStreamLog + after_update :log_activity + + activity_stream_permission 'admin.data_privacy' + end + + def log_activity + return if !saved_change_to_attribute?('state') + return if state != 'completed' + + activity_stream_log('completed', created_by_id, true) + end +end diff --git a/app/models/data_privacy_task/validation.rb b/app/models/data_privacy_task/validation.rb new file mode 100644 index 000000000..2b533aa21 --- /dev/null +++ b/app/models/data_privacy_task/validation.rb @@ -0,0 +1,101 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class DataPrivacyTask::Validation < ActiveModel::Validator + + attr_reader :record + + def validate(record) + @record = record + + check_for_user + check_for_system_user + check_for_current_user + check_for_last_admin + check_for_existing_task + end + + private + + def check_for_user + return if deletable_is_user? + + invalid_because(:deletable, 'is not a User') + end + + def check_for_system_user + return if !deletable_is_user? + return if deletable.id != 1 + + invalid_because(:deletable, 'is undeletable system User with ID 1') + end + + def check_for_current_user + return if !deletable_is_user? + return if deletable.id != UserInfo.current_user_id + + invalid_because(:deletable, 'is your current account') + end + + def check_for_last_admin + return if !deletable_is_user? + return if !last_admin? + + invalid_because(:deletable, 'is last account with admin permissions') + end + + def check_for_existing_task + return if !deletable_is_user? + return if !tasks_exists? + + invalid_because(:deletable, 'has an existing DataPrivacyTask queued') + end + + def deletable_is_user? + deletable.is_a?(User) + end + + def deletable + record.deletable + end + + def invalid_because(attribute, message) + record.errors.add attribute, message + end + + def tasks_exists? + DataPrivacyTask.where( + deletable: deletable + ).where.not( + id: record.id, + state: 'failed' + ).exists? + end + + def last_admin? + return false if !deletable_is_admin? + + future_admin_ids.blank? + end + + def future_admin_ids + other_admin_ids - existing_jobs_admin_ids + end + + def other_admin_ids + admin_users.where.not(id: deletable.id).pluck(:id) + end + + def deletable_is_admin? + admin_users.exists?(id: deletable.id) + end + + def existing_jobs_admin_ids + DataPrivacyTask.where( + deletable_id: other_admin_ids, + deletable_type: 'User' + ).pluck(:deletable_id) + end + + def admin_users + User.with_permissions('admin') + end +end diff --git a/app/models/karma/activity_log.rb b/app/models/karma/activity_log.rb index a3aacbc73..92db7b8a5 100644 --- a/app/models/karma/activity_log.rb +++ b/app/models/karma/activity_log.rb @@ -2,6 +2,7 @@ class Karma::ActivityLog < ApplicationModel belongs_to :object_lookup, optional: true + belongs_to :user, class_name: '::User' self.table_name = 'karma_activity_logs' diff --git a/app/models/organization.rb b/app/models/organization.rb index a58186f7a..ea262abf6 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -9,12 +9,14 @@ class Organization < ApplicationModel include CanCsvImport include ChecksHtmlSanitized include HasObjectManagerAttributesValidation + include HasTaskbars include Organization::Assets include Organization::Search include Organization::SearchIndex - has_many :members, class_name: 'User' + has_many :members, class_name: 'User', dependent: :destroy + has_many :tickets, class_name: 'Ticket', dependent: :destroy before_create :domain_cleanup before_update :domain_cleanup diff --git a/app/models/recent_view.rb b/app/models/recent_view.rb index 02db62002..354f62cd4 100644 --- a/app/models/recent_view.rb +++ b/app/models/recent_view.rb @@ -6,6 +6,7 @@ class RecentView < ApplicationModel # rubocop:disable Rails/InverseOf belongs_to :ticket, foreign_key: 'o_id', optional: true belongs_to :object, class_name: 'ObjectLookup', foreign_key: 'recent_view_object_id', optional: true + belongs_to :created_by, class_name: 'User' # rubocop:enable Rails/InverseOf after_create :notify_clients diff --git a/app/models/taskbar.rb b/app/models/taskbar.rb index bf8ae393b..4d9940ef1 100644 --- a/app/models/taskbar.rb +++ b/app/models/taskbar.rb @@ -1,15 +1,24 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class Taskbar < ApplicationModel + include ChecksClientNotification + store :state store :params store :preferences + + belongs_to :user + before_create :update_last_contact, :set_user, :update_preferences_infos before_update :update_last_contact, :set_user, :update_preferences_infos after_update :notify_clients after_destroy :update_preferences_infos, :notify_clients + client_notification_events_ignored :create, :update, :touch + + client_notification_send_to :user_id + attr_accessor :local_update def state_changed? diff --git a/app/models/template.rb b/app/models/template.rb index 470b86239..632f9f6f2 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -3,6 +3,10 @@ class Template < ApplicationModel include ChecksClientNotification + belongs_to :user, optional: true + store :options validates :name, presence: true + + association_attributes_ignored :user end diff --git a/app/models/text_module.rb b/app/models/text_module.rb index 89f49d2e4..4a436ae41 100644 --- a/app/models/text_module.rb +++ b/app/models/text_module.rb @@ -5,6 +5,8 @@ class TextModule < ApplicationModel include ChecksHtmlSanitized include CanCsvImport + belongs_to :user, optional: true + validates :name, presence: true validates :content, presence: true @@ -17,6 +19,8 @@ class TextModule < ApplicationModel has_and_belongs_to_many :groups, after_add: :cache_update, after_remove: :cache_update, class_name: 'Group' + association_attributes_ignored :user + =begin load text modules from online diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 3a16f9f19..5e8273b8c 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -14,6 +14,7 @@ class Ticket < ApplicationModel include HasKarmaActivityLog include HasLinks include HasObjectManagerAttributesValidation + include HasTaskbars include Ticket::Escalation include Ticket::Subject @@ -64,6 +65,7 @@ class Ticket < ApplicationModel belongs_to :organization, optional: true has_many :articles, class_name: 'Ticket::Article', after_add: :cache_update, after_remove: :cache_update, dependent: :destroy, inverse_of: :ticket has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket + has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy belongs_to :state, class_name: 'Ticket::State', optional: true belongs_to :priority, class_name: 'Ticket::Priority', optional: true belongs_to :owner, class_name: 'User', optional: true @@ -73,6 +75,8 @@ class Ticket < ApplicationModel belongs_to :create_article_type, class_name: 'Ticket::Article::Type', optional: true belongs_to :create_article_sender, class_name: 'Ticket::Article::Sender', optional: true + association_attributes_ignored :flags + self.inheritance_column = nil attr_accessor :callback_loop diff --git a/app/models/ticket/flag.rb b/app/models/ticket/flag.rb index e656397fc..c0635bf04 100644 --- a/app/models/ticket/flag.rb +++ b/app/models/ticket/flag.rb @@ -1,4 +1,7 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class Ticket::Flag < ApplicationModel + belongs_to :ticket + + association_attributes_ignored :ticket end diff --git a/app/models/user.rb b/app/models/user.rb index a3d376d1b..b326acc31 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,16 +14,32 @@ class User < ApplicationModel include HasRoles include HasObjectManagerAttributesValidation include HasTicketCreateScreenImpact + include HasTaskbars include User::HasTicketCreateScreenImpact include User::Assets include User::Search include User::SearchIndex - has_and_belongs_to_many :organizations, after_add: :cache_update, after_remove: :cache_update, class_name: 'Organization' - has_many :tokens, after_add: :cache_update, after_remove: :cache_update - has_many :authorizations, after_add: :cache_update, after_remove: :cache_update - belongs_to :organization, inverse_of: :members, optional: true - has_many :permissions, -> { where(roles: { active: true }, active: true) }, through: :roles + has_and_belongs_to_many :organizations, after_add: :cache_update, after_remove: :cache_update, class_name: 'Organization' + has_many :tokens, after_add: :cache_update, after_remove: :cache_update, dependent: :destroy + has_many :authorizations, after_add: :cache_update, after_remove: :cache_update, dependent: :destroy + has_many :online_notifications, dependent: :destroy + has_many :templates, dependent: :destroy + has_many :taskbars, dependent: :destroy + has_many :user_devices, dependent: :destroy + has_one :chat_agent_created_by, class_name: 'Chat::Agent', foreign_key: :created_by_id, dependent: :destroy, inverse_of: :created_by + has_one :chat_agent_updated_by, class_name: 'Chat::Agent', foreign_key: :updated_by_id, dependent: :destroy, inverse_of: :updated_by + has_many :chat_sessions, class_name: 'Chat::Session', dependent: :destroy + has_many :karma_user, class_name: 'Karma::User', dependent: :destroy + has_many :karma_activity_logs, class_name: 'Karma::ActivityLog', dependent: :destroy + has_many :cti_caller_ids, class_name: 'Cti::CallerId', dependent: :destroy + has_many :text_modules, dependent: :destroy + has_many :customer_tickets, class_name: 'Ticket', foreign_key: :customer_id, dependent: :destroy, inverse_of: :customer + has_many :owner_tickets, class_name: 'Ticket', foreign_key: :owner_id, inverse_of: :owner + has_many :created_recent_views, class_name: 'RecentView', foreign_key: :created_by_id, dependent: :destroy, inverse_of: :created_by + has_many :permissions, -> { where(roles: { active: true }, active: true) }, through: :roles + has_many :data_privacy_tasks, as: :deletable + belongs_to :organization, inverse_of: :members, optional: true before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier before_validation :check_mail_delivery_failed, on: :update @@ -32,10 +48,12 @@ class User < ApplicationModel after_create :avatar_for_email_check, unless: -> { BulkImportInfo.enabled? } after_update :avatar_for_email_check, unless: -> { BulkImportInfo.enabled? } after_commit :update_caller_id - before_destroy :destroy_longer_required_objects + before_destroy :destroy_longer_required_objects, :destroy_move_dependency_ownership store :preferences + association_attributes_ignored :online_notifications, :templates, :taskbars, :user_devices, :chat_sessions, :karma_activity_logs, :cti_caller_ids, :text_modules, :customer_tickets, :owner_tickets, :created_recent_views, :chat_agents, :data_privacy_tasks + activity_stream_permission 'admin.user' activity_stream_attributes_ignored :last_login, @@ -1147,20 +1165,39 @@ raise 'Minimum one user need to have admin permissions' end def destroy_longer_required_objects - ::Authorization.where(user_id: id).destroy_all - ::Avatar.remove('User', id) - ::Cti::CallerId.where(user_id: id).destroy_all - ::Taskbar.where(user_id: id).destroy_all - ::Karma::ActivityLog.where(user_id: id).destroy_all - ::Karma::User.where(user_id: id).destroy_all - ::OnlineNotification.where(user_id: id).destroy_all - ::RecentView.where(created_by_id: id).destroy_all + ::Avatar.remove(self.class.to_s, id) ::UserDevice.remove(id) - ::Token.where(user_id: id).destroy_all ::StatsStore.remove( - object: 'User', + object: self.class.to_s, o_id: id, ) + end + + def destroy_move_dependency_ownership + result = Models.references(self.class.to_s, id) + + result.each do |class_name, references| + next if class_name.blank? + next if references.blank? + + ref_class = class_name.constantize + references.each do |column, reference_found| + next if !reference_found + + if %w[created_by_id updated_by_id origin_by_id owner_id archived_by_id published_by_id internal_by_id].include?(column) + ref_class.where(column => id).find_in_batches(batch_size: 1000) do |batch_list| + batch_list.each do |record| + record.update!(column => 1) + rescue => e + Rails.logger.error e + end + end + elsif ref_class.exists?(column => id) + raise "Failed deleting references! Check logic for #{class_name}->#{column}." + end + end + end + true end diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 20dfc8cf2..834de2417 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -5,9 +5,13 @@ class UserDevice < ApplicationModel store :location_details validates :name, presence: true + belongs_to :user + before_create :fingerprint_validation before_update :fingerprint_validation + association_attributes_ignored :user + =begin store new device for user if device not already known diff --git a/app/policies/controllers/data_privacy_tasks_controller_policy.rb b/app/policies/controllers/data_privacy_tasks_controller_policy.rb new file mode 100644 index 000000000..1c44bef48 --- /dev/null +++ b/app/policies/controllers/data_privacy_tasks_controller_policy.rb @@ -0,0 +1,3 @@ +class Controllers::DataPrivacyTasksControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.data_privacy') +end diff --git a/config/routes/data_privacy_task.rb b/config/routes/data_privacy_task.rb new file mode 100644 index 000000000..e878e5031 --- /dev/null +++ b/config/routes/data_privacy_task.rb @@ -0,0 +1,10 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/data_privacy_tasks', to: 'data_privacy_tasks#index', via: :get + match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#show', via: :get + match api_path + '/data_privacy_tasks', to: 'data_privacy_tasks#create', via: :post + match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#update', via: :put + match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#destroy', via: :delete + +end diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index 4c436334b..93d2e816c 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -740,5 +740,17 @@ class CreateBase < ActiveRecord::Migration[4.2] add_index :smime_certificates, [:fingerprint], unique: true add_index :smime_certificates, [:modulus] add_index :smime_certificates, [:subject] + + create_table :data_privacy_tasks do |t| + t.column :name, :string, limit: 150, null: true + t.column :state, :string, limit: 150, default: 'in process', null: true + t.references :deletable, polymorphic: true + t.string :preferences, limit: 8000, null: true + t.column :updated_by_id, :integer, null: false + t.column :created_by_id, :integer, null: false + t.timestamps limit: 3, null: false + end + add_index :data_privacy_tasks, [:name] + add_index :data_privacy_tasks, [:state] end end diff --git a/db/migrate/20200707000001_data_privacy_init.rb b/db/migrate/20200707000001_data_privacy_init.rb new file mode 100644 index 000000000..f787188a5 --- /dev/null +++ b/db/migrate/20200707000001_data_privacy_init.rb @@ -0,0 +1,52 @@ +class DataPrivacyInit < ActiveRecord::Migration[4.2] + def up + + # return if it's a new setup + return if !Setting.exists?(name: 'system_init_done') + + up_table + up_permission + up_scheduler + end + + def up_table + create_table :data_privacy_tasks do |t| + t.column :name, :string, limit: 150, null: true + t.column :state, :string, limit: 150, default: 'in process', null: true + t.references :deletable, polymorphic: true + t.string :preferences, limit: 8000, null: true + t.column :updated_by_id, :integer, null: false + t.column :created_by_id, :integer, null: false + t.timestamps limit: 3, null: false + end + add_index :data_privacy_tasks, [:name] + add_index :data_privacy_tasks, [:state] + end + + def up_permission + Permission.create_if_not_exists( + name: 'admin.data_privacy', + note: 'Manage %s', + preferences: { + translations: ['Data Privacy'] + }, + ) + end + + def up_scheduler + Scheduler.create_or_update( + name: 'Handle data privacy tasks.', + method: 'DataPrivacyTaskJob.perform_now', + period: 10.minutes, + last_run: Time.zone.now, + prio: 2, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + end + + def self.down + drop_table :data_privacy_tasks + end +end diff --git a/db/seeds/permissions.rb b/db/seeds/permissions.rb index 0114af9d8..728b8a688 100644 --- a/db/seeds/permissions.rb +++ b/db/seeds/permissions.rb @@ -227,6 +227,13 @@ Permission.create_if_not_exists( translations: ['Monitoring'] }, ) +Permission.create_if_not_exists( + name: 'admin.data_privacy', + note: 'Manage %s', + preferences: { + translations: ['Data Privacy'] + }, +) Permission.create_if_not_exists( name: 'admin.maintenance', note: 'Manage %s', diff --git a/db/seeds/schedulers.rb b/db/seeds/schedulers.rb index 7234b202a..bdc61e964 100644 --- a/db/seeds/schedulers.rb +++ b/db/seeds/schedulers.rb @@ -199,3 +199,13 @@ Scheduler.create_if_not_exists( updated_by_id: 1, created_by_id: 1 ) +Scheduler.create_if_not_exists( + name: 'Handle data privacy tasks.', + method: 'DataPrivacyTaskJob.perform_now', + period: 10.minutes, + last_run: Time.zone.now, + prio: 2, + active: true, + updated_by_id: 1, + created_by_id: 1, +) diff --git a/lib/models.rb b/lib/models.rb index 3aee5d4c3..d0d3120b0 100644 --- a/lib/models.rb +++ b/lib/models.rb @@ -114,7 +114,7 @@ returns =end - def self.references(object_name, object_id) + def self.references(object_name, object_id, include_zero = false) object_name = object_name.to_s # check if model exists @@ -143,7 +143,7 @@ returns next if !model_attributes[:attributes].include?(item) count = model_class.where("#{item} = ?", object_id).count - next if count.zero? + next if count.zero? && !include_zero if !references[model_class.to_s][item] references[model_class.to_s][item] = 0 @@ -166,7 +166,7 @@ returns if reflection_value.options[:class_name] == object_name count = model_class.where("#{col_name} = ?", object_id).count - next if count.zero? + next if count.zero? && !include_zero if !references[model_class.to_s][col_name] references[model_class.to_s][col_name] = 0 @@ -179,7 +179,7 @@ returns next if reflection_value.name != object_name.downcase.to_sym count = model_class.where("#{col_name} = ?", object_id).count - next if count.zero? + next if count.zero? && !include_zero if !references[model_class.to_s][col_name] references[model_class.to_s][col_name] = 0 diff --git a/lib/pseudonymisation.rb b/lib/pseudonymisation.rb new file mode 100644 index 000000000..7d582e5df --- /dev/null +++ b/lib/pseudonymisation.rb @@ -0,0 +1,43 @@ +class Pseudonymisation + + def self.of_hash(source) + return if source.blank? + + source.transform_values do |value| + of_value(value) + end + end + + def self.of_value(source) + of_email_address(source) + rescue + of_string(source) + end + + def self.of_email_address(source) + email_address = Mail::AddressList.new(source).addresses.first + "#{of_string(email_address.local)}@#{of_domain(email_address.domain)}" + rescue + raise ArgumentError + end + + def self.of_domain(source) + domain_parts = source.split('.') + + # e.g. localhost + return of_string(source) if domain_parts.size == 1 + + tld = domain_parts[-1] + other = domain_parts[0..-2].join('.') + "#{of_string(other)}.#{tld}" + end + + def self.of_string(source) + return '*' if source.length == 1 + return "#{source.first}*#{source.last}" if source.exclude?(' ') + + source.split(' ').map do |sub_string| + of_string(sub_string) + end.join(' ') + end +end diff --git a/spec/db/migrate/issue_1977_remove_invalid_user_foreign_keys_spec.rb b/spec/db/migrate/issue_1977_remove_invalid_user_foreign_keys_spec.rb index 45441c0a1..bc626dd0f 100644 --- a/spec/db/migrate/issue_1977_remove_invalid_user_foreign_keys_spec.rb +++ b/spec/db/migrate/issue_1977_remove_invalid_user_foreign_keys_spec.rb @@ -23,7 +23,8 @@ RSpec.describe Issue1977RemoveInvalidUserForeignKeys, type: :db_migration do without_foreign_key(:online_notifications, column: :user_id) without_foreign_key(:recent_views, column: :created_by_id) - create(:recent_view, created_by_id: 1337) + record = build(:recent_view, created_by_id: 1337) + record.save(validate: false) create(:recent_view, created_by_id: existing_user_id) expect do diff --git a/spec/factories/chat/agent.rb b/spec/factories/chat/agent.rb new file mode 100644 index 000000000..8aca69c8b --- /dev/null +++ b/spec/factories/chat/agent.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :'chat/agent' do + created_by_id { 1 } + updated_by_id { 1 } + end +end diff --git a/spec/factories/data_privacy_task.rb b/spec/factories/data_privacy_task.rb new file mode 100644 index 000000000..cc2d470e3 --- /dev/null +++ b/spec/factories/data_privacy_task.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :data_privacy_task do + created_by_id { 1 } + updated_by_id { 1 } + end +end diff --git a/spec/factories/knowledge_base/answer/translation.rb b/spec/factories/knowledge_base/answer/translation.rb index e7aeb0eeb..d6c065bd4 100644 --- a/spec/factories/knowledge_base/answer/translation.rb +++ b/spec/factories/knowledge_base/answer/translation.rb @@ -1,5 +1,7 @@ FactoryBot.define do factory 'knowledge_base/answer/translation', aliases: %i[knowledge_base_answer_translation] do + created_by_id { 1 } + updated_by_id { 1 } answer { nil } kb_locale { nil } sequence(:title) { |n| "#{Faker::Appliance.equipment} ##{n}" } diff --git a/spec/factories/taskbar.rb b/spec/factories/taskbar.rb index 0ac816f30..69fedf67f 100644 --- a/spec/factories/taskbar.rb +++ b/spec/factories/taskbar.rb @@ -7,5 +7,6 @@ FactoryBot.define do state {} prio { 1 } notify { false } + user_id { 1 } end end diff --git a/spec/factories/ticket/flag.rb b/spec/factories/ticket/flag.rb new file mode 100644 index 000000000..0f44e135f --- /dev/null +++ b/spec/factories/ticket/flag.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :'ticket/flag', aliases: %i[ticket_flag] do + ticket + key { "key_#{rand(100)}" } + value { "value_#{rand(100)}" } + created_by_id { 1 } + end +end diff --git a/spec/jobs/data_privacy_task_job_spec.rb b/spec/jobs/data_privacy_task_job_spec.rb new file mode 100644 index 000000000..e98a2133f --- /dev/null +++ b/spec/jobs/data_privacy_task_job_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe DataPrivacyTaskJob, type: :job do + + describe '#perform' do + + before do + Setting.set('system_init_done', true) + end + + let!(:organization) { create(:organization, name: 'test') } + let!(:admin) { create(:admin) } + let!(:user) { create(:customer, organization: organization) } + + it 'checks if the user is deleted' do + create(:data_privacy_task, deletable: user) + described_class.perform_now + expect { user.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'checks if the organization is deleted' do + create(:data_privacy_task, deletable: user) + described_class.perform_now + expect(organization.reload).to be_a_kind_of(Organization) + end + + it 'checks if the state is completed' do + task = create(:data_privacy_task, deletable: user) + described_class.perform_now + expect(task.reload.state).to eq('completed') + end + + it 'checks if the user is deleted (delete_organization=true)' do + create(:data_privacy_task, deletable: user, preferences: { delete_organization: 'true' }) + described_class.perform_now + expect { user.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'checks if the organization is deleted (delete_organization=true)' do + create(:data_privacy_task, deletable: user, preferences: { delete_organization: 'true' }) + described_class.perform_now + expect { organization.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'checks creation of activity stream log' do + create(:data_privacy_task, deletable: user, created_by: admin) + travel 15.minutes + described_class.perform_now + expect(admin.activity_stream(20).any? { |entry| entry.type.name == 'completed' }).to be true + end + end +end diff --git a/spec/lib/pseudonymisation_spec.rb b/spec/lib/pseudonymisation_spec.rb new file mode 100644 index 000000000..183ed360e --- /dev/null +++ b/spec/lib/pseudonymisation_spec.rb @@ -0,0 +1,111 @@ +require 'rails_helper' + +RSpec.describe Pseudonymisation do + + describe '.of_hash' do + + let(:source) do + { + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@example.com', + organization: 'Example Inc.', + } + end + + let(:result) do + { + firstname: 'J*n', + lastname: 'D*e', + email: 'j*e@e*e.com', + organization: 'E*e I*.', + } + end + + it 'creates pseudonymous hash' do + expect(described_class.of_hash(source)).to eq(result) + end + end + + describe '.of_value' do + + context 'when email address is given' do + let(:source) { 'test@example.com' } + + it 'creates pseudonymous email_address' do + expect(described_class.of_value(source)).to eq('t*t@e*e.com') + end + end + + context 'when string is given' do + let(:source) { 'Zammad' } + + it 'creates pseudonymous string' do + expect(described_class.of_value(source)).to eq('Z*d') + end + end + end + + describe '.of_email_address' do + + let(:source) { 'test@example.com' } + + it 'creates pseudonymous email_address' do + expect(described_class.of_email_address(source)).to eq('t*t@e*e.com') + end + + context 'when address is invalid' do + + it 'raises ArgumentError for parsing errors' do + expect { described_class.of_email_address('i_m_no_address@') }.to raise_exception(ArgumentError) + end + + it 'raises ArgumentError for string argument' do + expect { described_class.of_email_address('i_m_no_address') }.to raise_exception(ArgumentError) + end + end + end + + describe '.of_domain' do + + let(:source) { 'zammad.com' } + + it 'creates pseudonymous string with TLD' do + expect(described_class.of_domain(source)).to eq('z*d.com') + end + + context 'when no TLD is present' do + + let(:source) { 'localhost' } + + it 'creates pseudonymous string' do + expect(described_class.of_domain(source)).to eq('l*t') + end + end + end + + describe '.of_string' do + + let(:source) { 'Zammad' } + + it 'creates pseudonymous string' do + expect(described_class.of_string(source)).to eq('Z*d') + end + + context 'when only one char long' do + let(:source) { 'a' } + + it 'returns *' do + expect(described_class.of_string(source)).to eq('*') + end + end + + context 'when multiple sub-strings are given' do + let(:source) { 'Zammad Foundation' } + + it 'create pseudonymous string for each' do + expect(described_class.of_string(source)).to eq('Z*d F*n') + end + end + end +end diff --git a/spec/models/concerns/has_taskbars_examples.rb b/spec/models/concerns/has_taskbars_examples.rb new file mode 100644 index 000000000..66a131f12 --- /dev/null +++ b/spec/models/concerns/has_taskbars_examples.rb @@ -0,0 +1,11 @@ +RSpec.shared_examples 'HasTaskbars' do + subject { create(described_class.name.underscore) } + + describe '#destroy_taskbars' do + it 'destroys related taskbars' do + taskbar = create(:taskbar, key: "#{described_class.name}-#{subject.id}") + subject.destroy + expect { taskbar.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/models/data_privacy_task_spec.rb b/spec/models/data_privacy_task_spec.rb new file mode 100644 index 000000000..18a19a200 --- /dev/null +++ b/spec/models/data_privacy_task_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe DataPrivacyTask, type: :model do + describe '.perform' do + let(:organization) { create(:organization, name: 'test') } + let!(:admin) { create(:admin) } + let(:user) { create(:customer, organization: organization) } + + it 'blocks other objects than user objects' do + expect { create(:data_privacy_task, deletable: create(:chat)) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is not a User') + end + + it 'blocks the multiple deletion tasks for the same user' do + create(:data_privacy_task, deletable: user) + expect { create(:data_privacy_task, deletable: user) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable has an existing DataPrivacyTask queued') + end + + it 'blocks deletion task for user id 1' do + expect { create(:data_privacy_task, deletable: User.find(1)) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is undeletable system User with ID 1') + end + + it 'blocks deletion task for yourself' do + UserInfo.current_user_id = user.id + expect { create(:data_privacy_task, deletable: user) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is your current account') + end + + it 'blocks deletion task for last admin' do + expect { create(:data_privacy_task, deletable: admin) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is last account with admin permissions') + end + + it 'allows deletion task for last two admins' do + create(:admin) + admin = create(:admin) + expect(create(:data_privacy_task, deletable: admin)).to be_truthy + end + + it 'sets the failed state when task failed' do + task = create(:data_privacy_task, deletable: user) + user.destroy + task.perform + expect(task.reload.state).to eq('failed') + end + + it 'sets an error message when task failed' do + task = create(:data_privacy_task, deletable: user) + user.destroy + task.perform + expect(task.reload.preferences[:error]).to eq("ERROR: #") + end + end + + describe '#prepare_deletion_preview' do + + let(:organization) { create(:organization, name: 'Zammad GmbH') } + let(:user) { create(:customer, organization: organization, email: 'secret@example.com') } + let(:task) { create(:data_privacy_task, deletable: user) } + + context 'when storing user data' do + + let(:pseudonymous_data) do + { + 'firstname' => 'N*e', + 'lastname' => 'B*n', + 'email' => 's*t@e*e.com', + 'organization' => 'Z*d G*H', + } + end + + it 'creates pseudonymous representation' do + expect(task[:preferences][:user]).to eq(pseudonymous_data) + end + end + + context 'when User is owner of Tickets' do + + let!(:owner_tickets) { create_list(:ticket, 3, owner: user) } + + it 'stores the numbers' do + expect(task[:preferences][:owner_tickets]).to eq(owner_tickets.reverse.map(&:number)) + end + end + + context 'when User is customer of Tickets' do + + let!(:customer_tickets) { create_list(:ticket, 3, customer: user) } + + it 'stores the numbers' do + expect(task[:preferences][:customer_tickets]).to eq(customer_tickets.reverse.map(&:number)) + end + end + end +end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index ec31a4836..3caa67977 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -5,6 +5,7 @@ require 'models/concerns/has_history_examples' require 'models/concerns/has_search_index_backend_examples' require 'models/concerns/has_xss_sanitized_note_examples' require 'models/concerns/has_object_manager_attributes_validation_examples' +require 'models/concerns/has_taskbars_examples' RSpec.describe Organization, type: :model do it_behaves_like 'ApplicationModel', can_assets: { associations: :members } @@ -13,6 +14,7 @@ RSpec.describe Organization, type: :model do it_behaves_like 'HasSearchIndexBackend', indexed_factory: :organization it_behaves_like 'HasXssSanitizedNote', model_factory: :organization it_behaves_like 'HasObjectManagerAttributesValidation' + it_behaves_like 'HasTaskbars' subject(:organization) { create(:organization) } @@ -24,6 +26,28 @@ RSpec.describe Organization, type: :model do expect(organizations).not_to be_blank end end + + describe '.destroy' do + + let!(:refs_known) { { 'Ticket' => { 'organization_id'=> 1 }, 'User' => { 'organization_id'=> 1 } } } + let!(:user) { create(:customer, organization: organization) } + let!(:ticket) { create(:ticket, organization: organization, customer: user) } + + it 'checks known refs' do + refs_organization = Models.references('Organization', organization.id, true) + expect(refs_organization).to eq(refs_known) + end + + it 'checks user deletion' do + organization.destroy + expect { user.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it 'checks ticket deletion' do + organization.destroy + expect { ticket.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + end end describe 'Callbacks, Observers, & Async Transactions -' do diff --git a/spec/models/taskbar_spec.rb b/spec/models/taskbar_spec.rb index 76c04b4fb..f8b1fd889 100644 --- a/spec/models/taskbar_spec.rb +++ b/spec/models/taskbar_spec.rb @@ -116,7 +116,7 @@ RSpec.describe Taskbar do end end - context 'multible creation' do + context 'multiple creation' do it 'create tasks' do @@ -132,6 +132,7 @@ RSpec.describe Taskbar do state: {}, prio: 1, notify: false, + user_id: 1, ) UserInfo.current_user_id = 2 @@ -145,6 +146,7 @@ RSpec.describe Taskbar do state: {}, prio: 2, notify: false, + user_id: 1, ) taskbar1.reload @@ -171,6 +173,7 @@ RSpec.describe Taskbar do state: {}, prio: 2, notify: false, + user_id: 1, ) taskbar1.reload @@ -205,6 +208,7 @@ RSpec.describe Taskbar do state: {}, prio: 4, notify: false, + user_id: 1, ) taskbar1.reload diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index 6dfc5c0da..5d19a716d 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -4,6 +4,7 @@ require 'models/concerns/can_be_imported_examples' require 'models/concerns/can_csv_import_examples' require 'models/concerns/has_history_examples' require 'models/concerns/has_tags_examples' +require 'models/concerns/has_taskbars_examples' require 'models/concerns/has_xss_sanitized_note_examples' require 'models/concerns/has_object_manager_attributes_validation_examples' @@ -13,6 +14,7 @@ RSpec.describe Ticket, type: :model do it_behaves_like 'CanCsvImport' it_behaves_like 'HasHistory', history_relation_object: 'Ticket::Article' it_behaves_like 'HasTags' + it_behaves_like 'HasTaskbars' it_behaves_like 'HasXssSanitizedNote', model_factory: :ticket it_behaves_like 'HasObjectManagerAttributesValidation' @@ -1114,6 +1116,27 @@ RSpec.describe Ticket, type: :model do .to(false) end + it 'destroys all related dependencies' do + refs_known = { 'Ticket::Article' => { 'ticket_id'=>1 }, + 'Ticket::TimeAccounting' => { 'ticket_id'=>1 }, + 'Ticket::Flag' => { 'ticket_id'=>1 } } + + ticket = create(:ticket) + article = create(:ticket_article, ticket: ticket) + accounting = create(:ticket_time_accounting, ticket: ticket) + flag = create(:ticket_flag, ticket: ticket) + + refs_ticket = Models.references('Ticket', ticket.id, true) + expect(refs_ticket).to eq(refs_known) + + ticket.destroy + + expect { ticket.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { article.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { accounting.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { flag.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + context 'when ticket is generated from email (with attachments)' do subject(:ticket) { Channel::EmailParser.new.process({}, raw_email).first } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8735b0637..57cc81834 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -9,6 +9,7 @@ require 'models/concerns/can_be_imported_examples' require 'models/concerns/has_object_manager_attributes_validation_examples' require 'models/user/has_ticket_create_screen_impact_examples' require 'models/user/can_lookup_search_index_attributes_examples' +require 'models/concerns/has_taskbars_examples' RSpec.describe User, type: :model do subject(:user) { create(:user) } @@ -27,6 +28,7 @@ RSpec.describe User, type: :model do it_behaves_like 'HasObjectManagerAttributesValidation' it_behaves_like 'HasTicketCreateScreenImpact' it_behaves_like 'CanLookupSearchIndexAttributes' + it_behaves_like 'HasTaskbars' describe 'Class methods:' do describe '.authenticate' do @@ -812,6 +814,144 @@ RSpec.describe User, type: :model do end describe 'Associations:' do + subject(:user) { create(:agent, groups: [group_subject]) } + + let!(:group_subject) { create(:group) } + + it 'does remove references before destroy' do + refs_known = { 'Group' => { 'created_by_id' => 1, 'updated_by_id' => 0 }, + 'Token' => { 'user_id' => 1 }, + 'Ticket::Article' => + { 'created_by_id' => 0, 'updated_by_id' => 0, 'origin_by_id' => 1 }, + 'Ticket::StateType' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket::Article::Sender' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket::Article::Type' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket::Article::Flag' => { 'created_by_id' => 0 }, + 'Ticket::Priority' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket::TimeAccounting' => { 'created_by_id' => 0 }, + 'Ticket::State' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket::Flag' => { 'created_by_id' => 0 }, + 'PostmasterFilter' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'OnlineNotification' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Ticket' => + { 'created_by_id' => 0, 'updated_by_id' => 0, 'owner_id' => 1, 'customer_id' => 3 }, + 'Template' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Avatar' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Scheduler' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Chat' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'HttpLog' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'EmailAddress' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Taskbar' => { 'user_id' => 1 }, + 'Sla' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'UserDevice' => { 'user_id' => 1 }, + 'Chat::Message' => { 'created_by_id' => 0 }, + 'Chat::Agent' => { 'created_by_id' => 1, 'updated_by_id' => 1 }, + 'Chat::Session' => { 'user_id' => 0, 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Tag' => { 'created_by_id' => 0 }, + 'Karma::User' => { 'user_id' => 0 }, + 'Karma::ActivityLog' => { 'user_id' => 1 }, + 'RecentView' => { 'created_by_id' => 1 }, + 'KnowledgeBase::Answer::Translation' => + { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'KnowledgeBase::Answer' => + { 'archived_by_id' => 1, 'published_by_id' => 1, 'internal_by_id' => 1 }, + 'Report::Profile' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Package' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Job' => { 'created_by_id' => 0, 'updated_by_id' => 1 }, + 'Store' => { 'created_by_id' => 0 }, + 'Cti::CallerId' => { 'user_id' => 1 }, + 'DataPrivacyTask' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Trigger' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Translation' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'ObjectManager::Attribute' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'User' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Organization' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Macro' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Channel' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Role' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'History' => { 'created_by_id' => 1 }, + 'Overview' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'ActivityStream' => { 'created_by_id' => 0 }, + 'StatsStore' => { 'created_by_id' => 0 }, + 'TextModule' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Calendar' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'UserGroup' => { 'user_id' => 1 }, + 'Signature' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Authorization' => { 'user_id' => 1 } } + + # delete objects + token = create(:token, user: user) + online_notification = create(:online_notification, user: user) + template = create(:template, :dummy_data, user: user) + taskbar = create(:taskbar, user: user) + user_device = create(:user_device, user: user) + karma_activity_log = create(:karma_activity_log, user: user) + cti_caller_id = create(:cti_caller_id, user: user) + text_module = create(:text_module, user: user) + authorization = create(:twitter_authorization, user: user) + recent_view = create(:recent_view, created_by: user) + avatar = create(:avatar, o_id: user.id) + + # create a chat agent for admin user (id=1) before agent user + # to be sure that the data gets removed and not mapped which + # would result in a foreign key because of the unique key on the + # created_by_id and updated_by_id. + create(:'chat/agent') + chat_agent_user = create(:'chat/agent', created_by_id: user.id, updated_by_id: user.id) + + # move ownership objects + group = create(:group, created_by_id: user.id) + job = create(:job, updated_by_id: user.id) + ticket = create(:ticket, group: group_subject, owner: user) + ticket_article = create(:ticket_article, ticket: ticket, origin_by_id: user.id) + customer_ticket1 = create(:ticket, group: group_subject, customer: user) + customer_ticket2 = create(:ticket, group: group_subject, customer: user) + customer_ticket3 = create(:ticket, group: group_subject, customer: user) + knowledge_base_answer = create(:knowledge_base_answer, archived_by_id: user.id, published_by_id: user.id, internal_by_id: user.id) + + refs_user = Models.references('User', user.id, true) + expect(refs_user).to eq(refs_known) + + user.destroy + + expect { token.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { online_notification.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { template.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { taskbar.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { user_device.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { karma_activity_log.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { cti_caller_id.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { text_module.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { authorization.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { recent_view.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { avatar.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { customer_ticket1.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { customer_ticket2.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { customer_ticket3.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { chat_agent_user.reload }.to raise_exception(ActiveRecord::RecordNotFound) + + # move ownership objects + expect { group.reload }.to change(group, :created_by_id).to(1) + expect { job.reload }.to change(job, :updated_by_id).to(1) + expect { ticket.reload }.to change(ticket, :owner_id).to(1) + expect { ticket_article.reload }.to change(ticket_article, :origin_by_id).to(1) + expect { knowledge_base_answer.reload } + .to change(knowledge_base_answer, :archived_by_id).to(1) + .and change(knowledge_base_answer, :published_by_id).to(1) + .and change(knowledge_base_answer, :internal_by_id).to(1) + end + + it 'does delete cache after user deletion' do + online_notification = create(:online_notification, created_by_id: user.id) + online_notification.attributes_with_association_ids + user.destroy + expect(online_notification.reload.attributes_with_association_ids['created_by_id']).to eq(1) + end + + it 'does return an exception on blocking dependencies' do + expect { user.send(:destroy_move_dependency_ownership) }.to raise_error(RuntimeError, 'Failed deleting references! Check logic for UserGroup->user_id.') + end + describe '#organization' do describe 'email domain-based assignment' do subject(:user) { build(:user) } diff --git a/spec/requests/integration/monitoring_spec.rb b/spec/requests/integration/monitoring_spec.rb index be6703c21..958896fe7 100644 --- a/spec/requests/integration/monitoring_spec.rb +++ b/spec/requests/integration/monitoring_spec.rb @@ -509,6 +509,20 @@ RSpec.describe 'Monitoring', type: :request do expect(json_response['healthy']).to eq(false) expect(json_response['message']).to eq("Channel: Email::Notification out ;unprocessable mails: 1;Failed to run import backend 'Import::Ldap'. Cause: Some bad error;Stuck import backend 'Import::Ldap' detected. Last update: #{stuck_updated_at_timestamp}") + privacy_stuck_updated_at_timestamp = 30.minutes.ago + task = create(:data_privacy_task, deletable: customer) + task.update(updated_at: privacy_stuck_updated_at_timestamp) + + # health_check + get "/api/v1/monitoring/health_check?token=#{token}", params: {}, as: :json + expect(response).to have_http_status(:ok) + + expect(json_response).to be_a_kind_of(Hash) + expect(json_response['message']).to be_truthy + expect(json_response['issues']).to be_truthy + expect(json_response['healthy']).to eq(false) + expect(json_response['message']).to eq("Channel: Email::Notification out ;unprocessable mails: 1;Failed to run import backend 'Import::Ldap'. Cause: Some bad error;Stuck import backend 'Import::Ldap' detected. Last update: #{stuck_updated_at_timestamp};Stuck data privacy task (ID #{task.id}) detected. Last update: #{privacy_stuck_updated_at_timestamp}") + Setting.set('ldap_integration', false) end diff --git a/spec/system/data_privacy_spec.rb b/spec/system/data_privacy_spec.rb new file mode 100644 index 000000000..1d53d3d3a --- /dev/null +++ b/spec/system/data_privacy_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.describe 'Data Privacy', type: :system, searchindex: true, authenticated_as: :authenticate do + before do + configure_elasticsearch(rebuild: true) + end + + let(:customer) { create(:customer, firstname: 'Frank1') } + let(:ticket) { create(:ticket, customer: customer, group: Group.find_by(name: 'Users')) } + + def authenticate + customer + ticket + true + end + + context 'when data privacy admin interface' do + it 'deletes customer' do + visit 'system/data_privacy' + click '.js-new' + + find(:css, '.js-input').send_keys(customer.firstname) + expect(page).to have_css('.searchableSelect-option-text', wait: 5) + click '.searchableSelect-option-text' + fill_in 'Are you sure?', with: 'DELETE' + expect(page).to have_no_text('DELETE ORGANIZATION?', wait: 5) + click '.js-submit' + + expect(page).to have_text('in process', wait: 5) + DataPrivacyTaskJob.perform_now + expect(page).to have_text('completed', wait: 5) + end + + context 'when customer is the single user of the organization' do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, firstname: 'Frank2', organization: organization) } + + def authenticate + organization + customer + ticket + true + end + + it 'deletes customer' do + visit 'system/data_privacy' + click '.js-new' + + find(:css, '.js-input').send_keys(customer.firstname) + expect(page).to have_css('.searchableSelect-option-text', wait: 5) + click '.searchableSelect-option-text' + fill_in 'Are you sure?', with: 'DELETE' + expect(page).to have_text('DELETE ORGANIZATION?', wait: 5) + click '.js-submit' + + expect(page).to have_text('in process', wait: 5) + DataPrivacyTaskJob.perform_now + expect(page).to have_text('completed', wait: 5) + end + end + end + + context 'when user profile' do + it 'deletes customer' do + visit "user/profile/#{customer.id}" + + click '.dropdown--actions' + click_on 'Delete' + + fill_in 'Are you sure?', with: 'DELETE' + click '.js-submit' + + expect(page).to have_text('in process', wait: 5) + DataPrivacyTaskJob.perform_now + expect(page).to have_text('completed', wait: 5) + end + end + + context 'when ticket zoom' do + it 'deletes customer' do + visit "ticket/zoom/#{ticket.id}" + + click '.tabsSidebar-tab[data-tab=customer]' + click 'h2.js-headline' + click_on 'Delete Customer' + + fill_in 'Are you sure?', with: 'DELETE' + click '.js-submit' + + expect(page).to have_text('in process', wait: 5) + DataPrivacyTaskJob.perform_now + expect(page).to have_text('completed', wait: 5) + end + end +end diff --git a/test/browser/switch_to_user_test.rb b/test/browser/switch_to_user_test.rb index e47792b06..0316424fd 100644 --- a/test/browser/switch_to_user_test.rb +++ b/test/browser/switch_to_user_test.rb @@ -22,6 +22,9 @@ class SwitchToUserTest < TestCase @browser.action.move_to(@browser.find_elements({ css: '.content.active .table-overview tbody tr:first-child' } )[0]).release.perform sleep 0.5 + click( + css: '.content.active .dropdown--actions', + ) click( css: '.content.active .icon-switchView', )