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(