diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 147032891..ad2dae9f9 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -663,6 +663,16 @@ role: edit-grants: title: Edit Grants description: Modify existing grant strings or add new grant strings to this role. + no-suggestions: No suggestions + completion-info: + wildcard-types: Wildcard - matches all types + wildcard-ids: Wildcard - matches all IDs + wildcard-actions: Wildcard - matches all actions + template-value: Template value + all-fields: All fields + editor: + title: Grant Editor + description: Add each new grant string on a different line string-formats: title: String format title_plural: String formats @@ -682,6 +692,7 @@ role: insert-resource-ids: insert resource ids export: format: Format + formatted-export-aria-label: Formatted export options: terraform: Terraform native-hcl: Native HCL diff --git a/ui/admin/app/components/form/role/edit-grants/index.hbs b/ui/admin/app/components/form/role/edit-grants/index.hbs index c991e8b65..4e8273374 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.hbs +++ b/ui/admin/app/components/form/role/edit-grants/index.hbs @@ -3,14 +3,58 @@ SPDX-License-Identifier: BUSL-1.1 }} - + - - {{! TODO: Add grants code editor. }} + + + + {{t 'resources.role.edit-grants.editor.title'}} + + + {{t 'resources.role.edit-grants.editor.description'}} + + + + <:actions> + + + + {{#if this.grantStringsText}} + + {{/if}} + + + @@ -55,7 +99,9 @@ {{/if}} - - - <:actions> - - - - {{#if this.grantStringLines}} - - {{/if}} - - - \ No newline at end of file diff --git a/ui/admin/app/components/form/role/edit-grants/index.js b/ui/admin/app/components/form/role/edit-grants/index.js index 52f4463b7..e24f290cf 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.js +++ b/ui/admin/app/components/form/role/edit-grants/index.js @@ -4,22 +4,69 @@ */ import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { + autocompletion, + completionKeymap, + keymap, +} from '@hashicorp/design-system-components/codemirror'; + +import { createGrantCompletionSource } from 'admin/utils/grant-completions'; + +export default class FormRoleEditGrantsComponent extends Component { + @service intl; -export default class FormRoleEditGrants extends Component { // =attributes exportOptionsMap = { terraform: 'terraform', nativeHCL: 'native-hcl' }; exportOptions = Object.values(this.exportOptionsMap); + completionTranslatedStrings = { + noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'), + wildcardTypes: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-types', + ), + wildcardIds: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-ids', + ), + templateValue: this.intl.t( + 'resources.role.edit-grants.completion-info.template-value', + ), + wildcardActions: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-actions', + ), + allFields: this.intl.t( + 'resources.role.edit-grants.completion-info.all-fields', + ), + }; - // TODO: Replace with actual grant lines from code editor once implemented. - grantStringLines = - 'ids=hc_123;type=host-catalog;actions=read,create,list\nids=ttcp_123;type=target;actions=list\ntype=credential;actions=create'; + completionSource = createGrantCompletionSource( + this.args.grantsSchema, + this.completionTranslatedStrings, + ); + @tracked grantStringsText = (this.args.model?.grant_strings ?? []).join('\n'); + @tracked currentLineText = this.args.model?.grant_strings?.[0] ?? ''; @tracked showExportOptionsFlyout = false; @tracked selectedExportOption = this.exportOptions[0]; + customExtensions = [ + autocompletion({ + override: [this.completionSource], + // Trigger autocompletion when the user completes a grant field (which we labeled as keywords) + activateOnCompletion: (completion) => completion.type === 'keyword', + }), + keymap.of(completionKeymap), + ]; + + get grantStrings() { + return this.grantStringsText + .split('\n') + .map((grantString) => grantString.trim()) + .filter(Boolean); + } + /** * Returns the formatted export based on the selected export option. * @type {string} @@ -37,7 +84,7 @@ export default class FormRoleEditGrants extends Component { */ get terraformFormattedExport() { let formatted = `grant_strings = [ \n`; - this.grantStringLines.split('\n').forEach((line) => { + this.grantStringsText.split('\n').forEach((line) => { formatted += ` "${line}",\n`; }); formatted += `]\n`; @@ -50,7 +97,7 @@ export default class FormRoleEditGrants extends Component { */ get nativeHclFormattedExport() { let formatted = `[ \n`; - this.grantStringLines.split('\n').forEach((line) => { + this.grantStringsText.split('\n').forEach((line) => { formatted += ` "${line}",\n`; }); formatted += `]\n`; @@ -59,6 +106,14 @@ export default class FormRoleEditGrants extends Component { // =actions + @action + onInput(value, view) { + this.grantStringsText = value; + + const line = view.state.doc.lineAt(view.state.selection.main.head); + this.currentLineText = line.text; + } + /** * Toggles the export options flyout open and closed. */ diff --git a/ui/admin/app/controllers/scopes/scope/roles/role/edit-grants.js b/ui/admin/app/controllers/scopes/scope/roles/role/edit-grants.js new file mode 100644 index 000000000..6ae1f6c5c --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/roles/role/edit-grants.js @@ -0,0 +1,38 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; + +export default class ScopesScopeRolesRoleEditGrantsController extends Controller { + @service router; + + /** + * Save grant strings on a role. + * @param {RoleModel} role + * @param {[string]} grantStrings + */ + @action + @loading + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.save-success') + async save(role, grantStrings) { + await role.saveGrantStrings(grantStrings); + this.router.replaceWith('scopes.scope.roles.role.grants'); + } + + /** + * Rollback changes on a role. + * @param {RoleModel} role + */ + @action + async cancel(role) { + role.rollbackAttributes(); + this.router.replaceWith('scopes.scope.roles.role.grants'); + } +} diff --git a/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js b/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js index 74dc74dcb..8173a1d91 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js @@ -4,5 +4,33 @@ */ import Route from '@ember/routing/route'; +import { service } from '@ember/service'; -export default class ScopesScopeRolesRoleEditGrantsRoute extends Route {} +export default class ScopesScopeRolesRoleEditGrantsRoute extends Route { + @service store; + + async model() { + const role = this.modelFor('scopes.scope.roles.role'); + let grantsSchema = { resource_types: [] }; + + try { + grantsSchema = await this.fetchGrantsSchema(); + } catch { + // TODO: Return an error message + } + + return { role, grantsSchema }; + } + + async fetchGrantsSchema() { + const adapter = this.store.adapterFor('application'); + const url = `${adapter.host ?? ''}/grants-schema.json`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch grants schema'); + } + + return response.json(); + } +} diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 558fdd3e8..16742322a 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -996,3 +996,9 @@ } } } + +.grant-editor { + .hds-code-editor__editor { + height: 200px; + } +} diff --git a/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs b/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs index b2ff25c2a..f02b67391 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs @@ -28,7 +28,12 @@ - + \ No newline at end of file diff --git a/ui/admin/app/utils/grant-completions.js b/ui/admin/app/utils/grant-completions.js new file mode 100644 index 000000000..3a7640695 --- /dev/null +++ b/ui/admin/app/utils/grant-completions.js @@ -0,0 +1,375 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields']; +const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}']; +const filterByPrefix = (values, partial) => + values.filter((value) => value.startsWith(partial)); +const withWildCard = (values = []) => ['*', ...new Set(values)]; +const parseIds = (idsValue = '') => idsValue.split(',').filter(Boolean); + +const getNoSuggestionsOption = (noSuggestionsLabel) => ({ + label: noSuggestionsLabel, + type: 'text', + apply: () => {}, +}); + +const parseGrantFields = (lineText) => + lineText + .split(';') + .map((pair) => { + const [fieldName, ...valueParts] = pair.split('='); + + // There should only be one '=' in a valid pair, but if there are more, + // we'll keep the value parts together and let the user know it's + // invalid through validation rather than breaking the parsing + return { + fieldName: fieldName, + fieldValue: valueParts.join('='), + }; + }) + .filter(({ fieldName }) => fieldName); + +const normalizeGrantsSchema = (grantsSchema) => { + const resourceTypes = grantsSchema.resource_types ?? []; + + const resourcesByType = resourceTypes.reduce((resources, resourceType) => { + const collectionActions = resourceType.collection_actions ?? []; + const idActions = resourceType.id_actions ?? []; + const idPrefixes = resourceType.id_prefixes ?? []; + + resources[resourceType.type] = { + actions: [...collectionActions, ...idActions], + collectionActions, + idActions, + idPrefixes, + parentType: resourceType.parent_type, + }; + + return resources; + }, {}); + + const childResourceTypesByParentType = resourceTypes.reduce( + (childrenByParentType, resourceType) => { + const parentType = resourceType.parent_type; + + if (!parentType) { + return childrenByParentType; + } + + childrenByParentType[parentType] = [ + ...(childrenByParentType[parentType] ?? []), + resourceType.type, + ]; + + return childrenByParentType; + }, + {}, + ); + + const resourceTypesByIdPrefix = resourceTypes.flatMap((resourceType) => + (resourceType.id_prefixes ?? []).map((idPrefix) => ({ + idPrefix, + type: resourceType.type, + })), + ); + + return { + resourceTypes, + resourcesByType, + childResourceTypesByParentType, + resourceTypesByIdPrefix, + }; +}; + +const getResourceTypesForId = (schema, id) => + id.includes('_') + ? [ + schema.resourceTypesByIdPrefix.find( + ({ idPrefix }) => id.split('_')[0] === idPrefix, + )?.type, + ].filter(Boolean) + : schema.resourceTypesByIdPrefix + .filter(({ idPrefix }) => idPrefix.startsWith(id)) + .map(({ type }) => type); + +const getCompatibleResourceTypeForIds = (schema, idsValue) => { + const resourceTypesById = parseIds(idsValue).flatMap((id) => + getResourceTypesForId(schema, id), + ); + const [firstResourceType] = resourceTypesById; + + if ( + !firstResourceType || + !resourceTypesById.every( + (resourceType) => firstResourceType === resourceType, + ) + ) { + return null; + } + + return firstResourceType; +}; + +const getIdActions = (schema, idsValue) => { + const matchedType = getCompatibleResourceTypeForIds(schema, idsValue); + + if (!matchedType) { + return []; + } + + return schema.resourcesByType[matchedType]?.idActions; +}; + +const getChildResourceActions = (schema, idsValue) => { + const parentType = getCompatibleResourceTypeForIds(schema, idsValue); + + if ( + !parentType || + !schema.childResourceTypesByParentType[parentType]?.length + ) { + return []; + } + + const childTypes = schema.childResourceTypesByParentType[parentType] ?? []; + + return [ + ...new Set( + childTypes.flatMap((type) => schema.resourcesByType[type]?.actions ?? []), + ), + ]; +}; + +const getTypeOptions = (schema, idsValue) => { + // If there is no ids value, we can only suggest top-level resource types that have collection actions + if (!idsValue) { + return schema.resourceTypes + .filter( + (resource) => + !resource.parent_type && + resource.collection_actions.some( + (action) => action === 'list' || action === 'create', + ), + ) + .map((resource) => resource.type); + } + + const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*'); + if (hasSpecificIds) { + const parentType = getCompatibleResourceTypeForIds(schema, idsValue); + + if (!parentType) { + return []; + } + + return schema.childResourceTypesByParentType[parentType] ?? []; + } + + // Return the ones that have id based actions (currently only billing doesn't have them) + return schema.resourceTypes + .filter((resource) => resource.id_actions?.length > 0) + .map((resource) => resource.type); +}; + +const getActionOptions = (schema, typeValue, idsValue) => { + const actionOptions = withWildCard( + Object.values(schema.resourcesByType).flatMap(({ actions }) => actions), + ); + + const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*'); + const hasOnlyType = Boolean(typeValue) && !idsValue; + const selectedResource = schema.resourcesByType[typeValue]; + + // Just show ID specific actions as collection actions are invalid here + if (!typeValue && hasSpecificIds) { + const idActions = getIdActions(schema, idsValue); + return idActions.length ? withWildCard(idActions) : []; + } + + if (!typeValue || (typeValue === '*' && idsValue === '*')) { + return actionOptions; + } + + if (hasOnlyType) { + if (selectedResource?.parentType) { + return []; + } + + return selectedResource?.collectionActions ?? []; + } + + if (hasSpecificIds) { + // These are pinned IDs with a wildcard type + if (typeValue === '*') { + const childResourceActions = getChildResourceActions(schema, idsValue); + + return childResourceActions.length + ? withWildCard(childResourceActions) + : []; + } + + const matchedType = getCompatibleResourceTypeForIds(schema, idsValue); + const isSelectedChildType = ( + schema.childResourceTypesByParentType[matchedType] ?? [] + ).includes(typeValue); + + // A specific ID paired with its own type is not a valid combination. + // For pinned IDs, only child types are valid. + if (!selectedResource || !isSelectedChildType) { + return []; + } + + // Return all the actions for the selected type from a pinned ID + return withWildCard([ + ...selectedResource.collectionActions, + ...selectedResource.idActions, + ]); + } + + // Should be wildcard IDs with a specific type, so show all actions for that type + return selectedResource + ? withWildCard(selectedResource.actions) + : actionOptions; +}; + +function grantCompletions(context, schema, translatedStrings) { + const line = context.state.doc.lineAt(context.pos); + const beforeCursor = line.text.slice(0, context.pos - line.from); + + const parsedFields = parseGrantFields(line.text); + const parsedFieldNames = parsedFields.map(({ fieldName }) => fieldName); + + const idsValue = parsedFields.find( + ({ fieldName }) => fieldName === 'ids', + )?.fieldValue; + const typeValue = parsedFields.find( + ({ fieldName }) => fieldName === 'type', + )?.fieldValue; + + // Check if we're typing a field name (e.g. "type=") by starting at the beginning or after a semicolon. + // This allows us to provide field name suggestions when the user is typing a new field + // and restrict suggestions to only fields that haven't been used yet in the current line + const fieldNameMatch = beforeCursor.match(/(?:^|;)([a-z_]*)$/); + if (fieldNameMatch) { + const partial = fieldNameMatch[1]; + const options = filterByPrefix( + GRANT_FIELDS.filter((field) => !parsedFieldNames.includes(field)), + partial, + ).map((field) => ({ + label: `${field}=`, + type: 'keyword', + })); + + return { + from: context.pos - partial.length, + options, + filter: false, // This is to preserve the original order of our array and not let codemirror sort it + }; + } + + // Check if we're providing a value for the "type" field + const typeValueMatch = beforeCursor.match(/type=([a-z-*]*)$/); + if (typeValueMatch) { + const partial = typeValueMatch[1]; + const matchingTypeOptions = getTypeOptions(schema, idsValue); + const typeOptions = + partial === '' && matchingTypeOptions.length + ? withWildCard(matchingTypeOptions) + : matchingTypeOptions; + const options = filterByPrefix(typeOptions, partial).map((type) => ({ + label: type, + type: 'type', + info: type === '*' ? translatedStrings.wildcardTypes : '', + })); + + return { + from: context.pos - partial.length, + options: options.length + ? options + : [getNoSuggestionsOption(translatedStrings.noSuggestions)], + }; + } + + const idsValueMatch = beforeCursor.match(/ids=([a-z0-9_*.{},-]*)$/); + if (idsValueMatch) { + const enteredIds = idsValueMatch[1].split(','); + const partial = enteredIds.pop() ?? ''; + const hasEnteredIds = enteredIds.length > 0; + // TODO: Add loaded ID suggestions here? Logic below will change + + return { + from: context.pos - partial.length, + options: filterByPrefix(['*', ...ID_TEMPLATES], partial) + .filter((value) => !hasEnteredIds || value !== '*') + .filter((value) => !enteredIds.includes(value)) + .map((value) => ({ + label: value, + type: value === '*' ? 'constant' : 'interface', + info: + value === '*' + ? translatedStrings.wildcardIds + : translatedStrings.templateValue, + })), + }; + } + + const actionsValueMatch = beforeCursor.match(/actions=([a-z0-9_:,*-]*)$/); + + if (actionsValueMatch) { + const enteredActions = actionsValueMatch[1].split(','); + const partial = enteredActions.pop() ?? ''; + const hasEnteredActions = enteredActions.length > 0; + const options = filterByPrefix( + getActionOptions(schema, typeValue, idsValue), + partial, + ) + .filter((action) => !hasEnteredActions || action !== '*') + .filter((action) => !enteredActions.includes(action)) + .map((action) => ({ + label: action, + type: 'constant', + info: action === '*' ? translatedStrings.wildcardActions : '', + })); + + return { + from: context.pos - partial.length, + options: options.length + ? options + : [getNoSuggestionsOption(translatedStrings.noSuggestions)], + }; + } + + const outputFieldsMatch = beforeCursor.match( + /output_fields=([a-z0-9_,*-]*)$/, + ); + + if (outputFieldsMatch) { + const enteredOutputFields = outputFieldsMatch[1].split(','); + const partial = enteredOutputFields.pop() ?? ''; + const hasEnteredOutputFields = enteredOutputFields.length > 0; + + return { + from: context.pos - partial.length, + options: filterByPrefix(['*'], partial) + .filter((value) => !hasEnteredOutputFields || value !== '*') + .map((value) => ({ + label: value, + type: 'constant', + info: translatedStrings.allFields, + })), + }; + } + + return null; +} + +export const createGrantCompletionSource = ( + grantsSchema, + translatedStrings, +) => { + const schema = normalizeGrantsSchema(grantsSchema); + + return (context) => grantCompletions(context, schema, translatedStrings); +}; diff --git a/ui/admin/tests/acceptance/roles/edit-grants-test.js b/ui/admin/tests/acceptance/roles/edit-grants-test.js index 0365176e2..11b897ef0 100644 --- a/ui/admin/tests/acceptance/roles/edit-grants-test.js +++ b/ui/admin/tests/acceptance/roles/edit-grants-test.js @@ -4,9 +4,10 @@ */ import { module, test } from 'qunit'; -import { visit, click, currentURL, select } from '@ember/test-helpers'; +import { visit, click, currentURL, find, select, waitFor } from '@ember/test-helpers'; import { setupApplicationTest } from 'admin/tests/helpers'; import * as selectors from './selectors'; +import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; module('Acceptance | roles/edit grants', function (hooks) { @@ -28,6 +29,10 @@ module('Acceptance | roles/edit grants', function (hooks) { instances.scopes.global = this.server.schema.scopes.find('global'); instances.role = this.server.create('role', { scope: instances.scopes.global, + grant_strings: [ + 'ids=*;type=role;actions=read', + 'ids=ttcp_1234567890;type=target;actions=authorize-session', + ], }); urls.role = `/scopes/global/roles/${instances.role.id}`; urls.editGrants = `${urls.role}/edit-grants`; @@ -122,4 +127,43 @@ module('Acceptance | roles/edit grants', function (hooks) { assert.dom(selectors.EXPORT_FORMAT_SELECT).hasValue('native-hcl'); // TODO: Update to check for the actual formatted grant string. }); + + test('loads current grants in the editor and updates them', async function (assert) { + assert.expect(4); + const newGrantString = 'ids=*;type=session;actions=list,read'; + const expectedGrantStrings = [ + ...instances.role.grant_strings, + newGrantString, + ]; + + await visit(urls.editGrants); + await waitFor(commonSelectors.CODE_EDITOR_CM); + + assert + .dom(commonSelectors.CODE_EDITOR_CONTENT) + .includesText(instances.role.grant_strings[0]); + assert + .dom(commonSelectors.CODE_EDITOR_CONTENT) + .includesText(instances.role.grant_strings[1]); + + const editorElement = find(commonSelectors.CODE_EDITOR_CODE); + const editorView = editorElement.editor; + editorView.dispatch({ + changes: { + from: editorView.state.doc.length, + insert: `\n${newGrantString}`, + }, + }); + + assert + .dom(commonSelectors.CODE_EDITOR_CONTENT) + .includesText(newGrantString); + + await click(commonSelectors.SAVE_BTN); + + assert.deepEqual( + this.server.schema.roles.find(instances.role.id).attrs.grantStrings, + expectedGrantStrings, + ); + }); }); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/edit-grants-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/edit-grants-test.js new file mode 100644 index 000000000..83f61be0c --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/edit-grants-test.js @@ -0,0 +1,87 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'admin/tests/helpers/mirage'; +import { setupIntl } from 'ember-intl/test-support'; +import { authenticateSession } from 'ember-simple-auth/test-support'; +import sinon from 'sinon'; + +module( + 'Unit | Controller | scopes/scope/roles/role/edit-grants', + function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en-us'); + + let store; + let controller; + let router; + let replaceWithStub; + + const instances = { + scopes: { + global: null, + }, + role: null, + }; + hooks.beforeEach(async function () { + store = this.owner.lookup('service:store'); + router = this.owner.lookup('service:router'); + replaceWithStub = sinon.stub(router, 'replaceWith'); + controller = this.owner.lookup( + 'controller:scopes/scope/roles/role/edit-grants', + ); + + instances.scopes.global = this.server.create( + 'scope', + { id: 'global' }, + 'withGlobalAuth', + ); + await authenticateSession({ + isGlobal: true, + account_id: this.server.schema.accounts.first().id, + }); + instances.role = this.server.create('role', { + scope: instances.scopes.global, + }); + }); + + test('it exists', function (assert) { + assert.ok(controller); + }); + + test('cancel action rolls-back changes on the specified model', async function (assert) { + const role = await store.findRecord('role', instances.role.id); + const originalGrantStrings = [...role.grant_strings]; + const updatedGrantStrings = [ + ...originalGrantStrings, + 'ids=*;type=*;actions=read', + ]; + role.grant_strings = updatedGrantStrings; + + assert.notDeepEqual(role.grant_strings, originalGrantStrings); + assert.deepEqual(role.grant_strings, updatedGrantStrings); + + await controller.cancel(role); + + assert.deepEqual(role.grant_strings, originalGrantStrings); + assert.ok( + replaceWithStub.calledOnceWith('scopes.scope.roles.role.grants'), + ); + }); + + test('save action saves grantStrings to specified model', async function (assert) { + const role = await store.findRecord('role', instances.role.id); + const grantStrings = role.grant_strings; + const newGrantStrings = [...grantStrings, 'ids=*;type=*;actions=read']; + + await controller.save(role, newGrantStrings); + + assert.deepEqual(role.grant_strings, newGrantStrings); + }); + }, +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/grants-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/grants-test.js index 0d39af43e..72611341a 100644 --- a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/grants-test.js +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/grants-test.js @@ -52,6 +52,7 @@ module('Unit | Controller | scopes/scope/roles/role/grants', function (hooks) { assert.ok(controller.roles); }); + // TODO: Will be removed once this view becomes read only test('cancel action rolls-back changes on the specified model', async function (assert) { await visit(urls.grants); const role = await store.findRecord('role', instances.role.id); diff --git a/ui/admin/tests/unit/utils/grant-completions-test.js b/ui/admin/tests/unit/utils/grant-completions-test.js new file mode 100644 index 000000000..872d355b0 --- /dev/null +++ b/ui/admin/tests/unit/utils/grant-completions-test.js @@ -0,0 +1,355 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupIntl } from 'ember-intl/test-support'; +import { setupMirage } from 'admin/tests/helpers/mirage'; + +import { createGrantCompletionSource } from 'admin/utils/grant-completions'; + +const createCompletionContext = (lineText) => ({ + pos: lineText.length, + state: { + doc: { + lineAt() { + return { + text: lineText, + from: 0, + }; + }, + }, + }, +}); + +const getLabels = (completionResult) => + completionResult.options.map(({ label }) => label); +const getInfos = (completionResult) => + completionResult.options.map(({ info }) => info ?? ''); + +let allActionLabels; + +module('Unit | Utils | grant-completions', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(async function () { + this.intl = this.owner.lookup('service:intl'); + this.completionTranslatedStrings = { + noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'), + wildcardTypes: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-types', + ), + wildcardIds: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-ids', + ), + wildcardActions: this.intl.t( + 'resources.role.edit-grants.completion-info.wildcard-actions', + ), + templateValue: this.intl.t( + 'resources.role.edit-grants.completion-info.template-value', + ), + allFields: this.intl.t( + 'resources.role.edit-grants.completion-info.all-fields', + ), + }; + this.noSuggestionsLabel = this.completionTranslatedStrings.noSuggestions; + + const response = await fetch('/grants-schema.json'); + + if (!response.ok) { + throw new Error(`Failed to fetch grants schema: ${response.status}`); + } + + const grantsSchemaData = await response.json(); + allActionLabels = grantsSchemaData.resource_types.reduce( + (labels, resource) => { + const resourceLabels = [ + ...(resource.collection_actions ?? []), + ...(resource.id_actions ?? []), + ]; + return [...new Set([...labels, ...resourceLabels])]; + }, + [], + ); + allActionLabels = ['*', ...allActionLabels]; + + this.grantCompletionSource = createGrantCompletionSource( + grantsSchemaData, + this.completionTranslatedStrings, + ); + }); + + test.each( + 'type suggestions', + { + 'without ids only include top-level collection resource types': { + lineText: 'type=', + expectedLabels: [ + '*', + 'auth-method', + 'session', + 'credential-store', + 'alias', + 'user', + 'auth-token', + 'target', + 'worker', + 'group', + 'role', + 'host-catalog', + 'storage-bucket', + 'policy', + 'scope', + 'session-recording', + ], + }, + 'with specific ids only include child resource types': { + lineText: 'ids=hcst_1234567890;type=', + expectedLabels: ['*', 'host', 'host-set'], + }, + 'with a partial id prefix include child resource types for the matched parent': + { + lineText: 'ids=hcs;type=', + expectedLabels: ['*', 'host', 'host-set'], + }, + 'with specific ids and no child resources show a no suggestions placeholder': + { + lineText: 'ids=hs_123;type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'with mixed resource ids show a no suggestions placeholder': { + lineText: 'ids=hs_123,s_123;type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'with specific ids and a partial type with no child resources show a no suggestions placeholder': + { + lineText: 'ids=hs_123;type=h', + expectedLabels: ['NO_SUGGESTIONS'], + }, + }, + function (assert, { lineText, expectedLabels }) { + const completionResult = this.grantCompletionSource( + createCompletionContext(lineText), + ); + const resolvedLabels = expectedLabels.map((label) => + label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label, + ); + + assert.deepEqual(getLabels(completionResult), resolvedLabels); + }, + ); + + test.each( + 'action suggestions', + { + 'type-only grants only suggest collection actions for top-level resource types': + { + lineText: 'type=scope;actions=', + expectedLabels: [ + 'destroy-key-version', + 'create', + 'list', + 'list-keys', + 'rotate-keys', + 'list-key-version-destruction-jobs', + ], + }, + 'type-only grants suggest nothing for child resource types': { + lineText: 'type=host;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'specific ids without a type only suggest id actions for the resource type': + { + lineText: 'ids=hs_123;actions=', + expectedLabels: [ + '*', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + 'read', + 'update', + ], + }, + 'comma-separated ids of the same resource type suggest that type id actions': + { + lineText: 'ids=hs_123,hs_456;actions=', + expectedLabels: [ + '*', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + 'read', + 'update', + ], + }, + 'ids with a matching type show a no suggestions placeholder': { + lineText: 'ids=s_123;type=session;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'ids that conflict with the selected type show a no suggestions placeholder': + { + lineText: 'ids=hs_123;type=session;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'comma-separated ids with conflicting resource types show a no suggestions placeholder': + { + lineText: 'ids=hs_123,s_123;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'pinned ids with a specific child resource type suggest that type actions': + { + lineText: 'ids=hcst_1234567890;type=host-set;actions=', + expectedLabels: [ + '*', + 'create', + 'list', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + 'read', + 'update', + ], + }, + 'wildcard ids with a specific child resource type suggest that type actions': + { + lineText: 'ids=*;type=host-set;actions=', + expectedLabels: [ + '*', + 'create', + 'list', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + 'read', + 'update', + ], + }, + 'ids that are a top level resource that has child resources with a wildcard type suggest the union of child resource actions': + { + lineText: 'ids=hcst_1234567890;type=*;actions=', + expectedLabels: [ + '*', + 'create', + 'list', + 'read', + 'update', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + ], + }, + 'a partial parent id prefix with a wildcard type suggests child resource actions': + { + lineText: 'ids=hcs;type=*;actions=', + expectedLabels: [ + '*', + 'create', + 'list', + 'read', + 'update', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + ], + }, + 'a partial parent id prefix with the same type shows a no suggestions placeholder': + { + lineText: 'ids=hcs;type=host-catalog;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'ids from different parent resource types with a wildcard type show a no suggestions placeholder': + { + lineText: 'ids=hcst_1234567890,o_123;type=*;actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + 'wildcard ids without a type suggest all actions': { + lineText: 'ids=*;actions=', + expectedLabels: () => allActionLabels, + }, + 'wildcard ids with a wildcard type suggest all actions': { + lineText: 'ids=*;type=*;actions=', + expectedLabels: () => allActionLabels, + }, + 'canonical template ids without a type show a no suggestions placeholder': + { + lineText: 'ids={{.User.Id}};actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + }, + function (assert, { lineText, expectedLabels }) { + const completionResult = this.grantCompletionSource( + createCompletionContext(lineText), + ); + const resolvedLabels = ( + typeof expectedLabels === 'function' ? expectedLabels() : expectedLabels + ).map((label) => + label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label, + ); + + assert.deepEqual(getLabels(completionResult), resolvedLabels); + }, + ); + + test.each( + 'ids suggestions', + { + 'include supported template values': { + lineText: 'ids={{', + expectedLabels: ['{{.User.Id}}', '{{.Account.Id}}'], + }, + }, + function (assert, { lineText, expectedLabels }) { + const completionResult = this.grantCompletionSource( + createCompletionContext(lineText), + ); + + assert.deepEqual(getLabels(completionResult), expectedLabels); + }, + ); + + test('completion info labels are translated values', function (assert) { + const typeCompletionResult = this.grantCompletionSource( + createCompletionContext('type='), + ); + const idsCompletionResult = this.grantCompletionSource( + createCompletionContext('ids='), + ); + const outputFieldsCompletionResult = this.grantCompletionSource( + createCompletionContext('output_fields='), + ); + const actionsCompletionResult = this.grantCompletionSource( + createCompletionContext('actions='), + ); + + assert.strictEqual( + typeCompletionResult.options[0].info, + this.completionTranslatedStrings.wildcardTypes, + ); + + assert.deepEqual(getInfos(idsCompletionResult), [ + this.completionTranslatedStrings.wildcardIds, + this.completionTranslatedStrings.templateValue, + this.completionTranslatedStrings.templateValue, + ]); + + assert.strictEqual( + outputFieldsCompletionResult.options[0].info, + this.completionTranslatedStrings.allFields, + ); + + assert.strictEqual( + actionsCompletionResult.options[0].info, + this.completionTranslatedStrings.wildcardActions, + ); + }); +});