diff --git a/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue b/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue index de76154c06a3fd66fd3134bb773e36e22c9fb25c..ed5271d0b566ba15202f0999f945a67f129cb784 100644 --- a/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue +++ b/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue @@ -9,7 +9,7 @@ import { GlFormSelect, GlFormTextarea, } from '@gitlab/ui'; - +import { difference, pull } from 'lodash'; import { createAlert } from '~/alert'; import { sprintf, s__ } from '~/locale'; import { ACCESS_LEVEL_GUEST_INTEGER, ACCESS_LEVEL_LABELS } from '~/access_level/constants'; @@ -68,8 +68,59 @@ export default { refetchMemberRolesQuery() { return this.groupFullPath ? groupMemberRolesQuery : instanceMemberRolesQuery; }, + parentPermissionsLookup() { + return this.availablePermissions.reduce((acc, { value, requirements }) => { + if (requirements) { + acc[value] = requirements; + } + + return acc; + }, {}); + }, + childPermissionsLookup() { + return this.availablePermissions.reduce((acc, { value, requirements }) => { + requirements?.forEach((requirement) => { + // Create the array if it doesn't exist, then add the requirement to it. + acc[requirement] = acc[requirement] || []; + acc[requirement].push(value); + }); + + return acc; + }, {}); + }, + }, + watch: { + permissions(newPermissions, oldPermissions) { + const added = difference(newPermissions, oldPermissions); + const removed = difference(oldPermissions, newPermissions); + + added.forEach((permission) => this.selectParentPermissions(permission)); + removed.forEach((permission) => this.deselectChildPermissions(permission)); + }, }, methods: { + selectParentPermissions(permission) { + const parentPermissions = this.parentPermissionsLookup[permission]; + + parentPermissions?.forEach((parentPermission) => { + // Only select the parent permission if it's not already selected. + if (!this.permissions.includes(parentPermission)) { + this.permissions.push(parentPermission); + this.selectParentPermissions(parentPermission); + } + }); + }, + deselectChildPermissions(permission) { + const childPermissions = this.childPermissionsLookup[permission]; + + childPermissions?.forEach((childPermission) => { + // Only remove the child permission if it's selected. + if (this.permissions.includes(childPermission)) { + pull(this.permissions, childPermission); + this.deselectChildPermissions(childPermission); + } + }); + }, validateFields() { this.baseRoleValid = this.baseRole !== null; this.nameValid = Boolean(this.name); diff --git a/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role_permissions.query.graphql b/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role_permissions.query.graphql index 19267c015560dff1d2bd21e5c9329552e6a5c8cb..72d39f9ae22cc95ef6f32b74ab626fed9c096585 100644 --- a/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role_permissions.query.graphql +++ b/ee/app/assets/javascripts/roles_and_permissions/graphql/member_role_permissions.query.graphql @@ -4,6 +4,7 @@ query memberRolePermissions { description name value + requirements } } } diff --git a/ee/spec/features/groups/member_roles_spec.rb b/ee/spec/features/groups/member_roles_spec.rb index 406d95608529f8a5233a20e1380eedc750d7f816..d8b72feb66286a34e1fc7d35b2030b4994135742 100644 --- a/ee/spec/features/groups/member_roles_spec.rb +++ b/ee/spec/features/groups/member_roles_spec.rb @@ -66,35 +66,19 @@ def created_role(name, id, access_level, permissions) let(:requirement) { permissions[permission][:requirements].first } let(:requirement_name) { requirement.to_s.humanize } - context 'when the requirement has not been met' do - it 'show an error message' do - create_role(access_level, name, [permission_name]) - - created_member_role = MemberRole.find_by( - name: name, - base_access_level: Gitlab::Access.options[access_level], - permission => true) - - expect(created_member_role).to be_nil - expect(page).to have_content("#{requirement_name} has to be enabled in order to enable #{permission_name}") - end - end - - context 'when the requirement has been met' do - it 'creates the custom role' do - create_role(access_level, name, [permission_name, requirement_name]) + it 'creates the custom role' do + create_role(access_level, name, [permission_name]) - created_member_role = MemberRole.find_by( - name: name, - base_access_level: Gitlab::Access.options[access_level], - permission => true, - requirement => true) + created_member_role = MemberRole.find_by( + name: name, + base_access_level: Gitlab::Access.options[access_level], + permission => true, + requirement => true) - expect(created_member_role).not_to be_nil + expect(created_member_role).not_to be_nil - role = created_role(name, created_member_role.id, access_level, [permission_name, requirement_name]) - expect(page).to have_content(role) - end + role = created_role(name, created_member_role.id, access_level, [permission_name, requirement_name]) + expect(page).to have_content(role) end end end diff --git a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js index c7531f7049cc32eadc02d6a67aecdf2c65bb9987..8108e07380aeef88115899ab58c3bb70bbc91427 100644 --- a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js +++ b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js @@ -1,4 +1,10 @@ -import { GlFormInput, GlFormSelect, GlFormTextarea, GlFormCheckbox } from '@gitlab/ui'; +import { + GlFormInput, + GlFormSelect, + GlFormTextarea, + GlFormCheckbox, + GlFormCheckboxGroup, +} from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { createAlert, VARIANT_DANGER } from '~/alert'; @@ -256,4 +262,72 @@ describe('CreateMemberRole', () => { expect(mockAlertDismiss).toHaveBeenCalledTimes(1); }); }); + + describe('dependent permissions', () => { + const availablePermissions = [ + { value: 'A' }, + { value: 'B', requirements: ['A'] }, + { value: 'C', requirements: ['B'] }, // Nested dependency: C -> B -> A + { value: 'D', requirements: ['C'] }, // Nested dependency: D -> C -> B -> A + { value: 'E', requirements: ['F'] }, // Circular dependency + { value: 'F', requirements: ['E'] }, // Circular dependency + { value: 'G', requirements: ['A', 'B', 'C'] }, // Multiple dependencies + ]; + + const checkPermissions = (permissions) => { + wrapper.findComponent(GlFormCheckboxGroup).vm.$emit('input', permissions); + }; + + const expectCheckedPermissions = (expected) => { + const selectedValues = wrapper + .findComponent(GlFormCheckboxGroup) + .attributes('checked') + .split(',') + .sort(); + + expect(selectedValues).toEqual(expected.sort()); + }; + + beforeEach(() => { + createComponent({ availablePermissions, stubs: { GlFormCheckboxGroup: true } }); + }); + + it.each` + permission | expected + ${'A'} | ${['A']} + ${'B'} | ${['A', 'B']} + ${'C'} | ${['A', 'B', 'C']} + ${'D'} | ${['A', 'B', 'C', 'D']} + ${'E'} | ${['E', 'F']} + ${'F'} | ${['E', 'F']} + ${'G'} | ${['A', 'B', 'C', 'G']} + `('selects $expected when $permission is selected', async ({ permission, expected }) => { + await checkPermissions([permission]); + + expectCheckedPermissions(expected); + }); + + it.each` + permission | expected + ${'A'} | ${['E', 'F']} + ${'B'} | ${['A', 'E', 'F']} + ${'C'} | ${['A', 'B', 'E', 'F']} + ${'D'} | ${['A', 'B', 'C', 'E', 'F', 'G']} + ${'E'} | ${['A', 'B', 'C', 'D', 'G']} + ${'F'} | ${['A', 'B', 'C', 'D', 'G']} + ${'G'} | ${['A', 'B', 'C', 'D', 'E', 'F']} + `( + 'selects $expected when all permissions are selected and $permission is unselected', + async ({ permission, expected }) => { + const allPermissions = availablePermissions.map((p) => p.value); + const selectedPermissions = allPermissions.filter((v) => v !== permission); + // Start by checking all the permissions. + await checkPermissions(allPermissions); + // Uncheck the permission by removing it from all permissions. + await checkPermissions(selectedPermissions); + + expectCheckedPermissions(expected); + }, + ); + }); }); diff --git a/ee/spec/frontend/roles_and_permissions/mock_data.js b/ee/spec/frontend/roles_and_permissions/mock_data.js index 8dd553a0dddff3fe9ca619d33471a1194837381c..ba1eb8d2b9ffd5f88efc8c331cf13efa815bf2c8 100644 --- a/ee/spec/frontend/roles_and_permissions/mock_data.js +++ b/ee/spec/frontend/roles_and_permissions/mock_data.js @@ -1,7 +1,22 @@ export const mockDefaultPermissions = [ - { name: 'Permission A', description: 'Description A', value: 'READ_CODE' }, - { name: 'Permission B', description: 'Description B', value: 'READ_VULNERABILITY' }, - { name: 'Permission C', description: 'Description C', value: 'ADMIN_VULNERABILITY' }, + { + name: 'Permission A', + description: 'Description A', + value: 'READ_CODE', + requirements: null, + }, + { + name: 'Permission B', + description: 'Description B', + value: 'READ_VULNERABILITY', + requirements: null, + }, + { + name: 'Permission C', + description: 'Description C', + value: 'ADMIN_VULNERABILITY', + requirements: null, + }, ]; export const mockPermissions = {