diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index 9d4115b12..9f66ac696 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -24,6 +24,7 @@ titles: empty-set: No Items Yet global: Global actions: Actions + action: Action documentation: Documentation abandon-confirm: Discard unsaved changes? welcome-to-boundary: Welcome to Boundary diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 92493a6b8..e4119f53d 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -753,6 +753,76 @@ role: options: terraform: Terraform native-hcl: Native HCL + actions: + collection-actions: Collection Actions + id-actions: ID Actions + create: Create a new {resourceType} + list: List all {resourceType} resources + read: Read a {resourceType} + update: Update a {resourceType} + delete: Delete a {resourceType} + read:self: Read the {resourceType} associated with the current user + delete:self: Delete a {resourceType} associated with the current user + cancel: Cancel a {resourceType} + cancel:self: Cancel a {resourceType} associated with the current user + generic: + create: Creates resources + list: Lists resources + read: Reads resources + update: Updates resources + delete: Deletes resources + read:self: Read the resource associated with the current user + delete:self: Delete a resource associated with the current user + add-members: Add members to a group + set-members: Set the full list of members on a group + remove-members: Remove members from a group + add-hosts: Add hosts to a host set + set-hosts: Set the full list of hosts on a host set + remove-hosts: Remove hosts from a host set + add-worker-tags: Add tags to a worker + set-worker-tags: Set the full list of tags on a worker + remove-worker-tags: Remove tags from a worker + reinitialize-certificate-authority: Reinitialize the certificate authority that is used to authorize Boundary workers + create:controller-led: Create a worker via controller-led workflow + create:worker-led: Create a worker via worker-led workflow + read-certificate-authority: Read the certificate authority that is used to authorize Boundary workers + set-password: Set a password on an account, without requiring the current password + change-password: Change the password on an account + authenticate: Authenticate to an auth method + change-state: Change the active and visibility state of an OIDC-type auth method + add-accounts: Add accounts to a user + set-accounts: Set the full list of accounts on a user + remove-accounts: Remove accounts from a user + list-resolvable-aliases: List all aliases that point to resources the user has permission to access + add-principals: Add principals to a role + set-principals: Set the full list of principals on a role + remove-principals: Remove principals from a role + set-grants: Set the full list of grants on a role + add-grants: Add grants to a role + remove-grants: Remove grants from a role + add-grant-scopes: Add grant scopes to a role + set-grant-scopes: Set the full list of grant scopes on a role + remove-grant-scopes: Remove grant scopes from a role + authorize-session: Authorize a session via a target + add-host-sources: Add host sources to a target + set-host-sources: Set the full list of host sources on a target + remove-host-sources: Remove host sources from a target + add-credential-sources: Add credential sources to a target + set-credential-sources: Set the full list of credential sources on a target + remove-credential-sources: Remove credential sources from a target + attach-storage-policy: Attach a storage policy to all session recordings in the scope + detach-storage-policy: Detach a storage policy from all session recordings in the scope + list-keys: List the keys within a given scope + rotate-keys: Replace a scope's current KEK and DEKs with a new set of keys + list-key-version-destruction-jobs: List all pending key version destruction jobs within a scope + destroy-key-version: Destroy a key version in the scope + download: Download a session recording + reapply-storage-policy: Reapply the storage policy to a session recording + monthly-active-users: Display the number of monthly active Boundary users to help predict billing + no-actions: No actions available for this resource type. + invalid-id-or-type: Invalid ID and type. No actions available. + no-type-detected: No resource type detected. + unavailable: Grant actions information not available. scope: title: Scope title_plural: Scopes 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 86949c136..2d40a3876 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.hbs +++ b/ui/admin/app/components/form/role/edit-grants/index.hbs @@ -21,7 +21,6 @@ @hasFullScreenButton={{true}} @ariaLabel={{t 'form.secret-editor.label'}} @value={{this.grantStringsText}} - @onInput={{this.onInput}} @onSetup={{this.onSetup}} @customExtensions={{this.customExtensions}} @cspNonce={{(csp-nonce)}} @@ -68,6 +67,17 @@ + + <:toggle>{{t 'titles.actions'}} + <:content> +
+ +
+ +
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 4ba76354b..577904350 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.js +++ b/ui/admin/app/components/form/role/edit-grants/index.js @@ -11,6 +11,7 @@ import { autocompletion, completionKeymap, keymap, + EditorView, linter, lintGutter, lintKeymap, @@ -172,6 +173,15 @@ export default class FormRoleEditGrantsComponent extends Component { // Trigger autocompletion when the user completes a grant field (which we labeled as keywords) activateOnCompletion: (completion) => completion.type === 'keyword', }), + EditorView.updateListener.of((update) => { + if (update.docChanged || update.selectionSet) { + const line = update.state.doc.lineAt(update.state.selection.main.head); + this.currentLineText = line.text; + if (update.docChanged) { + this.grantStringsText = update.state.doc.toString(); + } + } + }), linter(this.linterSource), lintGutter(), keymap.of([...completionKeymap, ...lintKeymap]), @@ -233,14 +243,6 @@ export default class FormRoleEditGrantsComponent extends Component { this.#editorView = view; } - @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/components/grant-actions/index.hbs b/ui/admin/app/components/grant-actions/index.hbs new file mode 100644 index 000000000..6f2271545 --- /dev/null +++ b/ui/admin/app/components/grant-actions/index.hbs @@ -0,0 +1,41 @@ +{{! + Copyright IBM Corp. 2021, 2026 + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if this.actionRows.length}} + + <:body as |B|> + + {{B.data.name}} + {{B.data.description}} + + + +{{else if this.showInvalidIdAndType}} + + + {{t 'resources.role.edit-grants.actions.invalid-id-or-type'}} + + +{{else if this.showNoResourceTypeDetected}} + + + {{t 'resources.role.edit-grants.actions.no-type-detected'}} + + +{{else}} + + + {{t 'resources.role.edit-grants.completion-info.no-suggestions'}} + + +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/components/grant-actions/index.js b/ui/admin/app/components/grant-actions/index.js new file mode 100644 index 000000000..18191cc46 --- /dev/null +++ b/ui/admin/app/components/grant-actions/index.js @@ -0,0 +1,69 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; +import { service } from '@ember/service'; + +import { analyzeGrantString } from 'admin/utils/grant-completions'; + +export default class GrantActionsIndex extends Component { + @service intl; + + @cached + get grantAnalysis() { + return analyzeGrantString( + this.args.grantsSchema ?? {}, + this.args.grantString, + ); + } + + get actionRows() { + return this.grantAnalysis.actions.map((action) => ({ + name: action, + description: this.getActionDescription(action), + })); + } + + get showNoResourceTypeDetected() { + const { actions, detectedResourceType } = this.grantAnalysis; + return ( + !this.showInvalidIdAndType && !actions.length && !detectedResourceType + ); + } + + get showInvalidIdAndType() { + const { + actions, + hasInvalidType, + hasInvalidIds, + hasInvalidPinnedIdTypeCombination, + } = this.grantAnalysis; + return ( + !actions.length && + (hasInvalidType || hasInvalidIds || hasInvalidPinnedIdTypeCombination) + ); + } + + getActionDescription(action) { + const { detectedResourceType } = this.grantAnalysis; + const specificKey = `resources.role.edit-grants.actions.${action}`; + const genericKey = `resources.role.edit-grants.actions.generic.${action}`; + + if (detectedResourceType && this.intl.exists(specificKey)) { + return this.intl.t(specificKey, { resourceType: detectedResourceType }); + } + + if (this.intl.exists(genericKey)) { + return this.intl.t(genericKey); + } + + if (this.intl.exists(specificKey)) { + return this.intl.t(specificKey); + } + + return action; + } +} diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index c5f186af5..9396d2bf7 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -1008,3 +1008,8 @@ height: 200px; } } + +.grant-actions-scroll-container { + max-height: 50vh; + overflow-y: auto; +} diff --git a/ui/admin/app/utils/grant-completions.js b/ui/admin/app/utils/grant-completions.js index dc55c1b44..0ad07ffce 100644 --- a/ui/admin/app/utils/grant-completions.js +++ b/ui/admin/app/utils/grant-completions.js @@ -5,6 +5,7 @@ const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields']; const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}']; +const CRUDL_ACTIONS = new Set(['create', 'read', 'update', 'delete', 'list']); const TEMPLATE_RESOURCE_TYPES = { '{{.User.Id}}': 'user', '{{.Account.Id}}': 'account', @@ -22,7 +23,7 @@ const getNoSuggestionsOption = (noSuggestionsLabel) => ({ apply: () => {}, }); -const parseGrantFields = (lineText) => +const parseGrantFields = (lineText = '') => lineText .split(';') .map((pair) => { @@ -38,8 +39,24 @@ const parseGrantFields = (lineText) => }) .filter(({ fieldName }) => fieldName); -const normalizeGrantsSchema = (grantsSchema) => { - const resourceTypes = grantsSchema?.resource_types ?? []; +const getGrantFieldValue = (parsedFields, targetFieldName) => + parsedFields.find(({ fieldName }) => fieldName === targetFieldName) + ?.fieldValue; + +const parseGrantLine = (lineText = '') => { + const parsedFields = parseGrantFields(lineText); + + return { + parsedFields, + idsValue: getGrantFieldValue(parsedFields, 'ids'), + typeValue: getGrantFieldValue(parsedFields, 'type'), + actionsValue: getGrantFieldValue(parsedFields, 'actions'), + outputFieldsValue: getGrantFieldValue(parsedFields, 'output_fields'), + }; +}; + +export const normalizeGrantsSchema = (grantsSchema) => { + const resourceTypes = grantsSchema.resource_types ?? []; const resourcesByType = resourceTypes.reduce((resources, resourceType) => { const collectionActions = resourceType.collection_actions ?? []; @@ -107,7 +124,7 @@ const getResourceTypesForId = (schema, id) => { .map(({ type }) => type); }; -const getCompatibleResourceTypeForIds = (schema, idsValue) => { +export const getCompatibleResourceTypeForIds = (schema, idsValue) => { const resourceTypesById = parseIds(idsValue).flatMap((id) => getResourceTypesForId(schema, id), ); @@ -135,7 +152,7 @@ const getIdActions = (schema, idsValue) => { return schema.resourcesByType[matchedType]?.idActions; }; -const getChildResourceActions = (schema, idsValue) => { +export const getChildResourceActions = (schema, idsValue) => { const parentType = getCompatibleResourceTypeForIds(schema, idsValue); if ( @@ -241,9 +258,64 @@ const getActionOptions = (schema, typeValue, idsValue) => { } // Should be wildcard IDs with a specific type, so show all actions for that type - return selectedResource - ? withWildCard(selectedResource.actions) - : actionOptions; + return selectedResource ? withWildCard(selectedResource.actions) : []; +}; + +export const analyzeGrantString = (grantsSchema, grantString = '') => { + const schema = normalizeGrantsSchema(grantsSchema); + const { idsValue, typeValue } = parseGrantLine(grantString); + + const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*'); + const hasExplicitType = Boolean(typeValue) && typeValue !== '*'; + + const hasTemplateIds = + hasSpecificIds && + parseIds(idsValue).some((id) => id.startsWith('{{') && id.endsWith('}}')); + + const hasLiteralIds = hasSpecificIds && !hasTemplateIds; + + const compatibleIdsResourceType = hasLiteralIds + ? getCompatibleResourceTypeForIds(schema, idsValue) + : null; + + const hasInvalidType = hasExplicitType && !schema.resourcesByType[typeValue]; + const hasInvalidIds = hasLiteralIds && !compatibleIdsResourceType; + + const hasInvalidPinnedIdTypeCombination = + hasSpecificIds && + hasExplicitType && + !hasInvalidIds && + !hasInvalidType && + !( + schema.childResourceTypesByParentType[compatibleIdsResourceType] ?? [] + ).includes(typeValue); + + const detectedResourceType = + typeValue && typeValue !== '*' + ? typeValue + : idsValue + ? getCompatibleResourceTypeForIds(schema, idsValue) + : null; + + const isBothWildcard = idsValue === '*' && typeValue === '*'; + + const actions = + idsValue || typeValue + ? getActionOptions(schema, typeValue, idsValue) + .filter( + (action) => + action !== '*' && (!isBothWildcard || CRUDL_ACTIONS.has(action)), + ) + .sort((left, right) => left.localeCompare(right)) + : []; + + return { + actions, + detectedResourceType, + hasInvalidType, + hasInvalidIds, + hasInvalidPinnedIdTypeCombination, + }; }; const getIdLookupTypes = (schema, typeValue, enteredIds) => { @@ -281,16 +353,9 @@ async function grantCompletions( const line = context.state.doc.lineAt(context.pos); const beforeCursor = line.text.slice(0, context.pos - line.from); - const parsedFields = parseGrantFields(line.text); + const { parsedFields, idsValue, typeValue } = parseGrantLine(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 @@ -338,14 +403,15 @@ async function grantCompletions( const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/); if (idsValueMatch) { const enteredIds = idsValueMatch[1].split(','); - const partial = enteredIds.pop() ?? ''; + const partial = (enteredIds.pop() ?? '').trim(); const hasEnteredIds = enteredIds.length > 0; const staticOptions = filterByPrefix(['*', ...ID_TEMPLATES], partial) .filter((value) => !hasEnteredIds || value !== '*') .filter( (value) => - (!hasEnteredIds && !typeValue) || !ID_TEMPLATES.includes(value), + !ID_TEMPLATES.includes(value) || + (!typeValue && enteredIds.every((id) => ID_TEMPLATES.includes(id))), ) .filter((value) => !enteredIds.includes(value)) .map((value) => ({ @@ -362,6 +428,8 @@ async function grantCompletions( typeValue, enteredIds, ); + const from = context.pos - partial.length; + const ids = await idLookup(partial, compatibleResourceTypes); const idOptions = ids .filter(({ id }) => !enteredIds.includes(id)) @@ -383,7 +451,7 @@ async function grantCompletions( } return { - from: context.pos - partial.length, + from, options: allOptions.length ? allOptions : [getNoSuggestionsOption(translate('no-suggestions'))], diff --git a/ui/admin/tests/acceptance/roles/edit-grants-test.js b/ui/admin/tests/acceptance/roles/edit-grants-test.js index 138c864a1..a04a71ffd 100644 --- a/ui/admin/tests/acceptance/roles/edit-grants-test.js +++ b/ui/admin/tests/acceptance/roles/edit-grants-test.js @@ -173,4 +173,14 @@ module('Acceptance | roles/edit grants', function (hooks) { expectedGrantStrings, ); }); + + test('shows actions for the first grant on page load', async function (assert) { + await visit(urls.editGrants); + + assert.dom(selectors.GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + // opted to not check for specific actions here since the grants schema + // may change and cause the test to fail even though the functionality + // is working as expected + assert.dom(selectors.GRANT_ACTIONS_TABLE).isVisible(); + }); }); diff --git a/ui/admin/tests/acceptance/roles/selectors.js b/ui/admin/tests/acceptance/roles/selectors.js index c51b5c124..a7af3d228 100644 --- a/ui/admin/tests/acceptance/roles/selectors.js +++ b/ui/admin/tests/acceptance/roles/selectors.js @@ -53,6 +53,11 @@ export const REMOVE_ORG_ONLY_BTN = (name) => export const REMOVE_ORG_PROJECTS_BTN = (name) => `[data-test-manage-scopes-remove-${name}-modal] div:nth-child(3) button`; +// Grant actions +export const GRANT_ACTIONS_TABLE = '[data-test-grant-actions-table]'; +export const GRANT_ACTIONS_NO_TYPE_DETECTED = + '[data-test-grant-actions-no-type-detected]'; + // Role grants export const GRANT_CODE_BLOCK = '.grant-codeblock .hds-code-block__code'; export const EXPORT_OPTIONS_FLYOUT = '[data-test-export-options-flyout]'; diff --git a/ui/admin/tests/integration/components/grant-actions/index-test.js b/ui/admin/tests/integration/components/grant-actions/index-test.js new file mode 100644 index 000000000..cac188f37 --- /dev/null +++ b/ui/admin/tests/integration/components/grant-actions/index-test.js @@ -0,0 +1,272 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import * as commonSelectors from 'admin/tests/helpers/selectors'; + +const GRANT_ACTIONS_TABLE = '[data-test-grant-actions-table]'; +const GRANT_ACTIONS_EMPTY_STATE = '[data-test-grant-actions-empty-state]'; +const GRANT_ACTIONS_NO_TYPE_DETECTED = + '[data-test-grant-actions-no-type-detected]'; +const GRANT_ACTIONS_INVALID_ID_OR_TYPE = + '[data-test-grant-actions-invalid-id-or-type]'; + +module('Integration | Component | grant-actions/index', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + this.grantsSchema = { + resource_types: [ + { + type: 'host-catalog', + collection_actions: ['create', 'list'], + id_actions: ['read', 'update', 'delete'], + id_prefixes: ['hcst'], + }, + { + type: 'host', + parent_type: 'host-catalog', + collection_actions: ['create', 'list'], + id_actions: ['read', 'update', 'delete'], + id_prefixes: ['h'], + }, + { + type: 'host-set', + parent_type: 'host-catalog', + collection_actions: ['create', 'list'], + id_actions: [ + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + 'read', + 'update', + ], + id_prefixes: ['hs'], + }, + ], + }; + }); + + test('it renders the actions relevant to the current grant line', async function (assert) { + this.grantString = 'ids=hcst_1234567890;type=host-set'; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).isVisible(); + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 8 }); + + const rows = [...this.element.querySelectorAll(commonSelectors.TABLE_ROWS)]; + const rowData = rows.map((row) => { + const cells = [...row.querySelectorAll('td')].map((cell) => + cell.textContent.trim(), + ); + + return { + name: cells[0], + description: cells[1], + }; + }); + + assert.deepEqual( + rowData.map(({ name }) => name), + [ + 'add-hosts', + 'create', + 'delete', + 'list', + 'read', + 'remove-hosts', + 'set-hosts', + 'update', + ], + ); + + assert.deepEqual(rowData[0], { + name: 'add-hosts', + description: 'Add hosts to a host set', + }); + + assert.deepEqual(rowData[1], { + name: 'create', + description: 'Create a new host-set', + }); + + assert.deepEqual(rowData.at(-1), { + name: 'update', + description: 'Update a host-set', + }); + + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + }); + + test('it renders the no resource type detected state when the current line has no detectable type', async function (assert) { + this.grantString = 'output_fields=*'; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_NO_TYPE_DETECTED) + .hasText('No resource type detected.'); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + }); + + test('it renders the invalid ID and type state for an unknown type', async function (assert) { + this.grantString = 'type=not-a-real-type;actions='; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE) + .hasText('Invalid ID and type. No actions available.'); + }); + + test('it renders the invalid ID and type state for an unknown ID', async function (assert) { + this.grantString = 'ids=bad_123;actions='; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE) + .hasText('Invalid ID and type. No actions available.'); + }); + + test('it keeps the no suggestions state when a type is known but no actions apply', async function (assert) { + this.grantString = 'type=host;actions='; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist(); + assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).hasText('No suggestions'); + }); + + test('it renders the no-type-detected state for an empty grant string', async function (assert) { + this.grantString = ''; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_NO_TYPE_DETECTED) + .hasText('No resource type detected.'); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + }); + + test('it renders the no-type-detected state for template IDs', async function (assert) { + this.grantString = 'ids={{.User.Id}}'; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_NO_TYPE_DETECTED) + .hasText('No resource type detected.'); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + }); + + test('it renders the invalid state for a pinned id paired with its own type instead of a child type', async function (assert) { + this.grantString = 'ids=hcst_1234567890;type=host-catalog'; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).doesNotExist(); + assert.dom(GRANT_ACTIONS_NO_TYPE_DETECTED).doesNotExist(); + assert.dom(GRANT_ACTIONS_EMPTY_STATE).doesNotExist(); + assert + .dom(GRANT_ACTIONS_INVALID_ID_OR_TYPE) + .hasText('Invalid ID and type. No actions available.'); + }); + + test('it renders only CRUDL actions with fallback descriptions when type is wildcard', async function (assert) { + this.grantString = 'ids=*;type=*'; + + await render(hbs` + + `); + + assert.dom(GRANT_ACTIONS_TABLE).isVisible(); + + const rows = [...this.element.querySelectorAll(commonSelectors.TABLE_ROWS)]; + const rowNames = rows.map((row) => + row.querySelector('td')?.textContent.trim(), + ); + + assert.deepEqual( + rowNames, + ['create', 'delete', 'list', 'read', 'update'], + 'only CRUDL actions are shown for wildcard grants', + ); + + const createRow = rows.find( + (row) => row.querySelector('td')?.textContent.trim() === 'create', + ); + assert.strictEqual( + createRow.querySelectorAll('td')[1].textContent.trim(), + 'Creates resources', + 'description falls back to generic when no resource type is detected', + ); + }); +}); diff --git a/ui/admin/tests/unit/utils/grant-completions-test.js b/ui/admin/tests/unit/utils/grant-completions-test.js index e63f788c6..4d6180b5f 100644 --- a/ui/admin/tests/unit/utils/grant-completions-test.js +++ b/ui/admin/tests/unit/utils/grant-completions-test.js @@ -8,7 +8,13 @@ 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'; +import { + createGrantCompletionSource, + analyzeGrantString, + getChildResourceActions, + getCompatibleResourceTypeForIds, + normalizeGrantsSchema, +} from 'admin/utils/grant-completions'; const createCompletionContext = (lineText) => ({ pos: lineText.length, @@ -74,6 +80,35 @@ module('Unit | Utils | grant-completions', function (hooks) { ); }); + test.each( + 'field name suggestions', + { + 'suggest all fields on an empty line': { + lineText: '', + expectedLabels: ['ids=', 'type=', 'actions=', 'output_fields='], + }, + 'filter already-used fields from suggestions': { + lineText: 'ids=*;', + expectedLabels: ['type=', 'actions=', 'output_fields='], + }, + 'match a partial field name': { + lineText: 'ty', + expectedLabels: ['type='], + }, + 'filter multiple already-used fields': { + lineText: 'ids=*;type=scope;', + expectedLabels: ['actions=', 'output_fields='], + }, + }, + async function (assert, { lineText, expectedLabels }) { + const completionResult = await this.grantCompletionSource( + createCompletionContext(lineText), + ); + + assert.deepEqual(getLabels(completionResult), expectedLabels); + }, + ); + test.each( 'type suggestions', { @@ -358,6 +393,35 @@ module('Unit | Utils | grant-completions', function (hooks) { }, ); + test.each( + 'ids suggestions', + { + 'include supported template values': { + lineText: 'ids={{', + expectedLabels: ['{{.User.Id}}', '{{.Account.Id}}'], + }, + 'suggest wildcard and templates on an empty ids value': { + lineText: 'ids=', + expectedLabels: ['*', '{{.User.Id}}', '{{.Account.Id}}'], + }, + 'filter wildcard once an id is already entered': { + lineText: 'ids={{.User.Id}},', + expectedLabels: ['{{.Account.Id}}'], + }, + 'filter already-entered template ids when completing a partial': { + lineText: 'ids={{.User.Id}},{{', + expectedLabels: ['{{.Account.Id}}'], + }, + }, + async function (assert, { lineText, expectedLabels }) { + const completionResult = await this.grantCompletionSource( + createCompletionContext(lineText), + ); + + assert.deepEqual(getLabels(completionResult), expectedLabels); + }, + ); + test('completion info labels are translated values', async function (assert) { const typeCompletionResult = await this.grantCompletionSource( createCompletionContext('type='), @@ -394,6 +458,240 @@ module('Unit | Utils | grant-completions', function (hooks) { ); }); + test('analyzeGrantString detects resource type and actions for a complete grant string', function (assert) { + const result = analyzeGrantString( + this.grantsSchemaData, + 'ids=hcst_1234567890;type=host-set;actions=read,list;output_fields=*', + ); + + assert.strictEqual(result.detectedResourceType, 'host-set'); + assert.false(result.hasInvalidType); + assert.false(result.hasInvalidIds); + assert.true(result.actions.length > 0); + }); + + test('analyzeGrantString returns no actions and no detected type for output_fields only', function (assert) { + const result = analyzeGrantString(this.grantsSchemaData, 'output_fields=*'); + + assert.strictEqual(result.detectedResourceType, null); + assert.deepEqual(result.actions, []); + }); + + test('analyzeGrantString returns no actions and no detected type for an empty string', function (assert) { + const result = analyzeGrantString(this.grantsSchemaData, ''); + + assert.strictEqual(result.detectedResourceType, null); + assert.deepEqual(result.actions, []); + }); + + test('getCompatibleResourceTypeForIds returns the matched type for a known id prefix', function (assert) { + const schema = normalizeGrantsSchema(this.grantsSchemaData); + + assert.strictEqual( + getCompatibleResourceTypeForIds(schema, 'hs_123'), + 'host-set', + ); + }); + + test('getCompatibleResourceTypeForIds returns null for an unknown id prefix', function (assert) { + const schema = normalizeGrantsSchema(this.grantsSchemaData); + + assert.strictEqual( + getCompatibleResourceTypeForIds(schema, 'bad_123'), + null, + ); + }); + + test('getCompatibleResourceTypeForIds returns null for mixed resource type ids', function (assert) { + const schema = normalizeGrantsSchema(this.grantsSchemaData); + + assert.strictEqual( + getCompatibleResourceTypeForIds(schema, 'hs_123,s_123'), + null, + ); + }); + + test('getCompatibleResourceTypeForIds returns the type when all ids share the same resource type', function (assert) { + const schema = normalizeGrantsSchema(this.grantsSchemaData); + + assert.strictEqual( + getCompatibleResourceTypeForIds(schema, 'hs_123,hs_456'), + 'host-set', + ); + }); + + test('analyzeGrantString.actions returns the same actions as the completion source uses', function (assert) { + const { actions } = analyzeGrantString( + this.grantsSchemaData, + 'ids=hcst_1234567890;type=host-set', + ); + + assert.deepEqual(actions, [ + 'add-hosts', + 'create', + 'delete', + 'list', + 'read', + 'remove-hosts', + 'set-hosts', + 'update', + ]); + }); + + test('analyzeGrantString.detectedResourceType returns the type inferred from a specific id', function (assert) { + const { detectedResourceType } = analyzeGrantString( + this.grantsSchemaData, + 'ids=hs_123', + ); + + assert.strictEqual(detectedResourceType, 'host-set'); + }); + + test('analyzeGrantString.detectedResourceType returns an explicit type value', function (assert) { + const { detectedResourceType } = analyzeGrantString( + this.grantsSchemaData, + 'type=session', + ); + + assert.strictEqual(detectedResourceType, 'session'); + }); + + test('analyzeGrantString.detectedResourceType returns null when no type can be detected', function (assert) { + const { detectedResourceType } = analyzeGrantString( + this.grantsSchemaData, + 'output_fields=*', + ); + + assert.strictEqual(detectedResourceType, null); + }); + + test('analyzeGrantString.detectedResourceType returns null for a wildcard type', function (assert) { + const { detectedResourceType } = analyzeGrantString( + this.grantsSchemaData, + 'type=*', + ); + + assert.strictEqual(detectedResourceType, null); + }); + + test('analyzeGrantString treats a type with additional equals signs as invalid', function (assert) { + const result = analyzeGrantString( + this.grantsSchemaData, + 'ids=s_123;type=session=a;actions=read', + ); + + assert.true(result.hasInvalidType); + }); + + test('getChildResourceActions returns the union of child resource actions for a pinned parent id', function (assert) { + const schema = normalizeGrantsSchema(this.grantsSchemaData); + + assert.deepEqual(getChildResourceActions(schema, 'hcst_1234567890'), [ + 'create', + 'list', + 'read', + 'update', + 'delete', + 'add-hosts', + 'set-hosts', + 'remove-hosts', + ]); + }); + + test.each( + 'analyzeGrantString.hasInvalidType', + { + 'returns true for an unknown type': { + grantString: 'type=not-a-real-type', + expected: true, + }, + 'returns false for a known type': { + grantString: 'type=host-set', + expected: false, + }, + 'returns false for a wildcard type': { + grantString: 'type=*', + expected: false, + }, + 'returns false when there is no type': { + grantString: 'ids=hs_123', + expected: false, + }, + }, + function (assert, { grantString, expected }) { + const { hasInvalidType } = analyzeGrantString( + this.grantsSchemaData, + grantString, + ); + assert.strictEqual(hasInvalidType, expected); + }, + ); + + test.each( + 'analyzeGrantString.hasInvalidIds', + { + 'returns true for an unrecognised id prefix': { + grantString: 'ids=bad_123', + expected: true, + }, + 'returns true for mixed ids with conflicting resource types': { + grantString: 'ids=hs_123,s_123', + expected: true, + }, + 'returns false for a recognised id prefix': { + grantString: 'ids=hs_123', + expected: false, + }, + 'returns false for a wildcard id': { + grantString: 'ids=*', + expected: false, + }, + 'returns false for a template id': { + grantString: 'ids={{.User.Id}}', + expected: false, + }, + }, + function (assert, { grantString, expected }) { + const { hasInvalidIds } = analyzeGrantString( + this.grantsSchemaData, + grantString, + ); + assert.strictEqual(hasInvalidIds, expected); + }, + ); + + test.each( + 'analyzeGrantString.hasInvalidPinnedIdTypeCombination', + { + 'returns true when a pinned id is paired with its own type': { + grantString: 'ids=hcst_1234567890;type=host-catalog', + expected: true, + }, + 'returns false when a pinned id is paired with a valid child type': { + grantString: 'ids=hcst_1234567890;type=host-set', + expected: false, + }, + 'returns false for wildcard ids with a specific type': { + grantString: 'ids=*;type=host-set', + expected: false, + }, + 'returns false when there is no type': { + grantString: 'ids=hcst_1234567890', + expected: false, + }, + 'returns false when ids are invalid': { + grantString: 'ids=bad_123;type=host-set', + expected: false, + }, + }, + function (assert, { grantString, expected }) { + const { hasInvalidPinnedIdTypeCombination } = analyzeGrantString( + this.grantsSchemaData, + grantString, + ); + assert.strictEqual(hasInvalidPinnedIdTypeCombination, expected); + }, + ); module('id auto completion', function () { test('idLookup results are included alongside static options', async function (assert) { const source = createGrantCompletionSource(