diff --git a/addons/api/addon/models/role.js b/addons/api/addon/models/role.js index 20f56aa73..58115593a 100644 --- a/addons/api/addon/models/role.js +++ b/addons/api/addon/models/role.js @@ -7,6 +7,26 @@ import GeneratedRoleModel from '../generated/models/role'; import { attr } from '@ember-data/model'; import { inject as service } from '@ember/service'; +export const GRANT_SCOPE_THIS = 'this'; +export const GRANT_SCOPE_CHILDREN = 'children'; +export const GRANT_SCOPE_DESCENDANTS = 'descendants'; +export const GRANT_SCOPE_KEYWORDS = Object.freeze([ + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +]); + +// Sort the grant scope IDs by the order in which they should be displayed +// in the UI as the API will return them in an arbitrary order. +const sortOrder = { + [GRANT_SCOPE_THIS]: 0, + [GRANT_SCOPE_CHILDREN]: 1, + [GRANT_SCOPE_DESCENDANTS]: 2, + global: 3, + o: 4, + p: 5, +}; + export default class RoleModel extends GeneratedRoleModel { // =services @@ -33,6 +53,34 @@ export default class RoleModel extends GeneratedRoleModel { */ @attr({ readOnly: true, emptyArrayIfMissing: true }) grant_strings; + /** + * Convenience for looking up the grant scopes, if loaded. + */ + get grantScopes() { + const grantScopes = []; + if (this.grant_scope_ids) { + const sortedScopeIDs = [...this.grant_scope_ids].sort((a, b) => { + const aSplit = a.split('_')[0]; + const bSplit = b.split('_')[0]; + + return sortOrder[aSplit] - sortOrder[bSplit]; + }); + + sortedScopeIDs.forEach((id) => { + if (GRANT_SCOPE_KEYWORDS.includes(id)) { + grantScopes.push({ id }); + } else { + const scope = this.store.peekRecord('scope', id); + if (scope) { + grantScopes.push(scope); + } + } + }); + } + + return grantScopes; + } + /** * A list of IDs for principals of type `user`. * @type {string[]} diff --git a/addons/api/mirage/factories/role.js b/addons/api/mirage/factories/role.js index 088281402..fc59bbcaa 100644 --- a/addons/api/mirage/factories/role.js +++ b/addons/api/mirage/factories/role.js @@ -28,6 +28,16 @@ export default factory.extend({ id: () => generateId('r_'), grant_scope_ids: () => ['this'], + /** + * Adds grant scopes to the role. + */ + withScopes: trait({ + afterCreate(role) { + const { scope } = role; + role.update({ grant_scope_ids: ['this', scope.id] }); + }, + }), + /** * Adds principals to the role. */ diff --git a/addons/api/mirage/factories/scope.js b/addons/api/mirage/factories/scope.js index cf69813d2..35484166e 100644 --- a/addons/api/mirage/factories/scope.js +++ b/addons/api/mirage/factories/scope.js @@ -7,6 +7,7 @@ import factory from '../generated/factories/scope'; import { trait } from 'miragejs'; import permissions from '../helpers/permissions'; import generateId from '../helpers/id'; +import { faker } from '@faker-js/faker'; export default factory.extend({ type: 'global', @@ -58,7 +59,24 @@ export default factory.extend({ return collectionActions; }, - id: () => generateId('s_'), + id() { + let id = 'global'; + if (this.type === 'org') { + id = generateId('o_'); + } + if (this.type === 'project') { + id = generateId('p_'); + } + return id; + }, + + name() { + let name = faker.word.words(); + if (this.type === 'global') { + name = 'global'; + } + return name; + }, withChildren: trait({ afterCreate(record, server) { diff --git a/addons/api/mirage/generated/factories/scope.js b/addons/api/mirage/generated/factories/scope.js index c950ca7eb..092302f85 100644 --- a/addons/api/mirage/generated/factories/scope.js +++ b/addons/api/mirage/generated/factories/scope.js @@ -10,7 +10,6 @@ import { faker } from '@faker-js/faker'; * GeneratedScopeModelFactory */ export default Factory.extend({ - name: () => faker.word.words(), description: () => faker.word.words(), created_time: () => faker.date.recent(), updated_time: () => faker.date.recent(), diff --git a/addons/api/tests/unit/models/role-test.js b/addons/api/tests/unit/models/role-test.js index 23f4bbb53..edafa28e4 100644 --- a/addons/api/tests/unit/models/role-test.js +++ b/addons/api/tests/unit/models/role-test.js @@ -6,6 +6,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; module('Unit | Model | role', function (hooks) { setupTest(hooks); @@ -183,4 +188,133 @@ module('Unit | Model | role', function (hooks) { const model = store.peekRecord('role', '123abc'); await model.removePrincipal('3'); }); + + test('grantScopes returns an empty array when no grant_scope_ids are present', async function (assert) { + const store = this.owner.lookup('service:store'); + const role = store.createRecord('role'); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 0); + }); + + test('grantScopes returns an empty array when no grant_scope_ids are loaded', async function (assert) { + const store = this.owner.lookup('service:store'); + const role = store.createRecord('role', { + grant_scope_ids: ['o_123'], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 0); + }); + + test('grantScopes returns an array of scope records', async function (assert) { + const store = this.owner.lookup('service:store'); + const orgScope = store.createRecord('scope', { id: 'o_123' }); + const role = store.createRecord('role', { + grant_scope_ids: [orgScope.id], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 1); + assert.strictEqual(grantScopes[0].id, orgScope.id); + }); + + test('grantScopes returns an array of scope records ordered by orgs then projects', async function (assert) { + const store = this.owner.lookup('service:store'); + const orgScope = store.createRecord('scope', { id: 'o_123' }); + const projectScope = store.createRecord('scope', { id: 'p_123' }); + const role = store.createRecord('role', { + grant_scope_ids: [projectScope.id, orgScope.id], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 2); + assert.strictEqual(grantScopes[0].id, orgScope.id); + assert.strictEqual(grantScopes[1].id, projectScope.id); + }); + + test('grantScopes returns an array of keywords and scope records', async function (assert) { + const store = this.owner.lookup('service:store'); + const orgScope = store.createRecord('scope', { id: 'o_123' }); + const projectScope = store.createRecord('scope', { id: 'p_123' }); + const role = store.createRecord('role', { + grant_scope_ids: [projectScope.id, GRANT_SCOPE_THIS, orgScope.id], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 3); + assert.strictEqual(grantScopes[0].id, GRANT_SCOPE_THIS); + assert.strictEqual(grantScopes[1].id, orgScope.id); + assert.strictEqual(grantScopes[2].id, projectScope.id); + }); + + test('grantScopes orders keywords by this, children, descendants', async function (assert) { + // A role cannot have this, children, and descendants at the same time + // but for testing purposes, we want to show the order they are sorted in + const store = this.owner.lookup('service:store'); + const role = store.createRecord('role', { + grant_scope_ids: [ + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, + GRANT_SCOPE_THIS, + ], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 3); + assert.strictEqual(grantScopes[0].id, GRANT_SCOPE_THIS); + assert.strictEqual(grantScopes[1].id, GRANT_SCOPE_CHILDREN); + assert.strictEqual(grantScopes[2].id, GRANT_SCOPE_DESCENDANTS); + }); + + test('grantScopes returns an array of keywords and scopes in the correct order', async function (assert) { + const store = this.owner.lookup('service:store'); + const orgScope = store.createRecord('scope', { id: 'o_123' }); + const projectScope = store.createRecord('scope', { id: 'p_123' }); + const role = store.createRecord('role', { + grant_scope_ids: [ + projectScope.id, + GRANT_SCOPE_THIS, + orgScope.id, + GRANT_SCOPE_CHILDREN, + ], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 4); + assert.strictEqual(grantScopes[0].id, GRANT_SCOPE_THIS); + assert.strictEqual(grantScopes[1].id, GRANT_SCOPE_CHILDREN); + assert.strictEqual(grantScopes[2].id, orgScope.id); + assert.strictEqual(grantScopes[3].id, projectScope.id); + }); + + test('grantScopes returns an array that orders global after keywords and before orgs and/or projects', async function (assert) { + const store = this.owner.lookup('service:store'); + const orgScope = store.createRecord('scope', { id: 'o_123' }); + const projectScope = store.createRecord('scope', { id: 'p_123' }); + const globalScope = store.createRecord('scope', { id: 'global' }); + const role = store.createRecord('role', { + grant_scope_ids: [ + projectScope.id, + orgScope.id, + GRANT_SCOPE_CHILDREN, + globalScope.id, + ], + }); + + const grantScopes = role.grantScopes; + + assert.strictEqual(grantScopes.length, 4); + assert.strictEqual(grantScopes[0].id, GRANT_SCOPE_CHILDREN); + assert.strictEqual(grantScopes[1].id, globalScope.id); + assert.strictEqual(grantScopes[2].id, orgScope.id); + assert.strictEqual(grantScopes[3].id, projectScope.id); + }); }); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index c603ae7cf..24014971d 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -560,6 +560,25 @@ role: description: Grants are permissions which allow roles to take actions and access resources. actions: create: New Grant + scope: + title: Scope + title_plural: Scopes + description: + global: Role grant sets can be applied to multiple descendants underneath this scope. + org: Role grant sets can be applied to multiple children underneath this scope. + project: Role grant sets can only be applied to this scope. + titles: + parent_scope: Parent scope + resource_type: Resource type + keywords: + this: + sub-title: This scope + children: + title: All Children + sub-title: All children under this scope + descendants: + title: All Descendants + sub-title: Add all of the orgs and the projects in the orgs that are underneath this scope target: title: Target title_plural: Targets @@ -872,6 +891,10 @@ scope: title_plural: Scopes messages: cannot_read: While you do not have permission to read this scope, you may have permission to view resources within it. + types: + global: Global + org: Org + project: Project managed-group: title: Managed Group title_plural: Managed Groups diff --git a/ui/admin/app/components/roles/role/nav/index.hbs b/ui/admin/app/components/roles/role/nav/index.hbs index 7106ee60a..f31314a36 100644 --- a/ui/admin/app/components/roles/role/nav/index.hbs +++ b/ui/admin/app/components/roles/role/nav/index.hbs @@ -7,6 +7,9 @@ {{t 'titles.settings'}} + + {{t 'resources.role.scope.title_plural'}} + {{t 'resources.role.principal.title_plural'}} diff --git a/ui/admin/app/controllers/scopes/scope/roles/role/scopes.js b/ui/admin/app/controllers/scopes/scope/roles/role/scopes.js new file mode 100644 index 000000000..d0589a2e5 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/roles/role/scopes.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller, { inject as controller } from '@ember/controller'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; + +export default class ScopesScopeRolesRoleScopesController extends Controller { + @controller('scopes/scope/roles/index') roles; + + isKeywordThis(id) { + return id === GRANT_SCOPE_THIS; + } + + isKeywordChildrenOrDescendants(id) { + return id === GRANT_SCOPE_CHILDREN || id === GRANT_SCOPE_DESCENDANTS; + } +} diff --git a/ui/admin/app/router.js b/ui/admin/app/router.js index ce92f8acf..1e72fd4a5 100644 --- a/ui/admin/app/router.js +++ b/ui/admin/app/router.js @@ -52,6 +52,7 @@ Router.map(function () { this.route('grants'); this.route('principals'); this.route('add-principals'); + this.route('scopes'); }); this.route('new'); }); diff --git a/ui/admin/app/routes/scopes/scope/roles/role/scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/scopes.js new file mode 100644 index 000000000..6ddc733bf --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/roles/role/scopes.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { GRANT_SCOPE_KEYWORDS } from 'api/models/role'; + +export default class ScopesScopeRolesRoleScopesRoute extends Route { + // =services + + @service store; + + async model() { + const role = this.modelFor('scopes.scope.roles.role'); + if (!GRANT_SCOPE_KEYWORDS.some((id) => role.grant_scope_ids.includes(id))) { + await this.store.query('scope', { + scope_id: role.scope.id, + recursive: true, + }); + } + return role; + } +} diff --git a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs new file mode 100644 index 000000000..2c2c31d7b --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs @@ -0,0 +1,154 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{page-title (t 'resources.role.scope.title_plural')}} + + + + + + + + + +

+ {{t 'resources.role.title'}} + +

+

+ {{#if @model.scope.isOrg}} + {{t 'resources.role.scope.description.org'}} + {{else if @model.scope.isProject}} + {{t 'resources.role.scope.description.project'}} + {{else}} + {{t 'resources.role.scope.description.global'}} + {{/if}} +

+ +
+ + + + + + + + + + + {{#if @model.grant_scope_ids}} + + <:body as |B|> + + + {{#if (this.isKeywordThis B.data.id)}} + + {{#if @model.scope.name}} + {{@model.scope.name}} + {{else}} + {{@model.scope.id}} + {{/if}} + + + + + {{else if (this.isKeywordChildrenOrDescendants B.data.id)}} + + {{t + (concat 'resources.role.scope.keywords.' B.data.id '.title') + }} + + + {{t + (concat + 'resources.role.scope.keywords.' B.data.id '.sub-title' + ) + }} + + {{else}} + {{#if (can 'read model' B.data)}} + + {{#if B.data.isGlobal}} + {{t 'titles.global'}} + {{else}} + {{B.data.displayName}} + {{/if}} + + {{else}} + {{B.data.displayName}} + {{/if}} + {{/if}} + + + + + + {{#if B.data.scope.isGlobal}} + + {{#if B.data.scope.name}} + {{t 'titles.global'}} + {{else}} + {{B.data.scope.id}} + {{/if}} + + {{else if B.data.scope.isOrg}} + + {{#if B.data.scope.name}} + {{B.data.scope.name}} + {{else}} + {{B.data.scope.id}} + {{/if}} + + {{else}} + {{#if B.data.scope.name}} + {{B.data.scope.name}} + {{else}} + {{B.data.scope.id}} + {{/if}} + {{/if}} + + + {{#if B.data.type}} + + {{/if}} + + + + + {{/if}} + + + +
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/roles/scopes-test.js b/ui/admin/tests/acceptance/roles/scopes-test.js new file mode 100644 index 000000000..392141004 --- /dev/null +++ b/ui/admin/tests/acceptance/roles/scopes-test.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { visit, currentURL, click } 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'; + +module('Acceptance | roles | scopes', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + setupIndexedDb(hooks); + + const instances = { + scopes: { + global: null, + org: null, + }, + role: null, + }; + const urls = { + orgScope: null, + roles: null, + role: null, + roleScopes: null, + }; + + hooks.beforeEach(function () { + authenticateSession({}); + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.role = this.server.create( + 'role', + { + scope: instances.scopes.org, + }, + 'withScopes', + ); + urls.roles = `/scopes/${instances.scopes.org.id}/roles`; + urls.role = `${urls.roles}/${instances.role.id}`; + urls.roleScopes = `${urls.role}/scopes`; + }); + + test('visiting role scopes', async function (assert) { + await visit(urls.roleScopes); + await a11yAudit(); + assert.strictEqual(currentURL(), urls.roleScopes); + assert + .dom('tbody tr') + .exists({ count: instances.role.grant_scope_ids.length }); + }); + + test('user can naviage to scope from role grant scopes', async function (assert) { + await visit(urls.roleScopes); + await click('tbody tr:nth-child(2) a'); + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.org.id}/scopes`, + ); + }); + + test('user can naviage to parent scope from role grant scopes', async function (assert) { + await visit(urls.roleScopes); + await click('tbody tr:nth-child(2) td:nth-child(3) a'); + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.global.id}/scopes`, + ); + }); +}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/scopes-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/scopes-test.js new file mode 100644 index 000000000..98da37d20 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/scopes-test.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { authenticateSession } from 'ember-simple-auth/test-support'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; + +module('Unit | Controller | scopes/scope/roles/role/scopes', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + let controller; + + const instances = { + scopes: { + global: null, + }, + role: null, + }; + + const urls = { + globalScope: null, + scopes: null, + }; + + hooks.beforeEach(async function () { + authenticateSession({}); + controller = this.owner.lookup('controller:scopes/scope/roles/role/scopes'); + + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.role = this.server.create('role', { + scopeId: 'global', + }); + + urls.globalScope = `/scopes/global`; + urls.scopes = `${urls.globalScope}/roles/${instances.role.id}/scopes`; + }); + + test('it exists', function (assert) { + assert.ok(controller); + }); + + test('isKeywordThis action returns true if id is GRANT_SCOPE_THIS', function (assert) { + assert.ok(controller.isKeywordThis(GRANT_SCOPE_THIS)); + assert.notOk(controller.isKeywordThis(GRANT_SCOPE_CHILDREN)); + }); + + test('isKeywordChildrenOrDescendants action returns true if id is GRANT_SCOPE_CHILDREN or GRANT_SCOPE_DESCENDANTS', function (assert) { + assert.ok(controller.isKeywordChildrenOrDescendants(GRANT_SCOPE_CHILDREN)); + assert.ok( + controller.isKeywordChildrenOrDescendants(GRANT_SCOPE_DESCENDANTS), + ); + assert.notOk(controller.isKeywordChildrenOrDescendants(GRANT_SCOPE_THIS)); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/roles/role/scopes-test.js b/ui/admin/tests/unit/routes/scopes/scope/roles/role/scopes-test.js new file mode 100644 index 000000000..8b9b13e82 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/roles/role/scopes-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/roles/role/scopes', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:scopes/scope/roles/role/scopes'); + assert.ok(route); + }); +});