Fixes #2603 - Limit access to KB Categories based on roles.

This commit is contained in:
Mantas Masalskis 2022-02-24 12:15:19 +01:00 committed by Rolf Schmidt
parent 3a4ada93b4
commit 154b3accf9
75 changed files with 2123 additions and 187 deletions

View file

@ -11,6 +11,10 @@ class App.KnowledgeBaseAgentController extends App.Controller
super super
@controllerBind('config_update_local', (data) => @configUpdated(data)) @controllerBind('config_update_local', (data) => @configUpdated(data))
@listenTo App.User.current(), 'refresh', @visibilityMayHaveChanged
App.Event.bind 'kb_visibility_may_have_changed', @visibilityMayHaveChanged
if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active') if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active')
@updateNavMenu() @updateNavMenu()
else if App.Config.get('kb_active_publicly') else if App.Config.get('kb_active_publicly')
@ -51,15 +55,8 @@ class App.KnowledgeBaseAgentController extends App.Controller
@loadChange(pushed_data) @loadChange(pushed_data)
, 1000, key, 'kb_data_changed_loading') , 1000, key, 'kb_data_changed_loading')
) )
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', => @listenTo App.KnowledgeBase, 'kb_data_change_loaded', @renderAfterChangeLoaded
return if !@displayingError @listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @renderAfterChangeLoaded
object = @constructor.pickObjectUsing(@lastParams, @)
if !@objectVisibleInternally(object)
return
@renderControllers(@lastParams)
@checkForUpdates() @checkForUpdates()
@ -112,6 +109,19 @@ class App.KnowledgeBaseAgentController extends App.Controller
notifyChangeLoaded: -> notifyChangeLoaded: ->
App.KnowledgeBase.trigger('kb_data_change_loaded') App.KnowledgeBase.trigger('kb_data_change_loaded')
notifyVisibilityChangeLoaded: ->
App.KnowledgeBase.trigger('kb_visibility_change_loaded')
renderAfterChangeLoaded: =>
return if !@displayingError
object = @constructor.pickObjectUsing(@lastParams, @)
if !@objectVisibleInternally(object)
return
@renderControllers(@lastParams)
active: (state) -> active: (state) ->
return @shown if state is undefined return @shown if state is undefined
@shown = state @shown = state
@ -138,6 +148,10 @@ class App.KnowledgeBaseAgentController extends App.Controller
if !@permissionCheckRedirect("knowledge_base.#{@requiredPermissionSuffix(params)}") if !@permissionCheckRedirect("knowledge_base.#{@requiredPermissionSuffix(params)}")
return return
if @loaded && @requiredPermissionSuffix(params) == 'editor' && @access(params) != 'editor'
@navigate params.match[0].replace(/\/edit$/, ''), { hideCurrentLocationFromHistory: true }
return
if @loaded && @rendered && @lastParams && !params.knowledge_base_id && @contentController && @kb_locale()? if @loaded && @rendered && @lastParams && !params.knowledge_base_id && @contentController && @kb_locale()?
@navigate @lastParams.match[0] , { hideCurrentLocationFromHistory: true } @navigate @lastParams.match[0] , { hideCurrentLocationFromHistory: true }
return return
@ -169,6 +183,7 @@ class App.KnowledgeBaseAgentController extends App.Controller
@pendingParams = params @pendingParams = params
renderScreenErrorInContent: (text) -> renderScreenErrorInContent: (text) ->
@contentController?.releaseController()
@contentController = undefined @contentController = undefined
@renderScreenError(detail: text, el: @$('.page-content')) @renderScreenError(detail: text, el: @$('.page-content'))
@displayingError = true @displayingError = true
@ -255,12 +270,12 @@ class App.KnowledgeBaseAgentController extends App.Controller
kb.kb_locales().filter((elem) => elem.system_locale_id == @lastParams.selectedSystemLocale.id)[0] kb.kb_locales().filter((elem) => elem.system_locale_id == @lastParams.selectedSystemLocale.id)[0]
getKnowledgeBase: -> getKnowledgeBase: ->
App.KnowledgeBase.find(@lastParams.knowledge_base_id) App.KnowledgeBase.find(@lastParams?.knowledge_base_id)
fetchAndRender: => fetchAndRender: =>
@fetch(true, true) @fetch(true, true)
fetch: (showLoader, processLoaded) -> fetch: (showLoader, processLoaded, notifyVisibilityChangeLoaded) ->
if showLoader if showLoader
@startLoading() @startLoading()
@ -278,6 +293,9 @@ class App.KnowledgeBaseAgentController extends App.Controller
if processLoaded if processLoaded
@processLoaded() @processLoaded()
if notifyVisibilityChangeLoaded
@notifyVisibilityChangeLoaded()
, ,
error: (xhr) => error: (xhr) =>
if showLoader if showLoader
@ -308,10 +326,11 @@ class App.KnowledgeBaseAgentController extends App.Controller
App[elem.modelName].find(id)?.remove(clear: true) App[elem.modelName].find(id)?.remove(clear: true)
calculateIdsToDelete: (data) -> calculateIdsToDelete: (data) ->
Object App.KnowledgeBase
.keys(data) .allKbModelNames()
.filter (elem) -> elem.match(/^KnowledgeBase/)
.map (model) -> .map (model) ->
return {ids : []} if !data[model]
newIds = Object.keys data[model] newIds = Object.keys data[model]
oldIds = App[model].all().map (elem) -> elem.id oldIds = App[model].all().map (elem) -> elem.id
diff = oldIds.filter (elem) -> !_.includes(newIds, String(elem)) diff = oldIds.filter (elem) -> !_.includes(newIds, String(elem))
@ -339,8 +358,13 @@ class App.KnowledgeBaseAgentController extends App.Controller
parentController: @ parentController: @
) )
access: (params) ->
@constructor
.pickObjectUsing(params, @)
?.access()
isEditor: -> isEditor: ->
App.User.current().permission('knowledge_base.editor') @access(@lastParams) == 'editor'
checkForUpdates: -> checkForUpdates: ->
@interval(@checkUpdatesAction, 10 * 60 * 1000, 'kb_interval_check') @interval(@checkUpdatesAction, 10 * 60 * 1000, 'kb_interval_check')
@ -371,6 +395,27 @@ class App.KnowledgeBaseAgentController extends App.Controller
clicked: -> clicked: ->
window.open(App.KnowledgeBase.first().publicBaseUrl(), '_blank') window.open(App.KnowledgeBase.first().publicBaseUrl(), '_blank')
visibilityMayHaveChanged: =>
kb = @getKnowledgeBase()
return if !kb
@ajax
id: 'kb_pull_visibility'
type: 'GET'
url: @apiPath + '/knowledge_bases/visible_ids'
processData: true
success: (data, status, xhr) =>
didRemove = kb.removeAssetsIfNeeded(data)
hasAssetsToLoad = kb.hasAssetsToLoad(data)
if hasAssetsToLoad
@fetch(false, false, true)
return
if didRemove
@notifyVisibilityChangeLoaded()
@pickObjectUsing: (params, parentController) -> @pickObjectUsing: (params, parentController) ->
kb = parentController.getKnowledgeBase() kb = parentController.getKnowledgeBase()
return if !kb return if !kb

View file

@ -103,7 +103,7 @@ class App.KnowledgeBaseContentCanBePublishedForm extends App.ControllerForm
, ,
value: 'internal' value: 'internal'
name: __('Internal') name: __('Internal')
note: __('Visible to agents & editors') note: __('Visible to readers & editors')
, ,
value: 'published' value: 'published'
name: __('Public') name: __('Public')

View file

@ -15,6 +15,12 @@ class App.KnowledgeBaseEditorCoordinator
parentController: @parentController parentController: @parentController
) )
clickedPermissions: (object) ->
new App.KnowledgeBasePermissionsDialog(
object: object
container: @parentController.el
)
# built-in Spine's function doesn't work when object has no ID set and includes "undefined" in URL # built-in Spine's function doesn't work when object has no ID set and includes "undefined" in URL
urlFor: (object) -> urlFor: (object) ->
if object.id if object.id

View file

@ -13,8 +13,8 @@ class App.KnowledgeBaseNavigation extends App.Controller
@controllerBind('knowledge_base::navigation::rerender', => @needsUpdate()) @controllerBind('knowledge_base::navigation::rerender', => @needsUpdate())
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', => @listenTo App.KnowledgeBase, 'kb_data_change_loaded', @needsUpdate
@needsUpdate() @listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @needsUpdate
buildCrumbsForRendering: (array, kb_locale, action) -> buildCrumbsForRendering: (array, kb_locale, action) ->
if action is 'search' if action is 'search'
@ -73,7 +73,7 @@ class App.KnowledgeBaseNavigation extends App.Controller
else else
false false
needsUpdate: -> needsUpdate: =>
@show(@savedParams, @savedAction) @show(@savedParams, @savedAction)
selectedLocaleDisplay: -> selectedLocaleDisplay: ->

View file

@ -0,0 +1,92 @@
class App.KnowledgeBasePermissionsDialog extends App.ControllerModal
events:
#'submit form': 'submitPermissions'
'click td.u-clickable': 'cellBackgroundClicked'
head: 'Permissions'
includeForm: true
buttonSubmit: true
accessLevels: { editor: 'Editor', reader: 'Reader', none: 'None' }
cellBackgroundClicked: (e) ->
return if e.target != e.currentTarget
e.preventDefault()
e.currentTarget.querySelector('input')?.click()
data: null
constructor: (params) ->
super
@load()
content: =>
return if !@data
App.view('knowledge_base/permissions_dialog')(
accessLevels: @accessLevels
params: @loadedParams(@data)
roles: @formRoles(@data)
)
loadedParams: (data) ->
params = []
for permission in data.permissions
params[permission.role_id] = permission.access
for role in data.roles_editor
params[role.id] ||= 'editor'
for role in data.roles_reader
params[role.id] ||= 'reader'
params
formRoles: (data) ->
data.roles_editor.forEach (elem) => @formRolesItem(elem, 'editor', data)
data.roles_reader.forEach (elem) => @formRolesItem(elem, 'reader', data)
_.sortBy data.roles_editor.concat(data.roles_reader), (elem) -> elem.name
formRolesItem: (elem, role_name, data) ->
elem.accessLevel = role_name
elem.limit = _.findWhere(data.inherited, { role_id: elem.id })?.access
load: =>
@ajax(
id: 'knowledge_base_permissions_get'
type: 'get'
url: @object.generateURL('permissions')
processData: true
success: (data, status, xhr) =>
@data = data
@update()
error: (xhr) =>
@showAlert(xhr.responseJSON?.error || __('Unable to load changes'))
)
toggleDisabled: (state) =>
@el.find('input, button').attr('disabled', state)
onSubmit: (e) =>
@clearAlerts()
@toggleDisabled(true)
data = @formParams()
params = { permissions_dialog: { permissions: data } }
@ajax(
id: 'knowledge_base_permissions_patch'
type: 'PATCH'
data: JSON.stringify(params)
url: @object.generateURL('permissions')
processData: true
success: (data, status, xhr) =>
@close()
error: (xhr) =>
@toggleDisabled(false)
@showAlert(xhr.responseJSON?.error || __('Unable to save changes'))
)

View file

@ -54,7 +54,10 @@ class App.KnowledgeBaseReaderController extends App.Controller
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', => @listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@renderAnswer(@object, kb_locale) @renderAnswer(@object, kb_locale)
renderAnswer: (answer, kb_locale) -> @listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', =>
@renderAnswer(@object, kb_locale, true)
renderAnswer: (answer, kb_locale, onlyVisibility) ->
if !answer if !answer
@parentController.renderNotFound() @parentController.renderNotFound()
return return
@ -63,13 +66,16 @@ class App.KnowledgeBaseReaderController extends App.Controller
@parentController.renderNotAvailableAnymore() @parentController.renderNotAvailableAnymore()
return return
paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale)
@answerPagination.html paginator.el
if onlyVisibility
return
@renderAttachments(answer.attachments) @renderAttachments(answer.attachments)
@renderTags(answer.tags) @renderTags(answer.tags)
@renderLinkedTickets(answer.translation(kb_locale.id)?.linked_tickets()) @renderLinkedTickets(answer.translation(kb_locale.id)?.linked_tickets())
paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale)
@answerPagination.html paginator.el
answer_translation = answer.translation(kb_locale.id) answer_translation = answer.translation(kb_locale.id)
if !answer_translation if !answer_translation

View file

@ -3,13 +3,13 @@ class App.KnowledgeBaseReaderListContainer extends App.Controller
super super
@render() @render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', => @listenTo App.KnowledgeBase, 'kb_data_change_loaded', @parentRefreshed
@parentRefreshed() @listenTo App.KnowledgeBase, 'kb_visibility_change_loaded', @parentRefreshed
tag: 'ul' tag: 'ul'
className: 'sections' className: 'sections'
parentRefreshed: -> parentRefreshed: =>
newIds = @children().map (elem) -> elem.id newIds = @children().map (elem) -> elem.id
oldIds = @el.children().toArray().map (elem) -> parseInt(elem.dataset.id) oldIds = @el.children().toArray().map (elem) -> parseInt(elem.dataset.id)
@ -59,7 +59,9 @@ class App.KnowledgeBaseReaderListContainer.Categories extends App.KnowledgeBaseR
@parent.children() @parent.children()
else else
[] []
# coffeelint: enable=indentation # coffeelint: enable=indentation
#
if !@isEditor if !@isEditor
items = items.filter (elem) => elem.visibleInternally(@kb_locale) items = items.filter (elem) => elem.visibleInternally(@kb_locale)

View file

@ -3,9 +3,16 @@ class App.KnowledgeBaseReaderListController extends App.Controller
super super
@render() @render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', => @listenTo App.KnowledgeBase, 'kb_data_change_loaded', @changeLoaded
if !@objectVisibleInternally() @listenTo App.KnowledgeBase, 'kb_visibility_may_have_changed', => @changeLoaded(true)
@parentController.renderNotAvailableAnymore()
changeLoaded: =>
if !@objectVisibleInternally()
@parentController.renderNotAvailableAnymore()
return
if @renderEmptinessState != @object.isEmpty()
@render()
elements: elements:
'.js-readerListContainer': 'container' '.js-readerListContainer': 'container'
@ -18,11 +25,13 @@ class App.KnowledgeBaseReaderListController extends App.Controller
@parentController.renderNotFound() @parentController.renderNotFound()
return return
@renderEmptinessState = @object.isEmpty()
if @object.isEmpty() if @object.isEmpty()
@renderScreenPlaceholder( @renderScreenPlaceholder(
icon: App.Utils.icon('mood-ok') icon: App.Utils.icon('mood-ok')
detail: __('This category is empty') detail: __('This category is empty')
action: __('Start Editing') action: if @parentController.isEditor() then __('Start Editing')
actionCallback: => actionCallback: =>
url = @object.uiUrl(@parentController.kb_locale(), 'edit') url = @object.uiUrl(@parentController.kb_locale(), 'edit')
@navigate url @navigate url

View file

@ -18,7 +18,10 @@ class App.KnowledgeBaseReaderListItem extends App.Controller
@sort_order = @item.position @sort_order = @item.position
attrs = @item.attributesForRendering(@kb_locale, isEditor: @isEditor) try
attrs = @item.attributesForRendering(@kb_locale, isEditor: @isEditor)
catch e
attrs = {}
@el @el
.prop('className') .prop('className')

View file

@ -6,6 +6,7 @@ class App.KnowledgeBaseSidebar extends App.Controller
constructor: -> constructor: ->
super super
@renderedWidgets = []
@show() @show()
@controllerBind 'knowledge_base::sidebar::rerender', => @rerender() @controllerBind 'knowledge_base::sidebar::rerender', => @rerender()
@ -14,9 +15,9 @@ class App.KnowledgeBaseSidebar extends App.Controller
@rerender() @rerender()
true true
rerender: -> rerender: =>
@delay( => @delay( =>
@show(@savedParams, @savedAction) @update()
, 300, 'rerender') , 300, 'rerender')
contentActionClicked: (e) -> contentActionClicked: (e) ->
@ -24,10 +25,15 @@ class App.KnowledgeBaseSidebar extends App.Controller
actionName = switch e.target.dataset.action actionName = switch e.target.dataset.action
when 'delete' then 'clickedDelete' when 'delete' then 'clickedDelete'
when 'visibility' then 'clickedCanBePublished' when 'visibility' then 'clickedCanBePublished'
when 'permissions' then 'clickedPermissions'
# coffeelint: enable=indentation # coffeelint: enable=indentation
@parentController.bodyModal = @parentController.coordinator[actionName]?(@savedParams) @parentController.bodyModal = @parentController.coordinator[actionName]?(@savedParams)
update: =>
for elem in @renderedWidgets
elem.updateIfNeeded?()
show: (object, action) -> show: (object, action) ->
isEdit = action is 'edit' isEdit = action is 'edit'
@ -39,17 +45,22 @@ class App.KnowledgeBaseSidebar extends App.Controller
if !isEdit if !isEdit
return return
for widget in @widgets(object) @renderedWidgets = []
@el.append new widget(
for elem in @getWidgets(object)
widget = new elem(
object: object object: object
kb_locale: @parentController.kb_locale() kb_locale: @parentController.kb_locale()
parentController: @parentController parentController: @parentController
).el )
@renderedWidgets.push widget
@el.append widget.el
hide: -> hide: ->
@el.addClass('hidden') @el.addClass('hidden')
widgets: (object) -> getWidgets: (object) ->
output = [App.KnowledgeBaseSidebarActions] output = [App.KnowledgeBaseSidebarActions]
if object instanceof App.KnowledgeBase || object instanceof App.KnowledgeBaseCategory if object instanceof App.KnowledgeBase || object instanceof App.KnowledgeBaseCategory

View file

@ -8,6 +8,9 @@ class App.KnowledgeBaseSidebarGenericList extends App.Controller
constructor: -> constructor: ->
super super
@render()
render: ->
@html App.view('knowledge_base/sidebar/generic_list')(@templateOptions()) @html App.view('knowledge_base/sidebar/generic_list')(@templateOptions())
templateOptions: -> templateOptions: ->
@ -52,3 +55,6 @@ class App.KnowledgeBaseSidebarGenericList extends App.Controller
urlNew: -> urlNew: ->
#has to be overridden #has to be overridden
updateIfNeeded: ->
@render()

View file

@ -19,9 +19,9 @@ class App.KnowledgeBaseSidebarAttachments extends App.Controller
super super
@render() @render()
@listenTo @object, 'refresh', @needsUpdate @listenTo @object, 'refresh', @updateIfNeeded
needsUpdate: => updateIfNeeded: =>
@render() @render()
render: -> render: ->

View file

@ -12,9 +12,9 @@ class App.KnowledgeBaseSidebarLinkedTickets extends App.Controller
super super
@render() @render()
@listenTo @object, 'refresh', @needsUpdate @listenTo @object, 'refresh', @updateIfNeeded
needsUpdate: => updateIfNeeded: =>
@render() @render()
render: -> render: ->

View file

@ -11,3 +11,6 @@ class App.KnowledgeBaseSidebarTags extends App.Controller
object: @object object: @object
tags: @object.tags tags: @object.tags
) )
updateIfNeeded: ->
@widget.reload(@object.tags)

View file

@ -0,0 +1,33 @@
InstanceMethods =
access: ->
permission_reader = App.Permission.findByAttribute('name', 'knowledge_base.reader')
permission_editor = App.Permission.findByAttribute('name', 'knowledge_base.editor')
permissions_effective = switch @constructor
when App.KnowledgeBaseAnswer
@category().permissions_effective
when App.KnowledgeBaseCategory, App.KnowledgeBase
@permissions_effective
access = 'none'
for role_id in App.User.current().role_ids
kb_permission = _.findWhere(permissions_effective, { role_id: role_id })
if kb_permission
switch kb_permission.access
when 'editor'
return 'editor'
when 'reader'
access = 'reader'
else if role = App.Role.find(role_id)
if role.permission_ids.indexOf(permission_editor.id) > -1
return 'editor'
if role.permission_ids.indexOf(permission_reader.id) > -1
access = 'reader'
return access
App.KnowledgeBaseAccess =
extended: ->
@include InstanceMethods

View file

@ -11,6 +11,15 @@ InstanceMethods =
disabled: @isNew() disabled: @isNew()
} }
if (@ instanceof App.KnowledgeBaseCategory) || (@ instanceof App.KnowledgeBase)
buttons.push {
iconName: 'lock'
name: 'Permissions'
action: 'permissions'
cssClass: 'btn--success'
disabled: @isNew()
}
if !(@ instanceof App.KnowledgeBase) if !(@ instanceof App.KnowledgeBase)
buttons.push { buttons.push {
iconName: 'trash' iconName: 'trash'

View file

@ -34,29 +34,13 @@ InstanceMethods =
attrs.iconFont = true attrs.iconFont = true
attrs.icon = @category_icon attrs.icon = @category_icon
attrs.count = @countDeepAnswers() attrs.count = @countDeepAnswers()
attrs.state = @visibilityState(kb_locale)
if options.isEditor
attrs.editorOnly = !@visibleInternally(kb_locale)
else
attrs.editorOnly = false
attrs.state = @visibilityState(kb_locale)
if @ instanceof App.KnowledgeBaseAnswer if @ instanceof App.KnowledgeBaseAnswer
attrs.icon = 'knowledge-base-answer' attrs.icon = 'knowledge-base-answer'
attrs.state = @can_be_published_state() attrs.state = @can_be_published_state()
attrs.tags = @tags attrs.tags = @tags
if options.isEditor
attrs.editorOnly = !@is_internally_published(kb_locale)
else
attrs.editorOnly = false
# attrs.className = if attrs.missingTranslation
# 'kb-item--missing-translation'
# else if attrs.editorOnly
# 'kb-item--invisible'
attrs.icons = {} attrs.icons = {}
if attrs.missingTranslation if attrs.missingTranslation

View file

@ -2,6 +2,7 @@ class App.KnowledgeBase extends App.Model
@configure 'KnowledgeBase', 'iconset', 'color_highlight', 'color_header', 'color_header_link', 'translation_ids', 'locale_ids', 'homepage_layout', 'category_layout', 'custom_address' @configure 'KnowledgeBase', 'iconset', 'color_highlight', 'color_header', 'color_header_link', 'translation_ids', 'locale_ids', 'homepage_layout', 'category_layout', 'custom_address'
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions @extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseAccess
@url: @apiPath + '/knowledge_bases' @url: @apiPath + '/knowledge_bases'
@manageUrl: @apiPath + '/knowledge_bases/manage' @manageUrl: @apiPath + '/knowledge_bases/manage'
@ -76,7 +77,7 @@ class App.KnowledgeBase extends App.Model
, initial , initial
visibleInternally: (kb_locale) -> visibleInternally: (kb_locale) ->
@active @active && @access() != 'none'
visiblePublicly: (kb_locale) -> visiblePublicly: (kb_locale) ->
@active @active
@ -88,6 +89,41 @@ class App.KnowledgeBase extends App.Model
attrs attrs
loadedAnswerIds: ->
App.KnowledgeBaseAnswer
.all()
.filter (elem) => elem.knowledge_base().id == @id
.map (elem) -> elem.id
loadedCategoryIds: ->
App.KnowledgeBaseCategory
.all()
.map (elem) -> elem.id
removeAssetsIfNeeded: (data) =>
removeAnswers = _.difference @loadedAnswerIds(), data.answer_ids
removeCategories = _.difference @loadedAnswerIds(), data.category_ids
for answer_id in removeAnswers
App.KnowledgeBaseAnswer.find(answer_id)?.remove(clear: true)
for category_id in removeCategories
App.KnowledgeBaseCategory.find(category_id)?.remove(clear: true)
!_.isEmpty(removeAnswers) || !_.isEmpty(removeCategories)
hasAssetsToLoad: (data) =>
needsLoadingAnswers = _.difference data.answer_ids, @loadedAnswerIds()
needsLoadingCategories = _.difference data.category_ids, @loadedCategoryIds()
!_.isEmpty(needsLoadingAnswers) || !_.isEmpty(needsLoadingCategories)
@allKbModelNames: ->
Object
.keys(App)
.filter (elem) ->
elem.match(/^KnowledgeBase/) && App[elem]?.prototype instanceof App.Model
@configure_attributes: [ @configure_attributes: [
{ {
name: 'translation::title' name: 'translation::title'

View file

@ -3,6 +3,7 @@ class App.KnowledgeBaseAnswer extends App.Model
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions @extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseCanBePublished @extend App.KnowledgeBaseCanBePublished
@extend App.KnowledgeBaseAccess
@serverClassName: 'KnowledgeBase::Answer' @serverClassName: 'KnowledgeBase::Answer'
@ -95,4 +96,4 @@ class App.KnowledgeBaseAnswer extends App.Model
'Answer' 'Answer'
visibleInternally: (kb_locale) => visibleInternally: (kb_locale) =>
@is_internally_published(kb_locale) (@is_internally_published(kb_locale) && @access() != 'none') || @is_published(kb_locale)

View file

@ -2,6 +2,7 @@ class App.KnowledgeBaseCategory extends App.Model
@configure 'KnowledgeBaseCategory', 'category_icon', 'parent_id', 'child_ids', 'translation_ids' @configure 'KnowledgeBaseCategory', 'category_icon', 'parent_id', 'child_ids', 'translation_ids'
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions @extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseAccess
url: -> url: ->
@knowledge_base().generateURL('categories') @knowledge_base().generateURL('categories')
@ -166,6 +167,8 @@ class App.KnowledgeBaseCategory extends App.Model
'draft' 'draft'
visibleInternally: (kb_locale) => visibleInternally: (kb_locale) =>
#return false if @access() == 'none'
@findDeepAnswer( (record) -> @findDeepAnswer( (record) ->
record.is_internally_published(kb_locale) record.is_internally_published(kb_locale)
)? )?

View file

@ -0,0 +1,2 @@
class App.KnowledgeBaseCategoryPermission extends App.Model
@configure 'KnowledgeBaseCategoryPermission', 'access'

View file

@ -65,7 +65,6 @@ class App.KnowledgeBaseForm extends App.Controller
submit: (e) -> submit: (e) ->
@preventDefaultAndStopPropagation(e) @preventDefaultAndStopPropagation(e)
#debuggerj
formController = @formControllers.filter((elem) -> (elem.form[0] is e.currentTarget) or (e.currentTarget.contains(elem.form[0])))[0] formController = @formControllers.filter((elem) -> (elem.form[0] is e.currentTarget) or (e.currentTarget.contains(elem.form[0])))[0]
params = @formParam(formController.form) params = @formParam(formController.form)

View file

@ -0,0 +1,33 @@
<div style="padding-left: 18px; padding-top: 10px;">
<table class="settings-list settings-list--roles-permissions">
<thead>
<th width=150><%- @T('Role') %>
<% for key, text of @accessLevels: %>
<th><%- @T(text) %>
<% end %>
<tbody>
<% for role in @roles: %>
<tr>
<td>
<%= role.name %>
<% for key, text of @accessLevels: %>
<td class="settings-list-control-cell settings-list-radio-cell u-clickable">
<label class="inline-label radio-replacement">
<input
type="radio"
value="<%= key %>"
name="<%= role.id %>"
<% if @params[role.id] == key: %>checked<% end %>
<% if role.limit?: %>
<% if key == 'editor' && role.limit != 'editor': %>disabled<% end %>
<% if key == 'reader' && role.limit == 'none': %>disabled<% end %>
<% end %>
/>
<%- @Icon('radio', 'icon-unchecked') %>
<%- @Icon('radio-checked', 'icon-checked') %>
</label>
<% end %>
</tr>
<% end %>
</table>
</div>

View file

@ -10921,10 +10921,15 @@ output {
} }
} }
.settings-list-checkbox-cell { .settings-list-checkbox-cell,
.settings-list-radio-cell {
vertical-align: middle; vertical-align: middle;
padding-left: 8px; padding-left: 8px;
} }
.settings-list-radio-cell {
text-align: center;
}
} }
.select-boxes { .select-boxes {

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase::PermissionsController < ApplicationController
prepend_before_action :authentication_check
before_action :fetch_object
def show
render json: response_hash
end
def update
permissions_params = params.require(:permissions_dialog).permit(permissions: {})
KnowledgeBase::PermissionsUpdate.new(@object, current_user).update_using_params!(permissions_params)
render json: response_hash
end
private
def fetch_object
if params[:knowledge_base_id]
@object = KnowledgeBase::Category.includes(:permissions).find params[:id]
authorize @object, :permissions?
else
@object = KnowledgeBase.includes(:permissions).find params[:id]
authorize @object, :update?
end
end
def parent_object
return if !@object.is_a? KnowledgeBase::Category
@object.parent || @object.knowledge_base
end
def response_hash
{
roles_reader: Role.with_permissions('knowledge_base.reader').pluck_as_hash(:id, :name),
roles_editor: Role.with_permissions('knowledge_base.editor').pluck_as_hash(:id, :name),
permissions: @object.permissions_effective.pluck_as_hash(:id, :access, :role_id),
inherited: parent_object&.permissions_effective&.pluck_as_hash(:id, :access, :role_id)
}
end
end

View file

@ -13,9 +13,7 @@ class KnowledgeBase::Public::AnswersController < KnowledgeBase::Public::BaseCont
private private
def render_alternative def render_alternative
@alternative = policy_scope(@knowledge_base.answers) @alternative = find_answer @knowledge_base.answers.eager_load(translations: :kb_locale), params[:answer], locale: false
.eager_load(translations: :kb_locale)
.find_by(id: params[:answer])
raise ActiveRecord::RecordNotFound if !@alternative&.translations&.any? raise ActiveRecord::RecordNotFound if !@alternative&.translations&.any?

View file

@ -2,7 +2,8 @@
class KnowledgeBase::Public::BaseController < ApplicationController class KnowledgeBase::Public::BaseController < ApplicationController
before_action :load_kb before_action :load_kb
helper_method :system_locale_via_uri, :fallback_locale, :current_user, :find_category, :filter_primary_kb_locale, :menu_items, :all_locales helper_method :system_locale_via_uri, :fallback_locale, :current_user, :find_category,
:filter_primary_kb_locale, :menu_items, :all_locales, :can_preview?
layout 'knowledge_base' layout 'knowledge_base'
@ -31,12 +32,9 @@ class KnowledgeBase::Public::BaseController < ApplicationController
end end
def fallback_locale def fallback_locale
if all_locales.find { |locale| locale.id == system_locale_via_uri&.id } return system_locale_via_uri if all_locales.find { |locale| locale.id == system_locale_via_uri&.id }
system_locale_via_uri
else
filter_primary_kb_locale || all_locales.first
end
filter_primary_kb_locale || all_locales.first
end end
def filter_primary_kb_locale def filter_primary_kb_locale
@ -62,16 +60,40 @@ class KnowledgeBase::Public::BaseController < ApplicationController
list list
.localed(system_locale_via_uri) .localed(system_locale_via_uri)
.sorted .sorted
.select { |category| policy(category).show? } .select { |category| policy(category).show_public? }
end end
def find_answer(scope, id) def answers_filter(list)
answers = list
.localed(system_locale_via_uri)
.sorted
if current_user&.permissions?('knowledge_base.editor')
answers.filter { |answer| policy(answer).show_public? }
else
answers.published
end
end
def find_answer(scope, id, locale: system_locale_via_uri)
return if scope.nil? return if scope.nil?
policy_scope(scope) scope = scope.include_contents
.localed(system_locale_via_uri) scope = scope.localed(locale) if locale
.include_contents
.find_by(id: id) if !current_user&.permissions?('knowledge_base.editor')
return scope.published.find_by(id: id)
end
if (item = scope.find_by(id: id)) && policy(item).show_public?
return item
end
nil
end
def can_preview?
@can_preview ||= policy(@knowledge_base).update?
end end
def not_found(e) def not_found(e)

View file

@ -15,14 +15,11 @@ class KnowledgeBase::Public::CategoriesController < KnowledgeBase::Public::BaseC
def show def show
@object = find_category(params[:category]) @object = find_category(params[:category])
render_alternatives && return if @object.nil? || !policy(@object).show? render_alternatives && return if @object.nil? || !policy(@object).show_public?
@categories = categories_filter(@object.children) @categories = categories_filter(@object.children)
@object_locales = find_locales(@object) @object_locales = find_locales(@object)
@answers = answers_filter(@object.answers)
@answers = policy_scope(@object.answers)
.localed(system_locale_via_uri)
.sorted
render :index render :index
end end

View file

@ -2,12 +2,7 @@
class KnowledgeBase::Public::TagsController < KnowledgeBase::Public::BaseController class KnowledgeBase::Public::TagsController < KnowledgeBase::Public::BaseController
def show def show
@object = [:tag, params[:tag]] @object = [:tag, params[:tag]]
@answers = answers_filter KnowledgeBase::Answer.tag_objects(params[:tag])
all_tagged = KnowledgeBase::Answer.tag_objects(params[:tag])
@answers = policy_scope(all_tagged)
.localed(system_locale_via_uri)
.sorted
end end
end end

View file

@ -5,15 +5,41 @@ class KnowledgeBasesController < KnowledgeBase::BaseController
render json: assets(params[:answer_translation_content_ids]) render json: assets(params[:answer_translation_content_ids])
end end
def visible_ids
render json: KnowledgeBase::InternalAssets.new(current_user).visible_ids
end
private private
def assets(answer_translation_content_ids = nil) def assets(answer_translation_content_ids = nil)
return editor_assets(answer_translation_content_ids) if current_user&.permissions?('knowledge_base.editor') if KnowledgeBase.granular_permissions?
return reader_assets(answer_translation_content_ids) if current_user&.permissions?('knowledge_base.reader') return granular_assets(answer_translation_content_ids) if kb_permissions?
else
return editor_assets(answer_translation_content_ids) if kb_permission_editor?
return reader_assets(answer_translation_content_ids) if kb_permission_reader?
end
public_assets public_assets
end end
def kb_permissions?
current_user&.permissions?(%w[knowledge_base.editor knowledge_base.reader])
end
def kb_permission_editor?
current_user&.permissions?('knowledge_base.editor')
end
def kb_permission_reader?
current_user&.permissions?('knowledge_base.reader')
end
def granular_assets(answer_translation_content_ids)
KnowledgeBase::InternalAssets
.new(current_user, answer_translation_content_ids: answer_translation_content_ids)
.collect_assets
end
def editor_assets(answer_translation_content_ids) def editor_assets(answer_translation_content_ids)
assets = [ assets = [
KnowledgeBase, KnowledgeBase,

View file

@ -36,7 +36,7 @@ module KnowledgeBaseTopBarHelper
end end
def render_top_bar_if_needed(object, knowledge_base) def render_top_bar_if_needed(object, knowledge_base)
return if !policy(:knowledge_base).edit? return if !can_preview?
editable = object || knowledge_base editable = object || knowledge_base

View file

@ -2,7 +2,7 @@
module KnowledgeBaseVisibilityClassHelper module KnowledgeBaseVisibilityClassHelper
def visibility_class_name(object) def visibility_class_name(object)
return if !policy(:knowledge_base).edit? return if !can_preview?
suffix = case object suffix = case object
when CanBePublished when CanBePublished

View file

@ -2,7 +2,7 @@
module KnowledgeBaseVisibilityNoteHelper module KnowledgeBaseVisibilityNoteHelper
def visibility_note(object) def visibility_note(object)
return if !policy(:knowledge_base).edit? return if !can_preview?
text = visibility_text(object) text = visibility_text(object)

View file

@ -4,12 +4,12 @@ class ChecksKbClientNotificationJob < ApplicationJob
include HasActiveJobLock include HasActiveJobLock
def lock_key def lock_key
# "ChecksKbClientNotificationJob/KnowledgeBase::Answer/42/destroy" # "ChecksKbClientNotificationJob/KnowledgeBase::Answer/42"
"#{self.class.name}/#{arguments[0]}/#{arguments[1]}/#{arguments[2]}" "#{self.class.name}/#{arguments[0]}/#{arguments[1]}"
end end
def perform(klass_name, id, event) def perform(klass_name, object_id)
object = klass_name.constantize.find_by(id: id) object = klass_name.constantize.find_by(id: object_id)
return if object.blank? return if object.blank?
level = needs_editor?(object) ? 'editor' : '*' level = needs_editor?(object) ? 'editor' : '*'
@ -24,7 +24,7 @@ class ChecksKbClientNotificationJob < ApplicationJob
def build_data(object, event) def build_data(object, event)
timestamp = event == :destroy ? Time.zone.now : object.updated_at timestamp = event == :destroy ? Time.zone.now : object.updated_at
url = event == :destroy ? nil : object.api_url url = event == :destroy ? nil : object.try(:api_url)
{ {
class: object.class.to_s, class: object.class.to_s,
@ -57,8 +57,4 @@ class ChecksKbClientNotificationJob < ApplicationJob
.filter_map { |user_id| User.find_by(id: user_id) } .filter_map { |user_id| User.find_by(id: user_id) }
.select { |user| user.permissions? "knowledge_base.#{permission_suffix}" } .select { |user| user.permissions? "knowledge_base.#{permission_suffix}" }
end end
def self.notify_later(object, event)
perform_later(object.class.to_s, object.id, event.to_s)
end
end end

View file

@ -0,0 +1,14 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class ChecksKbClientVisibilityJob < ApplicationJob
include HasActiveJobLock
def lock_key
# "ChecksKbClientVisibilityJob"
self.class.name
end
def perform
Sessions.broadcast({ event: 'kb_visibility_may_have_changed' })
end
end

View file

@ -4,10 +4,7 @@ module ChecksKbClientNotification
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
after_create :notify_kb_clients_after_create after_commit :notify_kb_clients_after
after_update :notify_kb_clients_after_update
after_touch :notify_kb_clients_after_touch
after_destroy :notify_kb_clients_after_destroy
class_attribute :notify_kb_clients_suspend, default: false class_attribute :notify_kb_clients_suspend, default: false
end end
@ -30,25 +27,9 @@ module ChecksKbClientNotification
# generic call # generic call
def notify_kb_clients(event) def notify_kb_clients_after
return if self.class.notify_kb_clients_suspend? return if self.class.notify_kb_clients_suspend?
ChecksKbClientNotificationJob.notify_later(self, event) ChecksKbClientNotificationJob.perform_later(self.class.name, id)
end
def notify_kb_clients_after_create
notify_kb_clients(:create)
end
def notify_kb_clients_after_update
notify_kb_clients(:update)
end
def notify_kb_clients_after_touch
notify_kb_clients(:touch)
end
def notify_kb_clients_after_destroy
notify_kb_clients(:destroy)
end end
end end

View file

@ -0,0 +1,17 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
module ChecksKbClientVisibility
extend ActiveSupport::Concern
included do
after_commit :notify_kb_client_visibility
end
private
def notify_kb_client_visibility
return if self.class.notify_kb_clients_suspend?
ChecksKbClientVisibilityJob.perform_later
end
end

View file

@ -24,6 +24,11 @@ class KnowledgeBase < ApplicationModel
has_many :answers, through: :categories has_many :answers, through: :categories
has_many :permissions, class_name: 'KnowledgeBase::Permission',
as: :permissionable,
autosave: true,
dependent: :destroy
validates :category_layout, inclusion: { in: KnowledgeBase::LAYOUTS } validates :category_layout, inclusion: { in: KnowledgeBase::LAYOUTS }
validates :homepage_layout, inclusion: { in: KnowledgeBase::LAYOUTS } validates :homepage_layout, inclusion: { in: KnowledgeBase::LAYOUTS }
@ -156,6 +161,24 @@ class KnowledgeBase < ApplicationModel
.any? { |e| e > 1 } .any? { |e| e > 1 }
end end
def permissions_effective
cache_key = KnowledgeBase::Permission.cache_key self
Rails.cache.fetch cache_key do
permissions
end
end
def attributes_with_association_ids
attrs = super
attrs[:permissions_effective] = permissions_effective
attrs
end
def self.granular_permissions?
KnowledgeBase::Permission.any?
end
private private
def set_defaults def set_defaults

View file

@ -6,6 +6,7 @@ class KnowledgeBase::Answer < ApplicationModel
include HasTags include HasTags
include CanBePublished include CanBePublished
include ChecksKbClientNotification include ChecksKbClientNotification
include ChecksKbClientVisibility
include CanCloneAttachments include CanCloneAttachments
AGENT_ALLOWED_ATTRIBUTES = %i[category_id promoted internal_note].freeze AGENT_ALLOWED_ATTRIBUTES = %i[category_id promoted internal_note].freeze
@ -49,7 +50,13 @@ class KnowledgeBase::Answer < ApplicationModel
siblings = category.answers siblings = category.answers
if !User.lookup(id: UserInfo.current_user_id)&.permissions?('knowledge_base.editor') if !User.lookup(id: UserInfo.current_user_id)&.permissions?('knowledge_base.editor')
siblings = siblings.internal ep = KnowledgeBase::EffectivePermission.new User.find(UserInfo.current_user_id), category
siblings = if ep.access_effective == 'public_reader'
siblings.published
else
siblings.internal
end
end end
data = ApplicationModel::CanAssets.reduce(siblings, data) data = ApplicationModel::CanAssets.reduce(siblings, data)

View file

@ -4,25 +4,31 @@ class KnowledgeBase::Category < ApplicationModel
include HasTranslations include HasTranslations
include HasAgentAllowedParams include HasAgentAllowedParams
include ChecksKbClientNotification include ChecksKbClientNotification
include ChecksKbClientVisibility
AGENT_ALLOWED_ATTRIBUTES = %i[knowledge_base_id parent_id category_icon].freeze AGENT_ALLOWED_ATTRIBUTES = %i[knowledge_base_id parent_id category_icon].freeze
AGENT_ALLOWED_NESTED_RELATIONS = %i[translations].freeze AGENT_ALLOWED_NESTED_RELATIONS = %i[translations].freeze
belongs_to :knowledge_base, inverse_of: :categories belongs_to :knowledge_base, inverse_of: :categories
has_many :answers, class_name: 'KnowledgeBase::Answer', has_many :answers, class_name: 'KnowledgeBase::Answer',
inverse_of: :category, inverse_of: :category,
dependent: :restrict_with_exception dependent: :restrict_with_exception
has_many :children, class_name: 'KnowledgeBase::Category', has_many :children, class_name: 'KnowledgeBase::Category',
foreign_key: :parent_id, foreign_key: :parent_id,
inverse_of: :parent, inverse_of: :parent,
dependent: :restrict_with_exception dependent: :restrict_with_exception
belongs_to :parent, class_name: 'KnowledgeBase::Category', belongs_to :parent, class_name: 'KnowledgeBase::Category',
inverse_of: :children, inverse_of: :children,
touch: true, touch: true,
optional: true optional: true
has_many :permissions, class_name: 'KnowledgeBase::Permission',
as: :permissionable,
autosave: true,
dependent: :destroy
validates :category_icon, presence: true validates :category_icon, presence: true
@ -119,6 +125,20 @@ class KnowledgeBase::Category < ApplicationModel
Rails.application.routes.url_helpers.knowledge_base_category_path(knowledge_base, self) Rails.application.routes.url_helpers.knowledge_base_category_path(knowledge_base, self)
end end
def permissions_effective
cache_key = KnowledgeBase::Permission.cache_key self
Rails.cache.fetch cache_key do
KnowledgeBase::Category::Permission.new(self).permissions_effective
end
end
def attributes_with_association_ids
attrs = super
attrs[:permissions_effective] = permissions_effective
attrs
end
private private
def cannot_be_child_of_parent def cannot_be_child_of_parent

View file

@ -0,0 +1,16 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase::Permission < ApplicationModel
belongs_to :permissionable, polymorphic: true, touch: true
belongs_to :role
validates :access, inclusion: { in: %w[editor reader none] }
validates :role, uniqueness: { scope: %i[permissionable_id permissionable_type] }
# cache key for calculated permissions
# @param permissionable [KnowledgeBase::Category, KnowledgeBase]
# @return [String]
def self.cache_key(permissionable)
"#{permissionable.class}::aws::#{permissionable.id}::permission::#{permissionable.updated_at}"
end
end

View file

@ -14,11 +14,12 @@ class Role < ApplicationModel
has_and_belongs_to_many :users, after_add: :cache_update, after_remove: :cache_update has_and_belongs_to_many :users, after_add: :cache_update, after_remove: :cache_update
has_and_belongs_to_many :permissions, has_and_belongs_to_many :permissions,
before_add: %i[validate_agent_limit_by_permission validate_permissions], before_add: %i[validate_agent_limit_by_permission validate_permissions],
after_add: :cache_update, after_add: %i[cache_update cache_add_kb_permission],
before_remove: :last_admin_check_by_permission, before_remove: :last_admin_check_by_permission,
after_remove: :cache_update after_remove: %i[cache_update cache_remove_kb_permission]
validates :name, presence: true validates :name, presence: true
store :preferences store :preferences
has_many :knowledge_base_permissions, class_name: 'KnowledgeBase::Permission', dependent: :destroy
before_create :check_default_at_signup_permissions before_create :check_default_at_signup_permissions
before_update :last_admin_check_by_attribute, :validate_agent_limit_by_attributes, :check_default_at_signup_permissions before_update :last_admin_check_by_attribute, :validate_agent_limit_by_attributes, :check_default_at_signup_permissions
@ -232,4 +233,31 @@ returns
raise Exceptions::UnprocessableEntity, "Cannot set default at signup when role has #{forbidden_permissions.join(', ')} permissions." raise Exceptions::UnprocessableEntity, "Cannot set default at signup when role has #{forbidden_permissions.join(', ')} permissions."
end end
def cache_add_kb_permission(permission)
return if !permission.name.starts_with? 'knowledge_base.'
return if !KnowledgeBase.granular_permissions?
KnowledgeBase::Category.all.each(&:touch)
end
def cache_remove_kb_permission(permission)
return if !permission.name.starts_with? 'knowledge_base.'
return if !KnowledgeBase.granular_permissions?
has_editor = permissions.where(name: 'knowledge_base.editor').any?
has_reader = permissions.where(name: 'knowledge_base.reader').any?
KnowledgeBase::Permission
.where(role: self)
.each do |elem|
if !has_editor && !has_reader
elem.destroy!
elsif !has_editor && has_reader
elem.update!(access: 'reader') if permission.access == 'editor'
end
end
KnowledgeBase::Category.all.each(&:touch)
end
end end

View file

@ -2,9 +2,34 @@
class Controllers::KnowledgeBase::AnswersControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy class Controllers::KnowledgeBase::AnswersControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy
def show? def show?
return true if user.permissions?('knowledge_base.editor') access(__method__)
end
object = record.klass.find(record.params[:id]) def create?
object.can_be_published_aasm.internal? || object.can_be_published_aasm.published? verify_category(__method__)
end
def update?
access(__method__) && verify_category(__method__)
end
def destroy?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::AnswerPolicy.new(user, object).send(method)
end
def verify_category(method)
new_category = KnowledgeBase::Category.find(record.params[:category_id])
KnowledgeBase::CategoryPolicy.new(user, new_category).send(method)
end end
end end

View file

@ -2,8 +2,38 @@
class Controllers::KnowledgeBase::CategoriesControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy class Controllers::KnowledgeBase::CategoriesControllerPolicy < Controllers::KnowledgeBase::BaseControllerPolicy
def show? def show?
return if user.permissions?('knowledge_base.editor') access(__method__)
end
record.klass.find(record.params[:id]).internal_content? def create?
verify_parent(__method__)
end
def update?
access(__method__) && verify_parent(__method__)
end
def destroy?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::CategoryPolicy.new(user, object).send(method)
end
def verify_parent(method)
if record.params[:parent_id].blank?
return user.permissions?('knowledge_base.editor')
end
parent = KnowledgeBase::Category.find(record.params[:parent_id])
KnowledgeBase::CategoryPolicy.new(user, parent).send(method)
end end
end end

View file

@ -12,4 +12,18 @@ class Controllers::KnowledgeBasesControllerPolicy < Controllers::KnowledgeBase::
def destroy? def destroy?
false false
end end
def update?
access(__method__)
end
private
def object
@object ||= record.klass.find(record.params[:id])
end
def access(method)
KnowledgeBase::CategoryPolicy.new(user, object).send(method)
end
end end

View file

@ -2,13 +2,39 @@
class KnowledgeBase::AnswerPolicy < ApplicationPolicy class KnowledgeBase::AnswerPolicy < ApplicationPolicy
def show? def show?
return true if user&.permissions?(%w[knowledge_base.editor]) return true if access_editor?
record.visible? || record.visible? ||
(user&.permissions?(%w[knowledge_base.reader]) && record.visible_internally?) (access_reader? && record.visible_internally?)
end
def show_public?
access_editor? || record.visible?
end
def create?
access_editor?
end
def update?
access_editor?
end end
def destroy? def destroy?
user&.permissions?(%w[knowledge_base.editor]) access_editor?
end
private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record.category).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end end
end end

View file

@ -2,13 +2,51 @@
class KnowledgeBase::CategoryPolicy < ApplicationPolicy class KnowledgeBase::CategoryPolicy < ApplicationPolicy
def show? def show?
return true if user&.permissions?('knowledge_base.editor') access_editor? || access_reader?
end
record.public_content? def show_public?
access_editor? || record.public_content?
end
def permissions?
access_editor?
end
def create?
parent_editor?
end
def update?
access_editor?
end
def destroy?
parent_editor?
end end
private private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end
def parent_access
@parent_access ||= KnowledgeBase::EffectivePermission.new(user, (record.parent || record.knowledge_base)).access_effective
end
def parent_editor?
parent_access == 'editor'
end
def user_required? def user_required?
false false
end end

View file

@ -1,7 +1,25 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ # Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBasePolicy < ApplicationPolicy class KnowledgeBasePolicy < ApplicationPolicy
def edit? def show?
user&.permissions?('knowledge_base.editor') access_editor? || access_reader?
end
def update?
access_editor?
end
private
def access
@access ||= KnowledgeBase::EffectivePermission.new(user, record).access_effective
end
def access_editor?
access == 'editor'
end
def access_reader?
access == 'reader'
end end
end end

View file

@ -20,6 +20,7 @@ Zammad::Application.routes.draw do
resources :knowledge_bases, only: %i[show update] do resources :knowledge_bases, only: %i[show update] do
collection do collection do
post :init post :init
get :visible_ids
post :search, controller: 'knowledge_base/search' post :search, controller: 'knowledge_base/search'
get :recent_answers, controller: 'knowledge_base/answers' get :recent_answers, controller: 'knowledge_base/answers'
@ -35,11 +36,17 @@ Zammad::Application.routes.draw do
end end
end end
member do
resource :permissions, controller: 'knowledge_base/permissions', only: %i[update show]
end
resources :categories, controller: 'knowledge_base/categories', resources :categories, controller: 'knowledge_base/categories',
except: %i[new edit] do except: %i[new edit] do
member do member do
patch :reorder_categories, :reorder_answers patch :reorder_categories, :reorder_answers
resource :permissions, controller: 'knowledge_base/permissions', only: %i[update show]
end end
collection do collection do

View file

@ -108,6 +108,18 @@ class InitializeKnowledgeBase < ActiveRecord::Migration[5.0]
t.timestamps # rubocop:disable Zammad/ExistsDateTimePrecision t.timestamps # rubocop:disable Zammad/ExistsDateTimePrecision
end end
create_table :knowledge_base_permissions do |t|
t.references :permissionable, polymorphic: true, null: false, index: { name: 'index_knowledge_base_permissions_on_permissionable' }
t.references :role, null: false, foreign_key: { to_table: :roles }
t.string 'access', limit: 50, default: 'full', null: false
t.index 'access'
t.index %i[role_id permissionable_id permissionable_type], unique: true, name: 'knowledge_base_permissions_uniqueness'
t.timestamps limit: 3
end
Setting.create_if_not_exists( Setting.create_if_not_exists(
title: 'Kb multi-lingual support', title: 'Kb multi-lingual support',
name: 'kb_multi_lingual_support', name: 'kb_multi_lingual_support',
@ -169,11 +181,12 @@ class InitializeKnowledgeBase < ActiveRecord::Migration[5.0]
) )
Permission.create_if_not_exists( Permission.create_if_not_exists(
name: 'knowledge_base.reader', name: 'knowledge_base.reader',
note: 'Access %s', note: 'Access %s',
preferences: { preferences: {
translations: ['Knowledge Base'] translations: ['Knowledge Base']
} },
allow_signup: true,
) )
Permission.create_if_not_exists( Permission.create_if_not_exists(

View file

@ -0,0 +1,22 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
# Using older 5.0 migration to stick to Integer primary keys. Otherwise migration fails in MySQL.
class CreateKnowledgeBasePermissions < ActiveRecord::Migration[5.0]
def change
return if !Setting.exists?(name: 'system_init_done')
create_table :knowledge_base_permissions do |t|
t.references :permissionable, polymorphic: true, null: false, index: { name: 'index_knowledge_base_permissions_on_permissionable' }
t.references :role, null: false, foreign_key: { to_table: :roles }
t.string 'access', limit: 50, default: 'full', null: false
t.index 'access'
t.index %i[role_id permissionable_id permissionable_type], unique: true, name: 'knowledge_base_permissions_uniqueness'
t.timestamps limit: 3
end
Permission.where(name: 'knowledge_base.reader').update_all(allow_signup: true) # rubocop:disable Rails/SkipsModelValidations
end
end

View file

@ -441,11 +441,12 @@ Permission.create_if_not_exists(
) )
Permission.create_if_not_exists( Permission.create_if_not_exists(
name: 'knowledge_base.reader', name: 'knowledge_base.reader',
note: __('Manage %s'), note: __('Manage %s'),
preferences: { preferences: {
translations: [__('Knowledge Base Reader')] translations: [__('Knowledge Base Reader')]
} },
allow_signup: true,
) )
admin = Role.find_by(name: 'Admin') admin = Role.find_by(name: 'Admin')

View file

@ -5163,6 +5163,10 @@ msgstr ""
msgid "Invalid payload, need data:image in logo param" msgid "Invalid payload, need data:image in logo param"
msgstr "" msgstr ""
#: lib/knowledge_base/permissions_update.rb
msgid "Invalid permissions, do not lock out yourself"
msgstr ""
#: app/models/concerns/checks_condition_validation.rb #: app/models/concerns/checks_condition_validation.rb
msgid "Invalid ticket selector conditions" msgid "Invalid ticket selector conditions"
msgstr "" msgstr ""
@ -7613,6 +7617,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee #: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
#: app/assets/javascripts/app/controllers/role.coffee #: app/assets/javascripts/app/controllers/role.coffee
#: app/assets/javascripts/app/views/integration/ldap.jst.eco #: app/assets/javascripts/app/views/integration/ldap.jst.eco
#: app/assets/javascripts/app/views/knowledge_base/permissions_dialog.jst.eco
msgid "Role" msgid "Role"
msgstr "" msgstr ""
@ -9570,6 +9575,14 @@ msgstr ""
msgid "URL (AJAX endpoint)" msgid "URL (AJAX endpoint)"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/permissions_dialog.coffee
msgid "Unable to load changes"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/permissions_dialog.coffee
msgid "Unable to save changes"
msgstr ""
#: app/controllers/first_steps_controller.rb #: app/controllers/first_steps_controller.rb
#: db/seeds/overviews.rb #: db/seeds/overviews.rb
msgid "Unassigned & Open Tickets" msgid "Unassigned & Open Tickets"
@ -10024,11 +10037,11 @@ msgid "Visibility"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee #: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
msgid "Visible to agents & editors" msgid "Visible to everyone"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee #: app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
msgid "Visible to everyone" msgid "Visible to readers & editors"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_integration/placetel.coffee #: app/assets/javascripts/app/controllers/_integration/placetel.coffee

View file

@ -37,3 +37,24 @@ module ActiveRecord
end end
end end
end end
module Enumerable
def pluck_as_hash(*column_names)
column_names.flatten! # flatten args in case array was given
pluck(*column_names)
.map { |elem| pluck_as_hash_map(column_names, elem) }
end
private
def pluck_as_hash_map(keys, values)
if keys.one?
{
keys.first => values
}
else
keys.zip(values).to_h
end
end
end

View file

@ -0,0 +1,33 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class Category
class Permission
def initialize(category)
@category = category
end
def permissions_effective
parents_for_category
.map(&:permissions)
.flatten
.each_with_object([]) do |elem, memo|
memo << elem if !memo.find { |added| added.role == elem.role }
end
end
private
def parents_for_category
categories_tree = @category.self_with_parents
categories_with_permissions = KnowledgeBase::Category.where(id: categories_tree).includes(:permissions).to_a
sorted_with_permissions = categories_tree
.map { |elem| categories_with_permissions.find { |elem_with_permissions| elem_with_permissions == elem } }
sorted_with_permissions + [@category.knowledge_base]
end
end
end
end

View file

@ -0,0 +1,58 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class EffectivePermission
def initialize(user, object)
@user = user
@object = object
end
def access_effective
return 'none' if !@user
@user.roles.reduce('none') do |memo, role|
access = access_role_effective(role)
return access if access == 'editor'
memo == 'reader' ? memo : access
end
end
private
def permissions
@permissions ||= @object.permissions_effective
end
def access_role_effective(role)
permission = permissions.find { |elem| elem.role == role }
return default_role_access(role) if !permission
calculate_role(role, permission)
end
def calculate_role(role, permission)
if permission.access == 'editor' && role.with_permission?('knowledge_base.editor')
'editor'
elsif %w[editor reader].include?(permission.access) && role.with_permission?(%w[knowledge_base.editor knowledge_base.reader])
'reader'
elsif @object.public_content?
'public_reader'
else
'none'
end
end
def default_role_access(role)
if role.with_permission?('knowledge_base.editor')
'editor'
elsif role.with_permission?('knowledge_base.reader')
'reader'
else
'none'
end
end
end
end

View file

@ -0,0 +1,120 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class InternalAssets
CategoriesCache = Struct.new(:editor, :reader, :public_reader, keyword_init: true) do
def all
editor + reader + public_reader
end
end
attr_reader :assets
def initialize(user, answer_translation_content_ids: [])
@user = user
@assets = {}
@answer_translation_content_ids = answer_translation_content_ids
end
def collect_assets
collect_base_assets
add_to_assets accessible_categories.all, type: :essential
add_to_assets KnowledgeBase::Category::Translation.where(category: accessible_categories.all)
collect_all_answer_assets
@assets
end
def accessible_categories
@accessible_categories ||= accessible_categories_calculate
end
def all_answer_ids
all_answer_batches.each_with_object([]) do |elem, sum|
sum.concat elem.pluck(:id)
end
end
def all_category_ids
accessible_categories.all.pluck(:id)
end
def visible_ids
{
answer_ids: all_answer_ids,
category_ids: all_category_ids
}
end
private
def accessible_categories_calculate
struct = CategoriesCache.new editor: [], reader: [], public_reader: []
KnowledgeBase::Category.all.find_in_batches do |group|
group.each do |cat|
case KnowledgeBase::EffectivePermission.new(@user, cat).access_effective
when 'editor'
struct.editor << cat
when 'reader'
struct.reader << cat if cat.internal_content?
when 'public_reader'
struct.public_reader << cat if cat.public_content?
end
end
end
struct
end
def add_to_assets(objects, type: nil)
@assets = ApplicationModel::CanAssets.reduce(objects, @assets, type)
end
def collect_base_assets
[KnowledgeBase, KnowledgeBase::Translation, KnowledgeBase::Locale]
.each do |klass|
klass.find_in_batches do |group|
add_to_assets group, type: :essential
end
end
end
def all_answer_batches
[
KnowledgeBase::Answer.where(category: accessible_categories.editor),
KnowledgeBase::Answer.internal.where(category: accessible_categories.reader),
KnowledgeBase::Answer.published.where(category: accessible_categories.public_reader)
]
end
def collect_all_answer_assets
all_answer_batches.each do |batch|
collect_answers_assets batch
end
end
def collect_answers_assets(scope)
scope.find_in_batches do |group|
add_to_assets group, type: :essential
translations = KnowledgeBase::Answer::Translation.where(answer: group)
add_to_assets translations, type: :essential
if @answer_translation_content_ids.present?
contents = KnowledgeBase::Answer::Translation::Content
.joins(:translation)
.where(
id: @answer_translation_content_ids,
knowledge_base_answer_translations: { answer_id: group }
)
add_to_assets contents
end
end
end
end
end

View file

@ -0,0 +1,97 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class KnowledgeBase
class PermissionsUpdate
def initialize(object, user = nil)
@object = object
@user = user
end
def update!(**roles_to_permissions)
ActiveRecord::Base.transaction do
update_object(roles_to_permissions)
next if !@object.changed_for_autosave?
@object.save!
update_all_children
ensure_editable!
end
end
def update_using_params!(params)
roles_to_permissions = params[:permissions].transform_keys { |key| Role.find key }
update!(**roles_to_permissions)
end
private
def update_object(roles_to_permissions)
@object.permissions.reject { |elem| roles_to_permissions.key? elem.role }.each(&:mark_for_destruction)
roles_to_permissions.each do |role, access|
update_object_permission(role, access)
end
end
def update_object_permission(role, access)
permission = @object.permissions.detect { |elem| elem.role == role } || @object.permissions.build(role: role)
permission.access = access
mark_permission_for_cleanup_if_needed(permission, parent_object_permissions)
end
def parent_object_permissions
@parent_object_permissions ||= begin
if @object.is_a? KnowledgeBase::Category
(@object.parent || @object.knowledge_base).permissions_effective || []
else
[]
end
end
end
def all_children
case @object
when KnowledgeBase::Category
@object.self_with_children - [@object]
when KnowledgeBase
@object.categories.root.map(&:self_with_children).flatten
end
end
def update_single_child(child)
inherited_permissions = child.permissions_effective
child.permissions.each do |child_permission|
mark_permission_for_cleanup_if_needed(child_permission, inherited_permissions)
end
child.changed_for_autosave? ? child.save! : child.touch # rubocop:disable Rails/SkipsModelValidations
end
def update_all_children
all_children.each do |child|
update_single_child(child)
end
end
def ensure_editable!
return if !@user
return if KnowledgeBase::EffectivePermission.new(@user, @object).access_effective == 'editor'
raise Exceptions::UnprocessableEntity, __('Invalid permissions, do not lock out yourself')
end
def mark_permission_for_cleanup_if_needed(permission, parents)
matching = parents.find { |elem| elem.role == permission.role }
return if !matching
return if matching.access != permission.access && matching.access != 'none'
permission.mark_for_destruction
end
end
end

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
FactoryBot.define do
factory 'knowledge_base/permission', aliases: %i[knowledge_base_permission] do
permissionable { create(:knowledge_base_category) }
role { create(:role) }
access { 'editor' }
end
end

View file

@ -6,6 +6,12 @@ FactoryBot.define do
created_by_id { 1 } created_by_id { 1 }
updated_by_id { 1 } updated_by_id { 1 }
transient do
permission_names { nil }
end
permissions { Permission.where(name: permission_names) }
factory :agent_role do factory :agent_role do
permissions { Permission.where(name: 'ticket.agent') } permissions { Permission.where(name: 'ticket.agent') }
end end

View file

@ -0,0 +1,73 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::Category::Permission do
include_context 'basic Knowledge Base'
describe '#permissions_effective' do
let(:child_category) { create(:knowledge_base_category, knowledge_base: knowledge_base, parent: category) }
let(:child_permission) { create(:knowledge_base_permission, permissionable: child_category, access: 'reader') }
let(:parent_permission) { create(:knowledge_base_permission, permissionable: category) }
context 'when no permissions exist' do
it 'parent category returns nil' do
expect(described_class.new(category).permissions_effective).to be_blank
end
it 'child category returns nil' do
expect(described_class.new(child_category).permissions_effective).to be_blank
end
end
context 'when parent category has permissions' do
before { parent_permission && child_category }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [parent_permission]
end
end
context 'when child category has permissions' do
before { child_permission }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to be_blank
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission]
end
end
context 'when both parent and child categories have permissions' do
before { child_permission && parent_permission }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns parent permission' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission, parent_permission]
end
end
context 'when both parent child categories have colliding permissions for the same role' do
let(:child_permission_on_same_role) { create(:knowledge_base_permission, permissionable: child_category, access: 'reader', role: parent_permission.role) }
before { parent_permission && child_permission && child_permission_on_same_role }
it 'parent category returns parent permission' do
expect(described_class.new(category).permissions_effective).to eq [parent_permission]
end
it 'child category returns child permission override' do
expect(described_class.new(child_category).permissions_effective).to eq [child_permission, child_permission_on_same_role]
end
end
end
end

View file

@ -0,0 +1,91 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::EffectivePermission do
include_context 'basic Knowledge Base'
describe '#access_effective' do
let(:role_editor) { create(:role, permission_names: 'knowledge_base.editor') }
let(:role_reader) { create(:role, permission_names: 'knowledge_base.reader') }
let(:role_non_kb) { create(:role, :admin) }
let(:user) { create(:user, roles: [role_editor, role_reader, role_non_kb]) }
let(:user_editor) { create(:user, roles: [role_editor]) }
let(:user_admin) { create(:admin) }
let(:user_reader) { create(:user, roles: [role_reader]) }
let(:user_nonkb) { create(:user, roles: [role_non_kb]) }
let(:child_category) { create(:knowledge_base_category, parent: category) }
it 'editor with no permissions defined returns editor' do
expect(described_class.new(user_editor, category).access_effective).to eq 'editor'
end
it 'user with multiple permissions defined returns editor' do
expect(described_class.new(user, category).access_effective).to eq 'editor'
end
it 'reader with no permissions defined returns reader' do
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'non-kb with no permissions defined returns none' do
expect(described_class.new(user_nonkb, category).access_effective).to eq 'none'
end
it 'editor with both reader and editor permissions returns editor' do
create_permission(role_reader, 'reader')
create_permission(role_editor, 'editor')
expect(described_class.new(user_admin, category).access_effective).to eq 'editor'
end
it 'editor with reader permission on parent category returns reader' do
create_permission(role_editor, 'reader')
expect(described_class.new(user_editor, child_category).access_effective).to eq 'reader'
end
it 'editor with reader permission on KB returns reader' do
create_permission(role_editor, 'reader', permissionable: knowledge_base)
expect(described_class.new(user_editor, category).access_effective).to eq 'reader'
end
it 'editor with reader permission on parent category but editor permission on category returns editor' do
create_permission(role_editor, 'reader', permissionable: category)
create_permission(role_editor, 'editor', permissionable: child_category)
expect(described_class.new(user_editor, child_category).access_effective).to eq 'editor'
end
it 'editor with editor permission on parent category but reader permission on category returns reader' do
create_permission(role_editor, 'editor', permissionable: category)
create_permission(role_editor, 'reader', permissionable: child_category)
expect(described_class.new(user_editor, child_category).access_effective).to eq 'reader'
end
it 'reader with reader and non-effective permissions returns reader' do
create_permission(role_reader, 'reader')
create_permission(role_editor, 'editor')
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'reader with no matching permissions returns reader' do
create_permission(role_editor, 'editor')
expect(described_class.new(user_reader, category).access_effective).to eq 'reader'
end
it 'retuns none when user not given' do
expect(described_class.new(nil, category).access_effective).to eq 'none'
end
end
def create_permission(role, access, permissionable: category)
create(:knowledge_base_permission, role: role, permissionable: permissionable, access: access)
end
end

View file

@ -0,0 +1,87 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::InternalAssets do
include_context 'basic Knowledge Base' do
before do
draft_answer
internal_answer
published_answer
end
end
describe '#collect_assets' do
subject(:assets) { described_class.new(user).collect_assets }
context 'when for KB editor' do
let(:user) { create(:user, roles: Role.where(name: 'Admin')) }
it 'returns assets for all KB objects' do
expect(assets).to include_assets_of(knowledge_base, category, draft_answer, internal_answer, published_answer)
end
context 'when has editor permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'editor'
end
it 'returns assets for all KB objects' do
expect(assets).to include_assets_of(knowledge_base, category, draft_answer, internal_answer, published_answer)
end
end
context 'when has reader permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'reader'
end
it 'returns assets for internally visible KB objects' do
expect(assets)
.to include_assets_of(knowledge_base, category, internal_answer, published_answer)
.and not_include_assets_of(draft_answer)
end
end
context 'when has none permission' do
before do
KnowledgeBase::PermissionsUpdate.new(category).update! user.roles.first => 'none'
end
it 'does not return assets for internally visible KB objects' do
published_answer.destroy # make sure public item does not make category visible
expect(assets)
.to include_assets_of(knowledge_base)
.and not_include_assets_of(category, draft_answer, internal_answer, published_answer)
end
it 'returns assets for published answer and it\'s category' do
expect(assets)
.to include_assets_of(knowledge_base, category, published_answer)
.and not_include_assets_of(draft_answer, internal_answer)
end
end
end
context 'when for agent' do
let(:user) { create(:agent) }
it 'returns assets for all KB objects' do
expect(assets)
.to include_assets_of(knowledge_base, category, internal_answer, published_answer)
.and not_include_assets_of(draft_answer)
end
end
context 'when for customer' do
let(:user) { create(:customer) }
it 'returns assets for all KB objects' do
expect(assets)
.to include_assets_of(knowledge_base)
.and not_include_assets_of(category, draft_answer, internal_answer, published_answer)
end
end
end
end

View file

@ -0,0 +1,180 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe KnowledgeBase::PermissionsUpdate do
describe '#update!' do
include_context 'basic Knowledge Base'
let(:role_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_another) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:child_category) { create(:knowledge_base_category, parent: category) }
describe 'updating itself' do
shared_examples 'updating itself' do |object_name:|
let(:object) { send(object_name) }
it 'adds role permission for self' do
described_class.new(object).update! role_editor => 'editor'
expect(object.permissions)
.to contain_exactly have_attributes(permissionable: object, role: role_editor, access: 'editor')
end
it 'adds additional role permission for self' do
described_class.new(object).update! role_editor => 'reader'
described_class.new(object).update! role_editor => 'reader', role_another => 'reader'
expect(object.permissions)
.to contain_exactly(have_attributes(role: role_editor), have_attributes(role: role_another))
end
it 'does not update when re-adding an existing permission' do
described_class.new(object).update! role_editor => 'reader'
travel 1.minute
expect { described_class.new(object).update! role_editor => 'reader' }
.not_to change(object, :updated_at)
end
end
context 'when saving role on KB itself' do
include_context 'updating itself', object_name: :knowledge_base
end
context 'when saving role on KB category' do
include_context 'updating itself', object_name: :category
end
end
describe 'updating descendants' do
context 'when saving role on KB itself' do
it 'adds effective permissions to descendant categories' do
described_class.new(knowledge_base).update! role_editor => 'reader'
expect(category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'reader', permissionable: knowledge_base)
end
it 'removing permission opens up access to descendants' do
described_class.new(knowledge_base).update! role_editor => 'editor'
described_class.new(knowledge_base).update!(**{})
expect(category.permissions_effective).to be_blank
end
context 'when category has editor role has editor role with editor permission' do
before do
described_class.new(category).update! role_editor => 'editor'
category.reload
travel 1.minute
end
it 'removes identical permissions on descendant roles' do
described_class.new(knowledge_base).update! role_editor => 'editor'
category.reload
expect(category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
end
end
end
context 'when saving role on KB category' do
it 'adds effective permissions to descendant roles' do
described_class.new(category).update! role_editor => 'reader'
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'reader', permissionable: category)
end
context 'when child category has editor role with editor permission' do
before do
described_class.new(child_category).update! role_editor => 'editor'
category.reload
child_category.reload
travel 1.minute
end
it 'removes conflicting permissions on descendant roles' do
described_class.new(category).update! role_editor => 'none'
category.reload
child_category.reload
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'none', permissionable: category)
end
it 'removes identical permissions on descendant roles' do
described_class.new(category).update! role_editor => 'editor'
category.reload
child_category.reload
expect(child_category.permissions_effective)
.to contain_exactly have_attributes(role: role_editor, access: 'editor', permissionable: category)
end
end
context 'when category has role editor with none permission' do
before do
described_class.new(category).update! role_editor => 'none'
category.reload
travel 1.minute
end
it 'removing permission opens up access to descendants' do
described_class.new(category).update!(**{})
category.reload
expect(child_category.permissions_effective).to be_blank
end
end
end
end
describe 'preventing user lockout' do
let(:user) { create(:admin) }
let(:role) { user.roles.first }
shared_examples 'preventing user lockout' do |object_name:|
let(:object) { send(object_name) }
it 'raises an error when saving a lockout change for a given user' do
expect { described_class.new(object, user).update! role => 'reader' }
.to raise_error(Exceptions::UnprocessableEntity)
end
it 'allows to save same change without a user' do
expect { described_class.new(object).update! role => 'reader' }.not_to raise_error
end
end
context 'when saving role on KB itself' do
include_context 'preventing user lockout', object_name: 'knowledge_base'
end
context 'when saving role on KB category' do
include_context 'preventing user lockout', object_name: 'category'
end
end
end
describe '#update_using_params!' do
subject(:updater) { described_class.new(category) }
let(:role) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:category) { create(:knowledge_base_category) }
it 'calls update! with given roles' do
updater.update_using_params!({ permissions: { role.id => 'editor' } })
expect(category.permissions.first).to have_attributes(role: role, access: 'editor', permissionable: category)
end
it 'raises an error when given a non existant role' do
expect { updater.update_using_params!({ permissions: { (role.id + 1) => 'editor' } }) }
.to raise_error(ActiveRecord::RecordNotFound)
end
end
end

View file

@ -16,6 +16,7 @@ RSpec.describe KnowledgeBase::Category, type: :model, current_user_id: 1 do
it { is_expected.to have_many(:answers) } it { is_expected.to have_many(:answers) }
it { is_expected.to have_many(:children) } it { is_expected.to have_many(:children) }
it { is_expected.to have_many(:permissions) }
it { is_expected.to belong_to(:parent).optional } it { is_expected.to belong_to(:parent).optional }
it { is_expected.to belong_to(:knowledge_base) } it { is_expected.to belong_to(:knowledge_base) }

View file

@ -0,0 +1,50 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'models/contexts/factory_context'
RSpec.describe KnowledgeBase::Permission, type: :model do
subject(:kb_category_permission) { create(:knowledge_base_permission) }
include_context 'basic Knowledge Base'
include_context 'factory'
describe '#permissionable' do
it { is_expected.to belong_to(:permissionable).touch(true) }
it 'allows multiple permissions for the same category' do
permission = build(:knowledge_base_permission, permissionable: kb_category_permission.permissionable)
permission.save
expect(permission).to be_persisted
end
it 'does not allow same role/permission conbination' do
permission = build(:knowledge_base_permission,
permissionable: kb_category_permission.permissionable,
role: kb_category_permission.role)
permission.save
expect(permission).not_to be_persisted
end
end
describe '#role' do
it { is_expected.to belong_to(:role) }
it 'allows multiple permissions for the same category' do
permission = build(:knowledge_base_permission, role: kb_category_permission.role)
permission.save
expect(permission).to be_persisted
end
end
describe '#access' do
it { is_expected.to validate_presence_of(:access).with_message(%r{}) }
it { is_expected.to allow_value('editor').for(:access) }
it { is_expected.to allow_value('reader').for(:access) }
it { is_expected.to allow_value('none').for(:access) }
it { is_expected.not_to allow_value('foobar').for(:access) }
end
end

View file

@ -0,0 +1,64 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBase::AnswerPolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base_answer) }
let(:user) { create(:user) }
shared_context 'with answer visibility' do |visible:, visible_internally:|
before do
allow(record).to receive(:visible?).and_return(visible)
allow(record).to receive(:visible_internally?).and_return(visible_internally)
end
end
describe '#show?' do
context 'when visible and visible internally' do
include_examples 'with answer visibility', visible: true, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show?
end
context 'when visible internally only' do
include_examples 'with answer visibility', visible: false, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
context 'when not visible' do
include_examples 'with answer visibility', visible: false, visible_internally: false
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show?
end
end
describe '#show_public?' do
context 'when visible and visible internally' do
include_examples 'with answer visibility', visible: true, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show_public?
end
context 'when visible internally only' do
include_examples 'with answer visibility', visible: false, visible_internally: true
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
context 'when not visible' do
include_examples 'with answer visibility', visible: false, visible_internally: false
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
end
describe '#update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
describe '#create?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :create?
end
describe '#destroy?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :destroy?
end
end

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBase::CategoryPolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base_category) }
let(:user) { create(:user) }
describe '#show?' do
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
describe '#show_public?' do
context 'when category has public content' do
before { allow(record).to receive(:public_content?).and_return(true) }
include_examples 'with KB policy check', editor: true, reader: true, none: true, method: :show_public?
end
context 'when category has no public content' do
before { allow(record).to receive(:public_content?).and_return(false) }
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :show_public?
end
end
describe '#permissions?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :permissions?
end
describe '#update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
describe '#create?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :create?, access_method: :parent_access
end
describe '#destroy?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :destroy?, access_method: :parent_access
end
end

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
RSpec.shared_context 'with KB policy check' do |editor:, reader:, none:, method:, access_method: :access|
let(:access_method) { access_method }
it 'returns true if editor' do
mock_permission 'editor'
expect(policy.send(method)).to be editor
end
it 'returns true if reader' do
mock_permission 'reader'
expect(policy.send(method)).to be reader
end
it 'returns false if none' do
mock_permission 'none'
expect(policy.send(method)).to be none
end
def mock_permission(access)
allow(policy)
.to receive(access_method)
.and_return(access)
end
end

View file

@ -0,0 +1,19 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'policies/knowledge_base_policy_examples'
describe KnowledgeBasePolicy do
subject(:policy) { described_class.new(user, record) }
let(:record) { create(:knowledge_base) }
let(:user) { create(:user) }
describe '#show?' do
include_examples 'with KB policy check', editor: true, reader: true, none: false, method: :show?
end
describe 'update?' do
include_examples 'with KB policy check', editor: true, reader: false, none: false, method: :update?
end
end

View file

@ -54,7 +54,9 @@ RSpec::Matchers.define :include_assets_of do
# #
# @return [Hash, nil] # @return [Hash, nil]
def find_assets_of(object, actual) def find_assets_of(object, actual)
actual.dig(object.class.name.gsub(%r{::}, ''), object.id.to_s) actual
.deep_stringify_keys
.dig(object.class.name.gsub(%r{::}, ''), object.id.to_s)
end end
end end

View file

@ -96,18 +96,6 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
let(:new_tag_name) { 'capybara_kb_tag' } let(:new_tag_name) { 'capybara_kb_tag' }
it 'adds a new tag' do it 'adds a new tag' do
within :active_content do
click '.js-newTagLabel'
elem = find('.js-newTagInput')
elem.fill_in with: new_tag_name
elem.send_keys :return
expect(page).to have_css('a.js-tag', text: new_tag_name)
end
end
it 'saves new tag to the database' do
within :active_content do within :active_content do
click '.js-newTagLabel' click '.js-newTagLabel'
@ -116,6 +104,7 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
elem.send_keys :return elem.send_keys :return
wait.until_exists { published_answer_with_tag.reload.tag_list.include? new_tag_name } wait.until_exists { published_answer_with_tag.reload.tag_list.include? new_tag_name }
expect(page).to have_css('a.js-tag', text: new_tag_name)
end end
end end
@ -127,22 +116,10 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system do
it 'deletes a tag' do it 'deletes a tag' do
within :active_content do within :active_content do
click '.js-newTagLabel'
find('.list-item', text: published_answer_tag_name) find('.list-item', text: published_answer_tag_name)
.find('.js-delete').click .find('.js-delete').click
expect(page).to have_no_css('a.js-tag', text: published_answer_tag_name) expect(page).to have_no_css('a.js-tag', text: published_answer_tag_name)
end
end
it 'deletes the tag from the database' do
within :active_content do
click '.js-newTagLabel'
find('.list-item', text: published_answer_tag_name)
.find('.js-delete').click
wait.until_exists { published_answer_with_tag.reload.tag_list.exclude? published_answer_tag_name } wait.until_exists { published_answer_with_tag.reload.tag_list.exclude? published_answer_tag_name }
end end
end end

View file

@ -0,0 +1,177 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Knowledge Base Locale Category Permissions', type: :system do
include_context 'basic Knowledge Base'
let(:role_editor) { Role.find_by name: 'Admin' }
let(:role_another_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_reader) { Role.find_by name: 'Agent' }
let(:child_category) { create(:knowledge_base_category, parent: category) }
it 'shows roles with has KB permissions only' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_text(%r{Admin}i)
.and(have_text(%r{Agent}i))
.and(have_no_text(%r{Customer}i))
end
end
describe 'permissions shown' do
it 'shows existing permissions when category has no permissions' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_editor.id}'][value='editor'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_editor.id}'][value='reader']:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_editor.id}'][value='none']:not([disabled])", visible: :all))
end
end
it 'shows existing permissions when category has inerited permissions only' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
end
end
it 'shows existing permissions' do
KnowledgeBase::PermissionsUpdate.new(child_category).update! role_reader => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
end
end
it 'shows editor permission not limited by parent category being read only' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_another_editor => 'reader'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='none']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='reader'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_another_editor.id}'][value='editor'][disabled]", visible: :all))
end
end
it 'shows editor permissions limited by parent category' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_another_editor => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='none'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='reader'][disabled]", visible: :all))
.and(have_css("input[name='#{role_another_editor.id}'][value='editor'][disabled]", visible: :all))
end
end
it 'shows reader permissions limited by parent category' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page child_category
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='reader'][disabled]", visible: :all))
end
end
end
describe 'saving changes' do
it 'saves permissions' do
open_page category
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_reader.id}'][value='none']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(category.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'none', permissionable: category),
have_attributes(role: role_editor, access: 'editor', permissionable: category)
)
end
it 'allows to modify existing permissions' do
KnowledgeBase::PermissionsUpdate.new(category).update! role_reader => 'none'
open_page category
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_reader.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(category.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: category),
have_attributes(role: role_editor, access: 'editor', permissionable: category)
)
end
it 'does not allow to lock user himself' do
open_page category
find('[data-action=permissions]').click
in_modal disappears: false do
find("input[name='#{role_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
expect(page).to have_css('.alert')
end
end
end
def open_page(category)
visit "knowledge_base/#{knowledge_base.id}/locale/#{Locale.first.locale}/category/#{category.id}/edit"
end
end

View file

@ -0,0 +1,118 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Knowledge Base Locale Knowledge Base Permissions', type: :system do
include_context 'basic Knowledge Base'
let(:role_editor) { Role.find_by name: 'Admin' }
let(:role_another_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_reader) { Role.find_by name: 'Agent' }
it 'shows roles with has KB permissions only' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_text(%r{Admin}i)
.and(have_text(%r{Agent}i))
.and(have_no_text(%r{Customer}i))
end
end
describe 'permissions shown' do
it 'shows existing permissions when KB has no permissions' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_editor.id}'][value='editor'][checked]", visible: :all)
.and(have_css("input[name='#{role_editor.id}'][value='reader']", visible: :all))
end
end
it 'shows existing permissions' do
KnowledgeBase::PermissionsUpdate.new(knowledge_base).update! role_another_editor => 'reader'
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='reader'][checked]", visible: :all)
.and(have_css("input[name='#{role_another_editor.id}'][value='editor']", visible: :all))
end
end
end
describe 'saving changes' do
it 'saves permissions' do
role_another_editor
open_page
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_another_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(knowledge_base.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_another_editor, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
)
end
it 'allows to modify existing permissions' do
KnowledgeBase::PermissionsUpdate.new(knowledge_base).update! role_another_editor => 'reader'
open_page
find('[data-action=permissions]').click
in_modal do
find("input[name='#{role_another_editor.id}'][value='editor']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
end
expect(knowledge_base.reload.permissions)
.to contain_exactly(
have_attributes(role: role_reader, access: 'reader', permissionable: knowledge_base),
have_attributes(role: role_another_editor, access: 'editor', permissionable: knowledge_base),
have_attributes(role: role_editor, access: 'editor', permissionable: knowledge_base)
)
end
it 'does not allow to lock user himself' do
open_page
find('[data-action=permissions]').click
in_modal disappears: false do
find("input[name='#{role_editor.id}'][value='reader']", visible: :all)
.ancestor('label')
.click
click_on 'Submit'
expect(page).to have_css('.alert')
end
end
end
def open_page
visit "knowledge_base/#{knowledge_base.id}/locale/#{Locale.first.locale}/edit"
end
end

View file

@ -939,7 +939,6 @@ RSpec.describe 'Ticket zoom', type: :system do
def forward def forward
within :active_content do within :active_content do
# binding.pry
wait.until_exists { find('.textBubble-content .richtext-content') } wait.until_exists { find('.textBubble-content .richtext-content') }
click '.js-ArticleAction[data-type=emailForward]' click '.js-ArticleAction[data-type=emailForward]'
fill_in 'To', with: 'customer@example.com' fill_in 'To', with: 'customer@example.com'