Fixes #3997 - KB: granular permissions misconfiguration is allowed by UI

This commit is contained in:
Mantas 2022-03-05 00:51:19 +02:00 committed by Mantas Masalskis
parent 20015da4d3
commit 4a71dc4eeb
17 changed files with 157 additions and 28 deletions

View file

@ -358,10 +358,10 @@ class App.KnowledgeBaseAgentController extends App.Controller
parentController: @ parentController: @
) )
access: (params) -> access: (params) =>
@constructor @constructor
.pickObjectUsing(params, @) .pickObjectUsing(params, @)
?.access() ?.access(@kb_locale())
isEditor: -> isEditor: ->
@access(@lastParams) == 'editor' @access(@lastParams) == 'editor'

View file

@ -54,6 +54,19 @@ class App.KnowledgeBasePermissionsDialog extends App.ControllerModal
elem.accessLevel = role_name elem.accessLevel = role_name
elem.limit = _.findWhere(data.inherited, { role_id: elem.id })?.access elem.limit = _.findWhere(data.inherited, { role_id: elem.id })?.access
if elem.limit?
elem.accessLevelIsDisabled = {
editor: elem.limit != 'editor'
reader: elem.limit == 'none'
none: false
}
else
elem.accessLevelIsDisabled = {
editor: elem.accessLevel != 'editor'
reader: false
none: false
}
load: => load: =>
@ajax( @ajax(
id: 'knowledge_base_permissions_get' id: 'knowledge_base_permissions_get'
@ -68,7 +81,7 @@ class App.KnowledgeBasePermissionsDialog extends App.ControllerModal
) )
toggleDisabled: (state) => toggleDisabled: (state) =>
@el.find('input, button').attr('disabled', state) @el.find('input:not([data-permanently-disabled]), button').attr('disabled', state)
onSubmit: (e) => onSubmit: (e) =>
@clearAlerts() @clearAlerts()

View file

@ -1,5 +1,5 @@
InstanceMethods = InstanceMethods =
access: -> access: (kb_locale) ->
permission_reader = App.Permission.findByAttribute('name', 'knowledge_base.reader') permission_reader = App.Permission.findByAttribute('name', 'knowledge_base.reader')
permission_editor = App.Permission.findByAttribute('name', 'knowledge_base.editor') permission_editor = App.Permission.findByAttribute('name', 'knowledge_base.editor')
@ -20,6 +20,8 @@ InstanceMethods =
return 'editor' return 'editor'
when 'reader' when 'reader'
access = 'reader' access = 'reader'
when 'none'
access = 'reader' if kb_locale && @visiblePublicly(kb_locale)
else if role = App.Role.find(role_id) else if role = App.Role.find(role_id)
if role.permission_ids.indexOf(permission_editor.id) > -1 if role.permission_ids.indexOf(permission_editor.id) > -1
return 'editor' return 'editor'

View file

@ -77,7 +77,7 @@ class App.KnowledgeBase extends App.Model
, initial , initial
visibleInternally: (kb_locale) -> visibleInternally: (kb_locale) ->
@active && @access() != 'none' @active && @access(kb_locale) != 'none'
visiblePublicly: (kb_locale) -> visiblePublicly: (kb_locale) ->
@active @active

View file

@ -96,4 +96,7 @@ class App.KnowledgeBaseAnswer extends App.Model
'Answer' 'Answer'
visibleInternally: (kb_locale) => visibleInternally: (kb_locale) =>
(@is_internally_published(kb_locale) && @access() != 'none') || @is_published(kb_locale) (@is_internally_published(kb_locale) && @access(kb_locale) != 'none') || @is_published(kb_locale)
visiblePublicly: (kb_locale) =>
@is_published(kb_locale)

View file

@ -167,8 +167,6 @@ 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

@ -18,9 +18,9 @@
value="<%= key %>" value="<%= key %>"
name="<%= role.id %>" name="<%= role.id %>"
<% if @params[role.id] == key: %>checked<% end %> <% if @params[role.id] == key: %>checked<% end %>
<% if role.limit?: %> <% if role.accessLevelIsDisabled[key]: %>
<% if key == 'editor' && role.limit != 'editor': %>disabled<% end %> disabled
<% if key == 'reader' && role.limit == 'none': %>disabled<% end %> data-permanently-disabled
<% end %> <% end %>
/> />
<%- @Icon('radio', 'icon-unchecked') %> <%- @Icon('radio', 'icon-unchecked') %>

View file

@ -84,8 +84,10 @@ module ApplicationController::HandlesErrors
error: e.message error: e.message
} }
if (message = e.try(:record)&.errors&.full_messages&.first) if (base_error = e.try(:record)&.errors&.messages&.find { |key, _| key.match? %r{[\w+.]?base} }&.last&.last)
data[:error_human] = message data[:error_human] = base_error
elsif (first_error = e.try(:record)&.errors&.full_messages&.first)
data[:error_human] = first_error
elsif e.message.match?(%r{(already exists|duplicate key|duplicate entry)}i) elsif e.message.match?(%r{(already exists|duplicate key|duplicate entry)}i)
data[:error_human] = __('Object already exists!') data[:error_human] = __('Object already exists!')
elsif e.message =~ %r{null value in column "(.+?)" violates not-null constraint}i || e.message =~ %r{Field '(.+?)' doesn't have a default value}i elsif e.message =~ %r{null value in column "(.+?)" violates not-null constraint}i || e.message =~ %r{Field '(.+?)' doesn't have a default value}i

View file

@ -179,6 +179,14 @@ class KnowledgeBase < ApplicationModel
KnowledgeBase::Permission.any? KnowledgeBase::Permission.any?
end end
def public_content?(kb_locale = nil)
scope = answers.published
scope = scope.localed(kb_locale.system_locale) if kb_locale
scope.any?
end
private private
def set_defaults def set_defaults

View file

@ -4,13 +4,33 @@ class KnowledgeBase::Permission < ApplicationModel
belongs_to :permissionable, polymorphic: true, touch: true belongs_to :permissionable, polymorphic: true, touch: true
belongs_to :role belongs_to :role
validates :access, inclusion: { in: %w[editor reader none] }
validates :role, uniqueness: { scope: %i[permissionable_id permissionable_type] } validates :role, uniqueness: { scope: %i[permissionable_id permissionable_type] }
validate :ensure_access_matches_role
# cache key for calculated permissions # cache key for calculated permissions
# @param permissionable [KnowledgeBase::Category, KnowledgeBase] # @param permissionable [KnowledgeBase::Category, KnowledgeBase]
# @return [String] # @return [String]
def self.cache_key(permissionable) def self.cache_key(permissionable)
"#{permissionable.class}::aws::#{permissionable.id}::permission::#{permissionable.updated_at}" "#{permissionable.class}::aws::#{permissionable.id}::permission::#{permissionable.updated_at}"
end end
private
def ensure_access_matches_role
return if role.blank?
return if allowed_access.include? access
errors.add :base, __('This permission level is not available based on the current roles permissions.')
end
def allowed_access
if role.with_permission? 'knowledge_base.editor'
%w[editor reader none]
elsif role.with_permission? 'knowledge_base.reader'
%w[reader none]
else
[]
end
end
end end

View file

@ -9132,6 +9132,10 @@ msgstr ""
msgid "This page is invisible to the public." msgid "This page is invisible to the public."
msgstr "" msgstr ""
#: app/models/knowledge_base/permission.rb
msgid "This permission level is not available based on the current roles permissions."
msgstr ""
#: app/assets/javascripts/app/controllers/_integration/github.coffee #: app/assets/javascripts/app/controllers/_integration/github.coffee
#: app/assets/javascripts/app/controllers/_integration/gitlab.coffee #: app/assets/javascripts/app/controllers/_integration/gitlab.coffee
#: app/assets/javascripts/app/controllers/_integration/idoit.coffee #: app/assets/javascripts/app/controllers/_integration/idoit.coffee

View file

@ -13,14 +13,25 @@ class KnowledgeBase
@user.roles.reduce('none') do |memo, role| @user.roles.reduce('none') do |memo, role|
access = access_role_effective(role) access = access_role_effective(role)
return access if access == 'editor' return 'editor' if access == 'editor'
memo == 'reader' ? memo : access access_role_reducer(memo, access)
end end
end end
private private
def access_role_reducer(memo, access)
case access
when 'reader'
'reader'
when 'public_reader'
memo == 'reader' ? memo : access
when 'none'
memo
end
end
def permissions def permissions
@permissions ||= @object.permissions_effective @permissions ||= @object.permissions_effective
end end

View file

@ -3,7 +3,7 @@
FactoryBot.define do FactoryBot.define do
factory 'knowledge_base/permission', aliases: %i[knowledge_base_permission] do factory 'knowledge_base/permission', aliases: %i[knowledge_base_permission] do
permissionable { create(:knowledge_base_category) } permissionable { create(:knowledge_base_category) }
role { create(:role) } role { create(:role, permission_names: 'knowledge_base.editor') }
access { 'editor' } access { 'editor' }
end end
end end

View file

@ -5,8 +5,10 @@ require 'rails_helper'
RSpec.describe KnowledgeBase::PermissionsUpdate do RSpec.describe KnowledgeBase::PermissionsUpdate do
describe '#update!' do describe '#update!' do
include_context 'basic Knowledge Base' include_context 'basic Knowledge Base'
let(:role_editor) { create(:role, permission_names: %w[knowledge_base.editor]) } let(:role_editor) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_another) { create(:role, permission_names: %w[knowledge_base.editor]) } let(:role_another) { create(:role, permission_names: %w[knowledge_base.editor]) }
let(:role_reader) { create(:role, permission_names: %w[knowledge_base.reader]) }
let(:child_category) { create(:knowledge_base_category, parent: category) } let(:child_category) { create(:knowledge_base_category, parent: category) }
describe 'updating itself' do describe 'updating itself' do
@ -35,6 +37,11 @@ RSpec.describe KnowledgeBase::PermissionsUpdate do
expect { described_class.new(object).update! role_editor => 'reader' } expect { described_class.new(object).update! role_editor => 'reader' }
.not_to change(object, :updated_at) .not_to change(object, :updated_at)
end end
it 'throws error when role does not allow given access' do
expect { described_class.new(object).update! role_reader => 'editor' }
.to raise_error(%r{Validation failed})
end
end end
context 'when saving role on KB itself' do context 'when saving role on KB itself' do

View file

@ -41,10 +41,44 @@ RSpec.describe KnowledgeBase::Permission, type: :model do
end end
describe '#access' do describe '#access' do
it { is_expected.to validate_presence_of(:access).with_message(%r{}) } it { is_expected.not_to allow_access_value(nil) }
it { is_expected.to allow_value('editor').for(:access) } it { is_expected.not_to allow_access_value('foobar') }
it { is_expected.to allow_value('reader').for(:access) }
it { is_expected.to allow_value('none').for(:access) } context 'when role is editor' do
it { is_expected.not_to allow_value('foobar').for(:access) } it { is_expected.to allow_access_value('editor') }
it { is_expected.to allow_access_value('reader') }
it { is_expected.to allow_access_value('none') }
end
context 'when role is reader' do
subject(:kb_category_permission) { build(:knowledge_base_permission, role: create(:role, permission_names: 'knowledge_base.reader')) }
it { is_expected.not_to allow_access_value('editor') }
it { is_expected.to allow_access_value('reader') }
it { is_expected.to allow_access_value('none') }
end
context 'when role has no KB access' do
subject(:kb_category_permission) { build(:knowledge_base_permission, role: create(:role)) }
it { is_expected.not_to allow_access_value('editor') }
it { is_expected.not_to allow_access_value('reader') }
it { is_expected.not_to allow_access_value('none') }
end
end
matcher :allow_access_value do
match do
actual.access = expected
actual.valid?
end
failure_message do
"Expected to allow #{expected} as access, but was not allowed"
end
failure_message_when_negated do
"Expected to not allow #{expected} as access, but was allowed"
end
end end
end end

View file

@ -43,6 +43,7 @@ RSpec.describe 'Knowledge Base Locale Category Permissions', type: :system do
expect(page) expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all) .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)) .and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_reader.id}'][value='editor'][disabled]", visible: :all))
end end
end end
@ -55,6 +56,7 @@ RSpec.describe 'Knowledge Base Locale Category Permissions', type: :system do
expect(page) expect(page)
.to have_css("input[name='#{role_reader.id}'][value='reader']:not([disabled])", visible: :all) .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)) .and(have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_reader.id}'][value='editor'][disabled]", visible: :all))
end end
end end
@ -93,6 +95,18 @@ RSpec.describe 'Knowledge Base Locale Category Permissions', type: :system do
expect(page) expect(page)
.to have_css("input[name='#{role_reader.id}'][value='none'][checked]:not([disabled])", visible: :all) .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)) .and(have_css("input[name='#{role_reader.id}'][value='reader'][disabled]", visible: :all))
.and(have_css("input[name='#{role_reader.id}'][value='editor'][disabled]", visible: :all))
end
end
it 'shows reader permissions limited by role itself' do
open_page child_category
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='none']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='reader'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_reader.id}'][value='editor'][disabled]", visible: :all))
end end
end end
end end

View file

@ -26,8 +26,9 @@ RSpec.describe 'Knowledge Base Locale Knowledge Base Permissions', type: :system
in_modal disappears: false do in_modal disappears: false do
expect(page) expect(page)
.to have_css("input[name='#{role_editor.id}'][value='editor'][checked]", visible: :all) .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']", 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
end end
@ -38,8 +39,20 @@ RSpec.describe 'Knowledge Base Locale Knowledge Base Permissions', type: :system
in_modal disappears: false do in_modal disappears: false do
expect(page) expect(page)
.to have_css("input[name='#{role_another_editor.id}'][value='reader'][checked]", visible: :all) .to 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']", visible: :all)) .and(have_css("input[name='#{role_another_editor.id}'][value='editor']:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_another_editor.id}'][value='none']:not([disabled])", visible: :all))
end
end
it 'shows reader permissions limited by role itself' do
open_page
in_modal disappears: false do
expect(page)
.to have_css("input[name='#{role_reader.id}'][value='none']:not([disabled])", visible: :all)
.and(have_css("input[name='#{role_reader.id}'][value='reader'][checked]:not([disabled])", visible: :all))
.and(have_css("input[name='#{role_reader.id}'][value='editor'][disabled]", visible: :all))
end end
end end
end end