diff --git a/addons/api/addon/generated/models/role.js b/addons/api/addon/generated/models/role.js index fa8d3caf4..62ef5daaf 100644 --- a/addons/api/addon/generated/models/role.js +++ b/addons/api/addon/generated/models/role.js @@ -23,6 +23,7 @@ export default class GeneratedRoleModel extends BaseModel { description; @attr('array', { + emptyArrayIfMissing: true, description: "The scopes to which this role applies. Defaults to the role's scope.", }) diff --git a/addons/api/tests/unit/serializers/role-test.js b/addons/api/tests/unit/serializers/role-test.js index 20fe41c28..fe77d8b54 100644 --- a/addons/api/tests/unit/serializers/role-test.js +++ b/addons/api/tests/unit/serializers/role-test.js @@ -97,6 +97,8 @@ module('Unit | Serializer | role', function (hooks) { id: '1', name: 'Role 1', grant_strings: ['*', '*'], + grant_scope_ids: ['*', '*'], + principals: ['*'], }; const normalized = serializer.normalizeSingleResponse( store, @@ -112,14 +114,15 @@ module('Unit | Serializer | role', function (hooks) { authorized_actions: [], name: 'Role 1', grant_strings: ['*', '*'], - principals: [], + grant_scope_ids: ['*', '*'], + principals: ['*'], }, relationships: {}, }, }); }); - test('it normalizes missing principals to empty array', function (assert) { + test('it normalizes missing arrays to empty array', function (assert) { const store = this.owner.lookup('service:store'); const serializer = store.serializerFor('role'); const role = store.createRecord('role').constructor; @@ -140,6 +143,7 @@ module('Unit | Serializer | role', function (hooks) { scope: { id: 'o_123', scope_id: 'o_123' }, principals: [], grant_strings: [], + grant_scope_ids: [], }, relationships: {}, }, diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index c34d84430..68d20e91a 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -571,9 +571,16 @@ role: parent_scope: Parent scope resource_type: Resource type messages: - title: No scopes added - description: You haven’t added any scopes to this role’s grant sets. - action: Add scopes to this role + none: + title: No scopes added + description: You haven't added any scopes to this role's grant sets. + action: Add scopes to this role + manage-scopes: + title: Manage Scopes + description: Select which scopes you want to add to this role. + keywords-selected: + description: Just a heads up, you can't select both add all descendants and add all children. You can only choose one or the other. + link: You can read more about it here keywords: this: sub-title: This scope @@ -583,6 +590,23 @@ role: descendants: title: All Descendants sub-title: Add all of the orgs and the projects in the orgs that are underneath this scope + actions: + manage-scopes: Manage Scopes + add-custom-scopes: + text: Add custom scopes + help: Customize which scopes are added to this role. + form: + this: + label: Add this scope + help: Add this scope. + children: + label: Add all children + help: + 0: Add all of the orgs underneath this scope. + 1: Add all of the projects underneath this org. + descendants: + label: Add all descendants + help: Add all of the orgs and the projects in the orgs that are underneath this scope. target: title: Target title_plural: Targets diff --git a/ui/admin/app/abilities/role.js b/ui/admin/app/abilities/role.js new file mode 100644 index 000000000..14befbb13 --- /dev/null +++ b/ui/admin/app/abilities/role.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import RoleAbility from 'api/abilities/role'; + +export default class OverrideRoleAbility extends RoleAbility { + /** + * This override ensures that a user can only set grant scopes + * on global and org level roles. + * @type {boolean} + */ + get canSetGrantScopes() { + return !this.model.scope.isProject && super.canSetGrantScopes; + } +} diff --git a/ui/admin/app/components/form/role/manage-scopes/index.hbs b/ui/admin/app/components/form/role/manage-scopes/index.hbs new file mode 100644 index 000000000..e49f03980 --- /dev/null +++ b/ui/admin/app/components/form/role/manage-scopes/index.hbs @@ -0,0 +1,83 @@ + + + + {{t 'resources.role.scope.form.this.label'}} + {{t 'resources.role.scope.form.this.help'}} + + + + {{t 'resources.role.scope.form.children.label'}} + {{if + @model.scope.isGlobal + (t 'resources.role.scope.form.children.help.0') + (t 'resources.role.scope.form.children.help.1') + }} + + + {{#if @model.scope.isGlobal}} + + {{t 'resources.role.scope.form.descendants.label'}} + {{t + 'resources.role.scope.form.descendants.help' + }} + + {{/if}} + + {{! TODO: Add correct href value inline link}} + {{#if this.showAlert}} + + + {{t 'resources.role.scope.messages.keywords-selected.description'}} + + {{t 'resources.role.scope.messages.keywords-selected.link'}} + + + + {{/if}} + + {{! TODO: Add correct route string once route is created}} + + + + {{t 'resources.role.scope.actions.add-custom-scopes.help'}} + + + + \ No newline at end of file diff --git a/ui/admin/app/components/form/role/manage-scopes/index.js b/ui/admin/app/components/form/role/manage-scopes/index.js new file mode 100644 index 000000000..4f4079f5a --- /dev/null +++ b/ui/admin/app/components/form/role/manage-scopes/index.js @@ -0,0 +1,58 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; + +export default class FormRoleManageScopesIndexComponent extends Component { + // =attributes + + keywords = { + keyThis: GRANT_SCOPE_THIS, + keyChildren: GRANT_SCOPE_CHILDREN, + keyDescendants: GRANT_SCOPE_DESCENDANTS, + }; + + @tracked selectedItems = [...this.args.model.grant_scope_ids]; + + /** + * Returns true if role is global level and either + * "children" or "descendants" is toggled on. + * @type {boolean} + */ + get showAlert() { + return ( + this.args.model.scope.isGlobal && + (this.selectedItems.includes(GRANT_SCOPE_CHILDREN) || + this.selectedItems.includes(GRANT_SCOPE_DESCENDANTS)) + ); + } + + // =actions + + /** + * Handles toggle event changes for selectedGrantScopeIDs + * @param {object} event + */ + @action + toggleField(event) { + const { checked, value } = event.target; + const removeValue = (value) => { + this.selectedItems = this.selectedItems.filter((item) => item !== value); + }; + if (checked) { + this.selectedItems = [...this.selectedItems, value]; + if (value === GRANT_SCOPE_CHILDREN) { + removeValue(GRANT_SCOPE_DESCENDANTS); + } + if (value === GRANT_SCOPE_DESCENDANTS) { + removeValue(GRANT_SCOPE_CHILDREN); + } + } else { + removeValue(value); + } + } +} diff --git a/ui/admin/app/components/roles/role/actions/index.hbs b/ui/admin/app/components/roles/role/actions/index.hbs index 91ae52242..bdd40e5f5 100644 --- a/ui/admin/app/components/roles/role/actions/index.hbs +++ b/ui/admin/app/components/roles/role/actions/index.hbs @@ -3,16 +3,30 @@ SPDX-License-Identifier: BUSL-1.1 }} +{{!TODO Refactor page to use hds dropdown in ICU-13879}} {{#if (can 'addPrincipals role' @model)}} - + {{t 'resources.role.principal.actions.add-principals'}} {{/if}} + + {{#if (can 'setGrantScopes role' @model)}} + + {{t 'resources.role.scope.actions.manage-scopes'}} + + {{/if}} + {{#if (can 'delete model' @model)}} message, { catch: true }) + @notifySuccess('notifications.add-success') + async setGrantScopes(role, grantScopeIDs) { + await role.setGrantScopes(grantScopeIDs); + this.router.replaceWith('scopes.scope.roles.role.scopes'); + } + + /** + * Redirect to role scopes as if nothing ever happened. + */ + @action + async cancel() { + await this.router.replaceWith('scopes.scope.roles.role.scopes'); + } +} diff --git a/ui/admin/app/router.js b/ui/admin/app/router.js index 1e72fd4a5..a46f8ddd2 100644 --- a/ui/admin/app/router.js +++ b/ui/admin/app/router.js @@ -53,6 +53,7 @@ Router.map(function () { this.route('principals'); this.route('add-principals'); this.route('scopes'); + this.route('manage-scopes'); }); this.route('new'); }); diff --git a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes.js new file mode 100644 index 000000000..7ec9a1ab8 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes.js @@ -0,0 +1,9 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeRolesRoleManageScopesRoute extends Route { + // =methods + + model() { + return this.modelFor('scopes.scope.roles.role'); + } +} diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index f1aaa6622..1a007cc41 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -1043,3 +1043,14 @@ margin-top: 3rem; } } + +.role-manage-scopes-form { + .add-custom-scopes-helper-text { + margin-top: 0.5rem; + margin-bottom: 2rem; + } + + .hds-alert { + margin-bottom: 2rem; + } +} diff --git a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes.hbs new file mode 100644 index 000000000..1bb84d12d --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes.hbs @@ -0,0 +1,34 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{page-title (t 'resources.role.scope.actions.manage-scopes')}} + + + + + + + + + + + {{t 'resources.role.scope.messages.manage-scopes.title'}} + {{! TODO: Add doclink }} + + {{t 'resources.role.scope.messages.manage-scopes.description'}} + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs index e4227b2a3..1208d4cd1 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs @@ -149,15 +149,14 @@ {{else}} - - + + {{#if (can 'setGrantScopes role' @model)}} - {{! TODO: Update route to manage-scopes once it is implemented }} {{/if}} diff --git a/ui/admin/tests/acceptance/roles/principals-test.js b/ui/admin/tests/acceptance/roles/principals-test.js index f0595e931..c1e0253e4 100644 --- a/ui/admin/tests/acceptance/roles/principals-test.js +++ b/ui/admin/tests/acceptance/roles/principals-test.js @@ -22,6 +22,8 @@ module('Acceptance | roles | principals', function (hooks) { setupMirage(hooks); setupIndexedDb(hooks); + const ADD_PRINCIPALS_SELECTOR = '[data-test-manage-dropdown-principals]'; + const instances = { scopes: { global: null, @@ -111,14 +113,14 @@ module('Acceptance | roles | principals', function (hooks) { ); instances.role.update({ authorized_actions }); await visit(urls.rolePrincipals); - assert.notOk(find('.rose-layout-page-actions a')); + assert.dom(ADD_PRINCIPALS_SELECTOR).doesNotExist(); }); test('select and save principals to add', async function (assert) { instances.role.update({ userIds: [], groupIds: [], managedGroupIds: [] }); await visit(urls.rolePrincipals); assert.strictEqual(findAll('tbody tr').length, 0); - await click('.rose-layout-page-actions a'); + await click(ADD_PRINCIPALS_SELECTOR); assert.strictEqual(currentURL(), urls.addPrincipals); // Click three times to select, unselect, then reselect (for coverage) await click('tbody label'); @@ -136,7 +138,7 @@ module('Acceptance | roles | principals', function (hooks) { await click('.hds-dropdown-toggle-icon'); await click('tbody tr .hds-dropdown-list-item button'); assert.strictEqual(findAll('tbody tr').length, principalsCount - 1); - await click('.rose-layout-page-actions a'); + await click(ADD_PRINCIPALS_SELECTOR); assert.strictEqual(currentURL(), urls.addPrincipals); await click('tbody label'); await click('form [type="button"]'); diff --git a/ui/admin/tests/acceptance/roles/scopes-test.js b/ui/admin/tests/acceptance/roles/scopes-test.js index d2d1c5c0f..237174180 100644 --- a/ui/admin/tests/acceptance/roles/scopes-test.js +++ b/ui/admin/tests/acceptance/roles/scopes-test.js @@ -4,18 +4,27 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, click } from '@ember/test-helpers'; +import { visit, currentURL, click, findAll } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { authenticateSession } from 'ember-simple-auth/test-support'; +import { GRANT_SCOPE_THIS } from 'api/models/role'; module('Acceptance | roles | scopes', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); setupIndexedDb(hooks); + const SCOPE_TOGGLE_SELECTOR = (name) => + `.hds-form-toggle input[name="${name}"]`; + const TABLE_ROW_SELECTOR = 'tbody tr'; + const SAVE_BTN_SELECTOR = 'form [type="submit"]'; + const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]'; + const MANAGE_SCOPES_SELECTOR = '[data-test-manage-dropdown-scopes]'; + const TOAST_SELECTOR = '[role="alert"]'; + const instances = { scopes: { global: null, @@ -28,6 +37,7 @@ module('Acceptance | roles | scopes', function (hooks) { roles: null, role: null, roleScopes: null, + manageScopes: null, }; hooks.beforeEach(function () { @@ -47,6 +57,7 @@ module('Acceptance | roles | scopes', function (hooks) { urls.roles = `/scopes/${instances.scopes.org.id}/roles`; urls.role = `${urls.roles}/${instances.role.id}`; urls.roleScopes = `${urls.role}/scopes`; + urls.manageScopes = `${urls.role}/manage-scopes`; }); test('visiting role scopes', async function (assert) { @@ -54,7 +65,7 @@ module('Acceptance | roles | scopes', function (hooks) { await a11yAudit(); assert.strictEqual(currentURL(), urls.roleScopes); assert - .dom('tbody tr') + .dom(TABLE_ROW_SELECTOR) .exists({ count: instances.role.grant_scope_ids.length }); }); @@ -93,4 +104,71 @@ module('Acceptance | roles | scopes', function (hooks) { await visit(urls.roleScopes); assert.dom('.role-grant-scopes div div:nth-child(3) a').doesNotExist(); }); + + test('toggle and save scope keywords to add', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('toggle and cancel scope keywords to add', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + }); + + test('shows error message on scope save', async function (assert) { + this.server.post('/roles/:idMethod', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: {}, + }, + ); + }); + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.dom(TOAST_SELECTOR).isVisible(); + }); }); diff --git a/ui/admin/tests/unit/abilities/role-test.js b/ui/admin/tests/unit/abilities/role-test.js new file mode 100644 index 000000000..4c803f474 --- /dev/null +++ b/ui/admin/tests/unit/abilities/role-test.js @@ -0,0 +1,64 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Abilities | role', function (hooks) { + setupTest(hooks); + + let canService; + let store; + + hooks.beforeEach(function () { + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('can setGrantScopes on role when authorized and scope is global type', function (assert) { + const globalScope = store.createRecord('scope', { id: 'global' }); + const role = store.createRecord('role', { + authorized_actions: ['set-grant-scopes'], + scope: globalScope, + }); + assert.true(canService.can('setGrantScopes role', role)); + role.authorized_actions = []; + assert.false(canService.can('setGrantScopes role', role)); + }); + + test('can setGrantScopes on role when authorized and scope is org type', function (assert) { + const globalScope = store.createRecord('scope', { id: 'global' }); + const orgScope = store.createRecord('scope', { + type: 'org', + scope: globalScope, + }); + const role = store.createRecord('role', { + authorized_actions: ['set-grant-scopes'], + scope: orgScope, + }); + assert.true(canService.can('setGrantScopes role', role)); + role.authorized_actions = []; + assert.false(canService.can('setGrantScopes role', role)); + }); + + test('cannot setGrantScopes on role when authorized and scope is project type', function (assert) { + const globalScope = store.createRecord('scope', { id: 'global' }); + const orgScope = store.createRecord('scope', { + type: 'org', + scope: globalScope, + }); + const projectScope = store.createRecord('scope', { + type: 'project', + scope: orgScope, + }); + const role = store.createRecord('role', { + authorized_actions: ['set-grant-scopes'], + scope: projectScope, + }); + assert.false(canService.can('setGrantScopes role', role)); + role.authorized_actions = []; + assert.false(canService.can('setGrantScopes role', role)); + }); +}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes-test.js new file mode 100644 index 000000000..417499630 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes-test.js @@ -0,0 +1,70 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { visit, currentURL } from '@ember/test-helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module( + 'Unit | Controller | scopes/scope/roles/role/manage-scopes', + function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + let store; + let controller; + + const instances = { + scopes: { + global: null, + }, + role: null, + }; + + const urls = { + globalScope: null, + scopes: null, + manageScopes: null, + }; + + hooks.beforeEach(async function () { + authenticateSession({}); + store = this.owner.lookup('service:store'); + controller = this.owner.lookup( + 'controller:scopes/scope/roles/role/manage-scopes', + ); + + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.role = this.server.create('role', { + scopeId: 'global', + grant_scope_ids: [], + }); + + urls.globalScope = `/scopes/global`; + urls.scopes = `${urls.globalScope}/roles/${instances.role.id}/scopes`; + urls.manageScopes = `${urls.globalScope}/roles/${instances.role.id}/manage-scopes`; + }); + + test('it exists', function (assert) { + assert.ok(controller); + }); + + test('setGrantScopes action adds scopes to specified model', async function (assert) { + await visit(urls.manageScopes); + const role = await store.findRecord('role', instances.role.id); + + assert.deepEqual(role.grant_scope_ids, []); + + await controller.setGrantScopes(role, ['this']); + + assert.deepEqual(role.grant_scope_ids, ['this']); + }); + + test('cancel action causes transition to expected route', async function (assert) { + await visit(urls.manageScopes); + + await controller.cancel(); + + assert.strictEqual(currentURL(), urls.scopes); + }); + }, +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes-test.js b/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes-test.js new file mode 100644 index 000000000..1aadf2e87 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module( + 'Unit | Route | scopes/scope/roles/role/manage-scopes', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/roles/role/manage-scopes', + ); + assert.ok(route); + }); + }, +);
{{t 'resources.role.scope.messages.manage-scopes.description'}}