diff --git a/addons/core/addon/styles/addon.scss b/addons/core/addon/styles/addon.scss index b608b2006..277f51094 100644 --- a/addons/core/addon/styles/addon.scss +++ b/addons/core/addon/styles/addon.scss @@ -31,6 +31,14 @@ } } + .selected-items-text { + display: flex; + justify-content: end; + align-items: center; + height: 1rem; + margin-bottom: 0.5rem; + } + > div, table { margin-bottom: 1rem; @@ -40,6 +48,11 @@ .hds-dropdown-toggle-button__text { white-space: nowrap; } + + // Remove margin if the segmented group is followed by the selected items text + &:has(+ .selected-items-text) { + margin-bottom: 0; + } } .search-filtering-toolbar { diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 456b17915..61043b6c8 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -613,6 +613,53 @@ role: description: Grants are permissions which allow roles to take actions and access resources. actions: create: New Grant + grant-templates: + title_plural: Grant Templates + actions: + add-grant-templates: Add Grant Templates + messages: + add-grant-templates: + title: Add Grant Templates + description: Select grant templates to add to this role. + titles: + grant-strings: Grant Strings + templates: + global-admin: + name: Global Admin + description: Full administrative access to all resources in Boundary + org-admin: + name: Org Admin + description: Administrative access to all resources within an organization + project-admin: + name: Project Admin + description: Administrative access to all resources within a project + session-manager: + name: Session Manager + description: Can view and cancel sessions + target-manager: + name: Target Manager + description: Can create and manage targets + host-resource-manager: + name: Host Resource Manager + description: Can manage hosts and host catalogs + credential-manager: + name: Credential Manager + description: Can manage credentials and credential stores + user-manager: + name: User Manager + description: Can manage users, groups, and accounts + auth-method-manager: + name: Auth Method Manager + description: Can manage auth methods and managed groups + session-auditor: + name: Session Auditor + description: Can view session recordings + target-user: + name: Target User + description: Can connect to targets but cannot manage anything + read-only-user: + name: Read-Only User + description: Can view but not modify any resources scope: title: Scope title_plural: Scopes @@ -624,7 +671,6 @@ role: parent_scope: Parent scope resource_type: Resource type projects_selected: Projects selected - selected: '{items} selected out of {total}' messages: none: title: No scopes added diff --git a/addons/core/translations/states/en-us.yaml b/addons/core/translations/states/en-us.yaml index cbde13a10..c9b4f03a2 100644 --- a/addons/core/translations/states/en-us.yaml +++ b/addons/core/translations/states/en-us.yaml @@ -29,3 +29,4 @@ incomplete-loading: refreshing: description: Updating cache... tooltip: Some items may not appear until the cache is finished updating +selected-items: '{items} selected out of {total}' diff --git a/ui/admin/app/components/form/role/add-grant-templates/index.hbs b/ui/admin/app/components/form/role/add-grant-templates/index.hbs new file mode 100644 index 000000000..f59b5fbc1 --- /dev/null +++ b/ui/admin/app/components/form/role/add-grant-templates/index.hbs @@ -0,0 +1,97 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + + {{#if this.paginatedTemplates}} + +
+ {{#if this.selectedGrantTemplates}} + + {{t + 'states.selected-items' + items=this.selectedGrantTemplates.length + total=this.totalCount + }} + + {{/if}} +
+ + + <:body as |B|> + + + + {{B.data.name}} + + + {{B.data.description}} + + + +
    + {{#each B.data.grantStrings as |grantString|}} +
  • + {{grantString}} +
  • + {{/each}} +
+
+
+ +
+ + + {{else}} + + + + + {{/if}} + +
\ No newline at end of file diff --git a/ui/admin/app/components/form/role/add-grant-templates/index.js b/ui/admin/app/components/form/role/add-grant-templates/index.js new file mode 100644 index 000000000..e0787feec --- /dev/null +++ b/ui/admin/app/components/form/role/add-grant-templates/index.js @@ -0,0 +1,195 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; + +export default class FormRoleAddGrantTemplatesIndexComponent extends Component { + // =services + + @service intl; + + // =properties + + @tracked selectedGrantTemplates = []; + + /** + * Grant template definitions with their grant strings. + * @type {Array<{id: string, grantStrings: Array}>} + */ + grantTemplateDefinitions = [ + { + id: 'global-admin', + grantStrings: ['ids=*;type=*;actions=*'], + }, + { + id: 'org-admin', + grantStrings: [ + 'ids=;type=scope;actions=*', + 'ids=*;type=auth-method;actions=*', + 'ids=*;type=user;actions=*', + 'ids=*;type=group;actions=*', + 'ids=*;type=role;actions=*', + ], + }, + { + id: 'project-admin', + grantStrings: [ + 'ids=;type=scope;actions=*', + 'ids=*;type=host-catalog;actions=*', + 'ids=*;type=target;actions=*', + 'ids=*;type=credential-store;actions=*', + 'ids=*;type=session;actions=*', + ], + }, + { + id: 'session-manager', + grantStrings: [ + 'ids=*;type=session;actions=list,read,cancel', + 'ids=*;type=target;actions=read,list', + ], + }, + { + id: 'target-manager', + grantStrings: [ + 'ids=*;type=target;actions=create,list,read,update,delete,add-host-sources,set-host-sources,remove-host-sources,add-credential-sources,set-credential-sources,remove-credential-sources', + ], + }, + { + id: 'host-resource-manager', + grantStrings: [ + 'ids=*;type=host-catalog;actions=create,list,read,update,delete', + 'ids=*;type=host-set;actions=create,read,update,delete,add-hosts,set-hosts,remove-hosts', + 'ids=*;type=host;actions=create,read,update,delete', + ], + }, + { + id: 'credential-manager', + grantStrings: [ + 'ids=*;type=credential;actions=create,read,update,delete', + 'ids=*;type=credential-store;actions=create,list,read,update,delete', + 'ids=*;type=credential-library;actions=create,read,update,delete', + ], + }, + { + id: 'user-manager', + grantStrings: [ + 'ids=*;type=user;actions=create,list,read,update,delete,add-accounts,set-accounts,remove-accounts', + 'ids=*;type=group;actions=create,list,read,update,delete,add-members,set-members,remove-members', + 'ids=*;type=account;actions=create,read,update,delete', + ], + }, + { + id: 'auth-method-manager', + grantStrings: [ + 'ids=*;type=auth-method;actions=create,list,read,update,delete,change-state', + 'ids=*;type=managed-group;actions=create,read,update,delete', + ], + }, + { + id: 'session-auditor', + grantStrings: [ + 'ids=*;type=session;actions=list,read', + 'ids=*;type=session-recording;actions=list,read,download', + ], + }, + { + id: 'target-user', + grantStrings: [ + 'ids=*;type=target;actions=authorize-session,read,list', + 'ids=*;type=session;actions=read:self,cancel:self', + ], + }, + { + id: 'read-only-user', + grantStrings: ['ids=*;type=session;actions=list,read,cancel'], + }, + ]; + + /** + * Grant templates with translated names and descriptions. + * @type {Array<{id: string, name: string, grantStrings: Array, description: string}>} + */ + get grantTemplates() { + return this.grantTemplateDefinitions.map((template) => ({ + id: template.id, + name: this.intl.t( + `resources.role.grant-templates.templates.${template.id}.name`, + ), + grantStrings: template.grantStrings, + description: this.intl.t( + `resources.role.grant-templates.templates.${template.id}.description`, + ), + })); + } + + /** + * Filtered grant templates based on search query. + * @return {Array<{id: string, name: string, grantStrings: Array, description: string}>} + */ + get filteredTemplates() { + let filteredTemplates = this.grantTemplates; + + if (this.args.search) { + filteredTemplates = this.grantTemplates.filter((template) => { + const searchTerm = this.args.search?.toLowerCase() ?? ''; + return ( + template.name.toLowerCase().includes(searchTerm) || + template.description.toLowerCase().includes(searchTerm) || + template.grantStrings.some((grant) => grant.includes(searchTerm)) + ); + }); + } + + return filteredTemplates; + } + + /** + * Paginated templates for current page. + * @type {Array} + */ + get paginatedTemplates() { + return this.filteredTemplates.slice( + (this.args.page - 1) * this.args.pageSize, + this.args.page * this.args.pageSize, + ); + } + + get totalCount() { + return this.grantTemplateDefinitions.length; + } + // =actions + + @action + onSelectionChange({ selectionKey, selectedRowsKeys }) { + const oldKeys = new Set(this.selectedGrantTemplates); + if (selectionKey === 'all') { + const newKeys = new Set(selectedRowsKeys); + if (newKeys.size === 0) { + this.selectedGrantTemplates = [...newKeys.difference(oldKeys)]; + } else { + this.selectedGrantTemplates = [...newKeys.union(oldKeys)]; + } + } else { + if (oldKeys.has(selectionKey)) { + oldKeys.delete(selectionKey); + } else { + oldKeys.add(selectionKey); + } + this.selectedGrantTemplates = [...oldKeys]; + } + } + + @action + submit() { + // Convert selected template IDs to their grant strings + const grantStrings = this.grantTemplates + .filter((template) => this.selectedGrantTemplates.includes(template.id)) + .flatMap((template) => template.grantStrings); + this.args.submit(grantStrings); + } +} diff --git a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs index 47decd8e0..fa84e9048 100644 --- a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs +++ b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs @@ -5,7 +5,7 @@ {{#if @model.totalItemsCount}} +
{{#if @model.role.grantScopeOrgIDs.length}} {{t - 'resources.role.scope.titles.selected' + 'states.selected-items' items=@model.role.grantScopeOrgIDs.length total=@model.totalItemsCount }} @@ -103,7 +103,7 @@ {{#if @model.role.grantScopeProjectIDs.length}} {{t - 'resources.role.scope.titles.selected' + 'states.selected-items' items=@model.role.grantScopeProjectIDs.length total=@model.totalItemsCount }} diff --git a/ui/admin/app/components/form/role/manage-org-projects/index.hbs b/ui/admin/app/components/form/role/manage-org-projects/index.hbs index 47bfa6fdb..e24b15957 100644 --- a/ui/admin/app/components/form/role/manage-org-projects/index.hbs +++ b/ui/admin/app/components/form/role/manage-org-projects/index.hbs @@ -5,7 +5,7 @@ {{#if @model.totalItemsCount}} {{#if @model.projectScopes}} -
+
{{#if this.selectedProjectsCount}} {{t - 'resources.role.scope.titles.selected' + 'states.selected-items' items=this.selectedProjectsCount total=@model.totalItemsCount }} diff --git a/ui/admin/app/components/roles/role/actions/index.hbs b/ui/admin/app/components/roles/role/actions/index.hbs index a72fda28d..d9323602e 100644 --- a/ui/admin/app/components/roles/role/actions/index.hbs +++ b/ui/admin/app/components/roles/role/actions/index.hbs @@ -7,11 +7,19 @@ (hash canAddPrincipals=(can 'addPrincipals role' @model) canSetGrantScopes=(can 'setGrantScopes role' @model) + canSetGrants=(can 'setGrants role' @model) canDelete=(can 'delete model' @model) ) as |perms| }} - {{#if (or perms.canAddPrincipals perms.canSetGrantScopes perms.canDelete)}} + {{#if + (or + perms.canAddPrincipals + perms.canSetGrantScopes + perms.canSetGrants + perms.canDelete + ) + }} {{#if perms.canAddPrincipals}} @@ -30,8 +38,18 @@ {{t 'resources.role.scope.actions.manage-scopes'}} {{/if}} + {{#if perms.canSetGrants}} + + {{t 'resources.role.grant-templates.actions.add-grant-templates'}} + + {{/if}} {{#if perms.canDelete}} - {{#if (or perms.canAddPrincipals perms.canSetGrantScopes)}} + {{#if + (or perms.canAddPrincipals perms.canSetGrantScopes perms.canSetGrants) + }} {{/if}} div { diff --git a/ui/admin/app/templates/scopes/scope/roles/role/add-grant-templates.hbs b/ui/admin/app/templates/scopes/scope/roles/role/add-grant-templates.hbs new file mode 100644 index 000000000..6b0cde25b --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/roles/role/add-grant-templates.hbs @@ -0,0 +1,44 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{page-title (t 'resources.role.grant-templates.actions.add-grant-templates')}} + + + + + + + + + + + {{t + 'resources.role.grant-templates.messages.add-grant-templates.title' + }} + + + {{t + 'resources.role.grant-templates.messages.add-grant-templates.description' + }} + + + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs index defc41134..881a035c3 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs @@ -34,7 +34,7 @@ - + - + item !== 'set-grants', + ); + instances.role.update({ authorized_actions }); + await visit(urls.role); + + await click(selectors.MANAGE_DROPDOWN_ROLES); + + assert.dom(selectors.MANAGE_DROPDOWN_ADD_GRANT_TEMPLATES).doesNotExist(); + }); + + test('can search grant templates', async function (assert) { + await visit(urls.addGrantTemplates); + + const initialRowCount = findAll(commonSelectors.TABLE_ROWS).length; + + await fillIn(commonSelectors.SEARCH_INPUT, 'admin'); + await waitUntil(() => findAll(commonSelectors.TABLE_ROWS).length === 3); + + assert.strictEqual(initialRowCount, 10); + assert.strictEqual(findAll(commonSelectors.TABLE_ROWS).length, 3); + }); + + test('can search for grant templates and get no results', async function (assert) { + await visit(urls.addGrantTemplates); + assert.dom(commonSelectors.TABLE_ROWS).isVisible(); + + await fillIn(commonSelectors.SEARCH_INPUT, 'nonexistentgranttemplate'); + await waitUntil(() => findAll(commonSelectors.TABLE_ROWS).length === 0); + + assert.dom(commonSelectors.TABLE_ROWS).doesNotExist(); + assert + .dom(selectors.NO_GRANT_TEMPLATES_MSG) + .includesText('No results found'); + }); + + test('can select and add grant templates', async function (assert) { + await visit(urls.addGrantTemplates); + const initialGrantCount = instances.role.grant_strings.length; + + await click(commonSelectors.TABLE_ROW_CHECKBOX); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.grants); + + assert.strictEqual(initialGrantCount, 0); + assert.dom(selectors.FIELD_GRANT).isVisible({ count: 1 }); + assert.dom(selectors.FIELD_GRANT).hasValue('ids=*;type=*;actions=*'); + }); + + test('cancel navigates back to grants without changes', async function (assert) { + await visit(urls.addGrantTemplates); + const initialGrantCount = instances.role.grant_strings.length; + + await click(commonSelectors.TABLE_ROW_CHECKBOX); + await click(commonSelectors.CANCEL_BTN); + + assert.strictEqual(initialGrantCount, 0); + assert.dom(selectors.FIELD_GRANT).isVisible({ count: 0 }); + }); +}); diff --git a/ui/admin/tests/acceptance/roles/selectors.js b/ui/admin/tests/acceptance/roles/selectors.js index ba19085b3..033c34aa2 100644 --- a/ui/admin/tests/acceptance/roles/selectors.js +++ b/ui/admin/tests/acceptance/roles/selectors.js @@ -14,6 +14,8 @@ export const MANAGE_DROPDOWN_ROLES_REMOVE = '[data-test-manage-roles-dropdown] div:last-child button'; export const MANAGE_DROPDOWN_ADD_PRINCIPALS = '[data-test-manage-role-principals]'; +export const MANAGE_DROPDOWN_ADD_GRANT_TEMPLATES = + '[data-test-manage-role-grant-templates]'; export const ROLE_BADGE = (id) => `tbody [data-test-role-row="${id}"] td:nth-child(2) .hds-badge__text`; @@ -33,6 +35,8 @@ export const NO_RESULTS_GRANT_SCOPE_MSG = '[data-test-no-grant-scope-results]'; export const NO_SCOPES_MSG = '.role-grant-scopes div'; export const NO_SCOPES_MSG_LINK = '.role-grant-scopes div div:nth-child(3) a'; +export const NO_GRANT_TEMPLATES_MSG = '[data-test-no-grant-template-results]'; + // Manage scopes selectors export const SCOPE_TOGGLE = (name) => `.hds-form-toggle input[name="${name}"]`; export const SCOPE_CHECKBOX = (type, id) => diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/add-grant-templates-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/add-grant-templates-test.js new file mode 100644 index 000000000..3952dc7b0 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/add-grant-templates-test.js @@ -0,0 +1,77 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; +import { setupMirage } from 'admin/tests/helpers/mirage'; +import { setupSqlite } from 'api/test-support/helpers/sqlite'; +import sinon from 'sinon'; + +module( + 'Unit | Controller | scopes/scope/roles/role/add-grant-templates', + function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupSqlite(hooks); + + let store; + let controller; + let router; + let routerStub; + + const instances = { + scopes: { + global: null, + org: null, + }, + role: null, + }; + + hooks.beforeEach(async function () { + store = this.owner.lookup('service:store'); + controller = this.owner.lookup( + 'controller:scopes/scope/roles/role/add-grant-templates', + ); + router = this.owner.lookup('service:router'); + routerStub = sinon.stub(router, 'replaceWith'); + + instances.scopes.global = this.server.create( + 'scope', + { id: 'global' }, + 'withGlobalAuth', + ); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.role = this.server.create('role', { + scope: instances.scopes.org, + grant_strings: ['ids=*;type=user;actions=read'], + }); + }); + + test('it exists', function (assert) { + assert.ok(controller); + }); + + test('addGrantTemplates action adds grant templates to role', async function (assert) { + const role = await store.findRecord('role', instances.role.id); + + const newGrantTemplates = [ + 'ids=*;type=*;actions=*', + 'ids=*;type=session;actions=list,read', + ]; + + await controller.addGrantTemplates(role, newGrantTemplates); + + assert.ok(routerStub.calledOnceWith('scopes.scope.roles.role.grants')); + assert.deepEqual(role.grant_strings, [ + 'ids=*;type=user;actions=read', + 'ids=*;type=*;actions=*', + 'ids=*;type=session;actions=list,read', + ]); + }); + }, +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/roles/role/add-grant-templates-test.js b/ui/admin/tests/unit/routes/scopes/scope/roles/role/add-grant-templates-test.js new file mode 100644 index 000000000..6c350e7a6 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/roles/role/add-grant-templates-test.js @@ -0,0 +1,21 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module( + 'Unit | Route | scopes/scope/roles/role/add-grant-templates', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/roles/role/add-grant-templates', + ); + assert.ok(route); + }); + }, +);