Icu 12708 add scopes tab route in role route (#2322)

* feat: 🎸 add roles/role/scopes route

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 update role and scope mirage factories

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* feat: 🎸 add grantScopes to role model

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* feat: 🎸 fetch all scopes recursively

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* feat: 🎸 add new role grant scopes translations

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* feat: 🎸 add table to list grant scopes on a role

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* test: 💍 acceptance test for grant scope table and controller

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* test: 💍 add unit tests for grantScopes method on role model

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 small updates from PR feedback

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 update grantScopes to use sort

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* feat: 🎸 update role scope description for each scope type

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 make grant_scope_ids sort better

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 update global scope to show as Global vs global

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 update logic for display grant scope keywords

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 small improvement in role.grantScopes

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 move scope query to roles/scope route

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708

* refactor: 💡 remove console.log

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12708
This commit is contained in:
Cameron Perera authored and Lisbet Alvarez committed 2024-07-15 17:23:00 -07:00
1 parent e60b774d05
commit 2bc314d271
14 files changed
+587 -2

No files matched your search

+48
View File
@@ -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[]}
+10
View File
@@ -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.
*/
+19 -1
View File
@@ -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) {
@@ -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(),
+134
View File
@@ -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);
});
});
@@ -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
@@ -7,6 +7,9 @@
<nav.link @route='scopes.scope.roles.role.index'>
{{t 'titles.settings'}}
</nav.link>
<nav.link @route='scopes.scope.roles.role.scopes'>
{{t 'resources.role.scope.title_plural'}}
</nav.link>
<nav.link @route='scopes.scope.roles.role.principals'>
{{t 'resources.role.principal.title_plural'}}
</nav.link>
@@ -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;
}
}
+1
View File
@@ -52,6 +52,7 @@ Router.map(function () {
this.route('grants');
this.route('principals');
this.route('add-principals');
this.route('scopes');
});
this.route('new');
});
@@ -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;
}
}
@@ -0,0 +1,154 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{page-title (t 'resources.role.scope.title_plural')}}
<Breadcrumbs::Item
@text={{t 'resources.role.scope.title_plural'}}
@route='scopes.scope.roles.role.scopes'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.role.title'}}
<DocLink @doc='role' @iconSize='large' />
</h2>
<p>
{{#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}}
</p>
<Hds::Copy::Snippet @textToCopy={{@model.id}} @color='secondary' />
</page.header>
<page.actions>
<Roles::Role::Actions @model={{@model}} @delete={{this.roles.delete}} />
</page.actions>
<page.navigation>
<Roles::Role::Nav @model={{@model}} />
</page.navigation>
<page.body>
{{#if @model.grant_scope_ids}}
<Hds::Table
@model={{@model.grantScopes}}
@columns={{array
(hash label=(t 'resources.role.scope.title'))
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.role.scope.titles.parent_scope'))
(hash label=(t 'resources.role.scope.titles.resource_type'))
}}
@valin='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
{{#if (this.isKeywordThis B.data.id)}}
<Hds::Text::Body @tag='p'>
{{#if @model.scope.name}}
{{@model.scope.name}}
{{else}}
{{@model.scope.id}}
{{/if}}
</Hds::Text::Body>
<Hds::Text::Body @tag='p'>
<Hds::Badge
@text={{t 'resources.role.scope.keywords.this.sub-title'}}
@size='small'
/>
</Hds::Text::Body>
{{else if (this.isKeywordChildrenOrDescendants B.data.id)}}
<Hds::Text::Body @tag='p'>
{{t
(concat 'resources.role.scope.keywords.' B.data.id '.title')
}}
</Hds::Text::Body>
<Hds::Text::Body @tag='p'>
{{t
(concat
'resources.role.scope.keywords.' B.data.id '.sub-title'
)
}}
</Hds::Text::Body>
{{else}}
{{#if (can 'read model' B.data)}}
<LinkTo @route='scopes.scope' @model={{B.data}}>
{{#if B.data.isGlobal}}
{{t 'titles.global'}}
{{else}}
{{B.data.displayName}}
{{/if}}
</LinkTo>
{{else}}
{{B.data.displayName}}
{{/if}}
{{/if}}
</B.Td>
<B.Td>
<Hds::Copy::Snippet
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{#if B.data.scope.isGlobal}}
<Hds::Link::Inline
@route='scopes.scope'
@model={{B.data.scope.id}}
@icon='globe'
@iconPosition='leading'
>
{{#if B.data.scope.name}}
{{t 'titles.global'}}
{{else}}
{{B.data.scope.id}}
{{/if}}
</Hds::Link::Inline>
{{else if B.data.scope.isOrg}}
<Hds::Link::Inline
@route='scopes.scope'
@model={{B.data.scope.id}}
@icon='org'
@iconPosition='leading'
>
{{#if B.data.scope.name}}
{{B.data.scope.name}}
{{else}}
{{B.data.scope.id}}
{{/if}}
</Hds::Link::Inline>
{{else}}
{{#if B.data.scope.name}}
{{B.data.scope.name}}
{{else}}
{{B.data.scope.id}}
{{/if}}
{{/if}}
</B.Td>
<B.Td>
{{#if B.data.type}}
<Hds::Badge
@text={{t (concat 'resources.scope.types.' B.data.type)}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -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`,
);
});
});
@@ -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));
});
});
@@ -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);
});
});