diff --git a/addons/api/app/mirage/factories/role.js b/addons/api/app/mirage/factories/role.js index dbfded391..b394504c9 100644 --- a/addons/api/app/mirage/factories/role.js +++ b/addons/api/app/mirage/factories/role.js @@ -26,7 +26,7 @@ export default factory.extend({ 'set-grant-scopes', ], grant_strings: () => [ - 'ids=*;action=*', + 'ids=*;actions=*', 'ids=*;type=host-catalog;actions=create,read', ], diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index ad2dae9f9..ba64409a5 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -670,6 +670,51 @@ role: wildcard-actions: Wildcard - matches all actions template-value: Template value all-fields: All fields + linting-errors: + general: + whitespace-not-allowed: Whitespace is not allowed + leading-semicolon-not-allowed: Leading semicolon is not allowed + trailing-semicolon-not-allowed: Trailing semicolon is not allowed + invalid-character: Invalid character "{character}" + invalid-format: 'Invalid format: expected key=value' + invalid-syntax: Invalid syntax + fields: + duplicate-field: Duplicate field "{field}" + unknown-field: Unknown field "{field}" + empty-value: '"{field}" value cannot be empty' + missing-ids-or-type: Missing "ids" or "type" fields + missing-actions: Missing "actions" field + missing-actions-or-output-fields: Missing "actions" or "output_fields" fields + type: + invalid-type: Invalid resource type "{type}" + not-top-level: Type "{type}" is not a top-level type + not-child-type: Type "{type}" is not a child type of the pinned ID + missing-for-wildcard-ids: Missing "type" field for wildcard ids + wildcard-requires-ids: Missing "ids" field for wildcard type + ids: + empty-list: '"ids" field does not contain at least one id' + wildcard-cannot-combine: Wildcard id "*" cannot be combined with other ids + duplicate-template: Duplicate id "{primary}" and "{secondary}" + duplicate-id: Duplicate id "{id}" + unknown-template: Unknown template "{id}" + invalid-id: Invalid id "{id}" + mismatched-types: Ids do not have the same type. Invalid id "{id}" + must-support-child-types: Pinned IDs must support child types. Invalid id "{id}" + must-match-parent-type: Pinned IDs must be "{parentType}" ids. Invalid id "{id}" + actions: + empty-list: '"actions" field does not contain at least one action' + wildcard-cannot-combine: Wildcard action "*" cannot be combined with other actions + duplicate-action: Duplicate action "{action}" + invalid-action: Invalid action "{action}" + id-only: Only id actions for "{type}" type are allowed. Invalid action "{action}" + collection-only: Only collection actions are allowed. Invalid action "{action}" + noop-requires-list: '"no-op" action should only be used with "list" action' + list-requires-noop: '"list" action should be used with "no-op" action to have an effect' + noop-unnecessary: '"no-op" action is unnecessary when other actions are specified' + editor: + remove: Remove + remove-comma: Remove comma + add-noop: Add "no-op" action editor: title: Grant Editor description: Add each new grant string on a different line diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e445a1b2..30433bc3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8728,8 +8728,8 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} - terser-webpack-plugin@5.3.17: - resolution: {integrity: sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==} + terser-webpack-plugin@5.4.0: + resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -18918,7 +18918,7 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - terser-webpack-plugin@5.3.17(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)): + terser-webpack-plugin@5.4.0(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -19465,7 +19465,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.17(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)) + terser-webpack-plugin: 5.4.0(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: 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 e24f290cf..1acefebea 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.js +++ b/ui/admin/app/components/form/role/edit-grants/index.js @@ -11,9 +11,12 @@ import { autocompletion, completionKeymap, keymap, + linter, + lintGutter, + lintKeymap, } from '@hashicorp/design-system-components/codemirror'; - import { createGrantCompletionSource } from 'admin/utils/grant-completions'; +import { createGrantLinter } from 'admin/utils/grant-linter'; export default class FormRoleEditGrantsComponent extends Component { @service intl; @@ -41,10 +44,17 @@ export default class FormRoleEditGrantsComponent extends Component { ), }; + translateLintingError = (key, options = {}) => + this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options); + completionSource = createGrantCompletionSource( this.args.grantsSchema, this.completionTranslatedStrings, ); + linterSource = createGrantLinter( + this.args.grantsSchema, + this.translateLintingError, + ); @tracked grantStringsText = (this.args.model?.grant_strings ?? []).join('\n'); @tracked currentLineText = this.args.model?.grant_strings?.[0] ?? ''; @@ -57,7 +67,9 @@ 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', }), - keymap.of(completionKeymap), + linter(this.linterSource), + lintGutter(), + keymap.of([...completionKeymap, ...lintKeymap]), ]; get grantStrings() { diff --git a/ui/admin/app/utils/grant-linter.js b/ui/admin/app/utils/grant-linter.js new file mode 100644 index 000000000..a126248d6 --- /dev/null +++ b/ui/admin/app/utils/grant-linter.js @@ -0,0 +1,767 @@ +/** + * Copyright IBM Corp. 2021, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields']; +const ID_TEMPLATES = [ + '{{.Account.Id}}', + '{{.User.Id}}', + '{{user.id}}', + '{{account.id}}', +]; + +const createDeleteTextAction = (name) => ({ + name, + apply(view, from, to) { + view.dispatch({ + changes: { from, to }, + }); + }, +}); + +const createAddTextAction = (name) => ({ + name, + apply(view, from) { + view.dispatch({ + changes: { from, insert: 'no-op,' }, + }); + }, +}); + +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 validResourceTypes = resourceTypes.map((resource) => resource.type); + validResourceTypes.push('*'); + + const allActions = new Set(); + Object.values(resourcesByType).forEach((resource) => { + resource.actions.forEach((action) => allActions.add(action)); + }); + const validActions = Array.from(allActions); + validActions.push('*'); + validActions.push('no-op'); + + const resourcesByPrefix = resourceTypes.reduce((map, resource) => { + resource.id_prefixes?.forEach((prefix) => { + map[prefix] = resource.type; + }); + return map; + }, {}); + + let parentTypes = new Set( + resourceTypes.filter((r) => r.parent_type).map((r) => r.parent_type), + ); + parentTypes = Array.from(parentTypes); + + const topLevelTypes = resourceTypes + .filter((r) => { + const hasCreateAction = r.collection_actions.includes('create'); + const hasListAction = r.collection_actions.includes('list'); + return !r.parent_type && (hasCreateAction || hasListAction); + }) + .map((r) => r.type); + + const childResourceTypesByParentType = resourceTypes.reduce( + (childrenByParentType, resourceType) => { + const parentType = resourceType.parent_type; + + if (!parentType) { + return childrenByParentType; + } + + childrenByParentType[parentType] = [ + ...(childrenByParentType[parentType] ?? []), + resourceType.type, + ]; + + return childrenByParentType; + }, + {}, + ); + + return { + resourcesByType, + validResourceTypes, + validActions, + resourcesByPrefix, + childResourceTypesByParentType, + parentTypes, + topLevelTypes, + }; +}; + +const grantLinter = (context, schema, translate) => { + const diagnostics = []; + const doc = context.state.doc.toString(); + const lines = doc.split('\n'); + + let currentPos = 0; + + lines.forEach((line) => { + const trimmedLine = line.trim(); + const lineStart = currentPos; + const lineEnd = currentPos + line.length; + + // Check for whitespace + const whitespaceMatches = line.matchAll(/\s+/g); + for (const match of whitespaceMatches) { + const spaceStart = lineStart + match.index; + const spaceEnd = spaceStart + match[0].length; + diagnostics.push({ + from: spaceStart, + to: spaceEnd, + severity: 'error', + message: translate('general.whitespace-not-allowed'), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } + + const trimStart = line.indexOf(trimmedLine); + // Check for leading semicolon + if (trimmedLine.startsWith(';')) { + diagnostics.push({ + from: lineStart, + to: lineStart + trimStart + 1, + severity: 'error', + message: translate('general.leading-semicolon-not-allowed'), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } + + // Check for trailing semicolon + if (trimmedLine.endsWith(';')) { + const semicolonPos = lineStart + line.lastIndexOf(';'); + diagnostics.push({ + from: semicolonPos, + to: semicolonPos + 1, + severity: 'error', + message: translate('general.trailing-semicolon-not-allowed'), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } + + // Check for invalid characters (only allow alphanumeric characters, equal signs, commas, semicolons, underscores, hyphens, curly braces, and periods) + const invalidCharMatch = line.match(/[^a-zA-Z0-9=,;:_*\-.{}\s]/g) || []; + for (const invalidChar of invalidCharMatch) { + const invalidCharIndex = lineStart + line.indexOf(invalidChar); + diagnostics.push({ + from: invalidCharIndex, + to: invalidCharIndex + 1, + severity: 'error', + message: translate('general.invalid-character', { + character: invalidChar, + }), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } + + // Parse key-value pairs separated by semicolons + const pairs = trimmedLine.split(';').filter((p) => p.trim()); + const parsedFields = {}; + const fieldPositions = {}; + const allFields = []; + const fieldKeyCount = {}; + + let pairOffset = trimStart; // Start from where trimmed content begins + + for (const pair of pairs) { + const pairInLine = line.indexOf(pair, pairOffset - trimStart); + const [key, ...valueParts] = pair.split('='); + const value = valueParts.join('='); // Handle cases where value contains '=' + + if (!key || value === undefined) { + // Invalid format - not a key=value pair + const pairStart = lineStart + pairInLine; + diagnostics.push({ + from: pairStart, + to: pairStart + pair.length, + severity: 'error', + message: translate('general.invalid-format'), + }); + continue; + } + + const trimmedKey = key.trim(); + const keyStartInPair = pair.indexOf(trimmedKey); + const valueStartInPair = pair.indexOf('=') + 1; + + const pos = { + keyStart: lineStart + pairInLine + keyStartInPair, + valueStart: lineStart + pairInLine + valueStartInPair, + valueEnd: lineStart + pairInLine + pair.length, + }; + allFields.push({ key: trimmedKey, value: value.trim(), pos }); + fieldKeyCount[trimmedKey] = (fieldKeyCount[trimmedKey] ?? 0) + 1; + + pairOffset = pairInLine + pair.length + 1; // +1 for semicolon + } + + // Validate there are no duplicate fields and store the last instance of each field for further validation + for (const field of allFields) { + parsedFields[field.key] = field.value; + fieldPositions[field.key] = field.pos; + if (fieldKeyCount[field.key] > 1) { + diagnostics.push({ + from: field.pos.keyStart, + to: field.pos.valueEnd, + severity: 'error', + message: translate('fields.duplicate-field', { + field: field.key, + }), + }); + } + } + + // Validate all fields are recognized + for (const field of Object.keys(parsedFields)) { + if (!GRANT_FIELDS.includes(field)) { + const pos = fieldPositions[field]; + diagnostics.push({ + from: pos.keyStart, + to: pos.valueEnd, + severity: 'error', + message: translate('fields.unknown-field', { field }), + }); + } + } + + // Validate field values are not empty + for (const [field, value] of Object.entries(parsedFields)) { + if (!value.trim()) { + const pos = fieldPositions[field]; + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('fields.empty-value', { field }), + }); + } + } + + const validatedFields = {}; + + // Validate type field value is a valid resource type + if (parsedFields.type) { + validatedFields.type = { value: '', wildcard: false }; + const typeValue = parsedFields.type.trim(); + const pos = fieldPositions.type; + if (!schema.validResourceTypes.includes(typeValue)) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('type.invalid-type', { type: typeValue }), + }); + } else { + validatedFields.type.value = typeValue; + if (!parsedFields.ids) { + if (!schema.topLevelTypes.includes(typeValue)) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('type.not-top-level', { + type: typeValue, + }), + }); + } + } + } + validatedFields.type.wildcard = typeValue === '*'; + } + + // Validate ids field + if (parsedFields.ids) { + validatedFields.ids = { value: [], knownType: '', wildcard: false }; + const pos = fieldPositions.ids; + const idsList = validateListField( + parsedFields.ids, + pos, + diagnostics, + translate, + ); + if (idsList.length === 0) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('ids.empty-list'), + }); + } else if (idsList.includes('*')) { + if (idsList.length > 1) { + const wildCardIndex = pos.valueStart + parsedFields.ids.indexOf('*'); + diagnostics.push({ + from: wildCardIndex, + to: wildCardIndex + 1, // Include wildcard and comma after it + severity: 'error', + message: translate('ids.wildcard-cannot-combine'), + }); + } else { + validatedFields.ids.wildcard = true; + if (!parsedFields.type) { + diagnostics.push({ + from: lineStart, + to: lineEnd, + severity: 'error', + message: translate('type.missing-for-wildcard-ids'), + }); + } + } + } else { + const validIdType = validateIdsField( + schema, + diagnostics, + idsList, + validatedFields.type, + pos, + fieldPositions.type, + translate, + ); + if (validIdType) { + validatedFields.ids.knownType = validIdType; + } + } + } + + if (!validatedFields.ids) { + if (!validatedFields.type) { + diagnostics.push({ + from: lineStart, + to: lineEnd, + severity: 'error', + message: translate('fields.missing-ids-or-type'), + }); + } else if (validatedFields.type.wildcard) { + diagnostics.push({ + from: lineStart, + to: lineEnd, + severity: 'error', + message: translate('type.wildcard-requires-ids'), + }); + } else { + if (!parsedFields.actions && !parsedFields.output_fields) { + diagnostics.push({ + from: lineStart, + to: lineEnd, + severity: 'error', + message: translate('fields.missing-actions-or-output-fields'), + }); + } + } + } else { + if (!parsedFields.actions && !parsedFields.output_fields) { + diagnostics.push({ + from: lineStart, + to: lineEnd, + severity: 'error', + message: translate('fields.missing-actions'), + }); + } + } + + // Validate actions field + if (parsedFields.actions) { + const pos = fieldPositions.actions; + const actionList = validateListField( + parsedFields.actions, + pos, + diagnostics, + translate, + ); + if (actionList.length === 0) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('actions.empty-list'), + }); + } else if (actionList.includes('*') && actionList.length > 1) { + const wildcardIndex = + pos.valueStart + parsedFields.actions.indexOf('*'); + diagnostics.push({ + from: wildcardIndex, + to: wildcardIndex + 1, + severity: 'error', + message: translate('actions.wildcard-cannot-combine'), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } else { + // Validate individual actions against the resource type + validateActionsField( + schema, + diagnostics, + actionList, + validatedFields, + pos, + translate, + ); + } + } + currentPos += line.length + 1; // +1 for newline + }); + + return diagnostics; +}; + +const validateListField = (fieldValue, pos, diagnostics, translate) => { + const unfilteredVals = fieldValue.split(','); + const valList = []; + let segmentStart = 0; + const maxIndex = unfilteredVals.length - 1; + for (const [index, val] of unfilteredVals.entries()) { + const emptyVal = val.match(/^\s*$/); + if (!emptyVal) { + valList.push(val); + } + if (index < maxIndex) { + const emptyNextVal = unfilteredVals[index + 1].match(/^\s*$/); + if (emptyVal || emptyNextVal) { + const commaPos = segmentStart + val.length; + diagnostics.push({ + from: pos.valueStart + commaPos, + to: pos.valueStart + commaPos + 1, // +1 for comma + severity: 'error', + message: translate('general.invalid-syntax'), + actions: [createDeleteTextAction(translate('editor.remove-comma'))], + }); + } + } + segmentStart += val.length + 1; // +1 for comma delimiter + } + + return valList; +}; + +// Returns template, resource type, or null; +const idType = (schema, id) => { + if (id.startsWith('{{') && id.endsWith('}}')) { + return 'template'; + } + const prefix = id.split('_')[0]; + return schema.resourcesByPrefix[prefix]; +}; + +const getChildResourceActions = (schema, parentType) => { + if ( + !parentType || + !schema.childResourceTypesByParentType[parentType]?.length + ) { + return []; + } + + const childTypes = schema.childResourceTypesByParentType[parentType] ?? []; + + return [ + ...new Set( + childTypes.flatMap((type) => schema.resourcesByType[type]?.actions ?? []), + ), + ]; +}; + +const validateIdsField = ( + schema, + diagnostics, + idsList, + validatedFieldsType = {}, + idsPos, + typePos, + translate, +) => { + if ( + idsList.includes('{{.Account.Id}}') && + idsList.includes('{{account.id}}') + ) { + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.duplicate-template', { + primary: '{{.Account.Id}}', + secondary: '{{account.id}}', + }), + }); + return; + } + if (idsList.includes('{{.User.Id}}') && idsList.includes('{{user.id}}')) { + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.duplicate-template', { + primary: '{{.User.Id}}', + secondary: '{{user.id}}', + }), + }); + return; + } + let segmentStart = idsPos.valueStart; + let seenIdType; + for (const [i, id] of idsList.entries()) { + let segmentEnd = segmentStart + id.length; + if (i === 0) { + seenIdType = idType(schema, id); + } + if (i < idsList.length - 1) { + segmentEnd += 1; // Include comma delimiter for all but last id + } + if (idsList.slice(i + 1).includes(id)) { + diagnostics.push({ + from: segmentStart, + to: segmentEnd, + severity: 'error', + message: translate('ids.duplicate-id', { id }), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + return; + } + const currentIdType = idType(schema, id); + if (currentIdType === 'template') { + if (!ID_TEMPLATES.includes(id)) { + diagnostics.push({ + from: segmentStart, + to: segmentEnd, + severity: 'error', + message: translate('ids.unknown-template', { id }), + }); + return; + } + } else { + if (!currentIdType) { + diagnostics.push({ + from: segmentStart, + to: segmentEnd, + severity: 'error', + message: translate('ids.invalid-id', { id }), + }); + return; + } + } + if (currentIdType !== seenIdType) { + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.mismatched-types', { id }), + }); + return; + } + if (validatedFieldsType.wildcard) { + // If type is wildcard, then pinned id(s) must support child types + if (!schema.parentTypes.includes(currentIdType)) { + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.must-support-child-types', { id }), + }); + return; + } + } else if (validatedFieldsType.value) { + // If type is known and not wildcard, then pinned id(s) must match the type + const parentType = + schema.resourcesByType[validatedFieldsType.value]?.parentType; + if (!parentType) { + diagnostics.push({ + from: typePos.valueStart, + to: typePos.valueEnd, + severity: 'error', + message: translate('type.not-child-type', { + type: validatedFieldsType.value, + }), + }); + return; + } + if (currentIdType !== parentType) { + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.must-match-parent-type', { + id, + parentType, + }), + }); + return; + } + } + segmentStart += id.length + 1; // +1 for comma + } + if (seenIdType === 'template') { + if (idsList.length > 1) { + seenIdType = 'template-multiple'; + } else if ( + idsList[0] === '{{.Account.Id}}' || + idsList[0] === '{{account.id}}' + ) { + seenIdType = 'account-template'; + } else if (idsList[0] === '{{.User.Id}}' || idsList[0] === '{{user.id}}') { + seenIdType = 'user-template'; + } + } + return seenIdType; +}; + +const getIdActionsForResourceType = (schema, resourceType) => { + switch (resourceType) { + case 'template-multiple': + return [ + '*', + ...schema.resourcesByType['account'].idActions, + ...schema.resourcesByType['user'].idActions, + ]; + case 'account-template': + return ['*', ...schema.resourcesByType['account'].idActions]; + case 'user-template': + return ['*', ...schema.resourcesByType['user'].idActions]; + default: + return ['*', ...schema.resourcesByType[resourceType].idActions]; + } +}; + +const validateActionsField = ( + schema, + diagnostics, + actionList, + validatedFields, + pos, + translate, +) => { + let validActions = schema.validActions; + let errorMessage = (action) => + translate('actions.invalid-action', { action }); + if (!validatedFields.type) { + if (validatedFields.ids?.knownType) { + // Only id actions belonging to known id type (including wildcard action) + validActions = getIdActionsForResourceType( + schema, + validatedFields.ids.knownType, + ); + errorMessage = (action) => + translate('actions.id-only', { + type: validatedFields.ids.knownType, + action, + }); + } + } else if (validatedFields.type.wildcard) { + if (validatedFields.ids?.knownType) { + // Any action from associated child resource types would be valid (including wildcard action) + validActions = [ + '*', + 'no-op', + ...getChildResourceActions(schema, validatedFields.ids.knownType), + ]; + } + } else if (validatedFields.type.value) { + if (!validatedFields.ids) { + // Only collection actions create/list belonging to the specified type + validActions = + schema.resourcesByType[validatedFields.type.value].collectionActions; + validActions = validActions.filter( + (action) => action.startsWith('create') || action.startsWith('list'), + ); + errorMessage = (action) => + translate('actions.collection-only', { action }); + } else if (validatedFields.ids.knownType || validatedFields.ids.wildcard) { + // Any action belonging to the specified type (including wildcard action) + validActions = [ + '*', + 'no-op', + ...schema.resourcesByType[validatedFields.type.value].actions, + ]; + } + } + + let segmentStart = pos.valueStart; + for (const [i, action] of actionList.entries()) { + let segmentEnd = segmentStart + action.length; + if (i < actionList.length - 1) { + segmentEnd += 1; // Include comma delimiter for all but last action + } + if (actionList.slice(i + 1).includes(action)) { + diagnostics.push({ + from: segmentStart, + to: segmentEnd, + severity: 'error', + message: translate('actions.duplicate-action', { action }), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + return; + } + if (!validActions.includes(action)) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: errorMessage(action), + }); + return; + } + segmentStart += action.length + 1; // +1 for comma + } + + // no-op should be used with list action or else it has no effect. + // Also, no-op is not necessary if there is already any other action specified. + const noopAllowed = validActions.includes('no-op'); + const hasNoOp = actionList.includes('no-op'); + const hasList = actionList.includes('list'); + const hasOtherActions = actionList.some( + (action) => action !== 'list' && action !== 'no-op', + ); + if (noopAllowed) { + if (hasNoOp) { + if (!hasList) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('actions.noop-requires-list'), + }); + return; + } + if (hasOtherActions) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('actions.noop-unnecessary'), + }); + return; + } + } + if (hasList) { + if (!hasNoOp && !hasOtherActions) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('actions.list-requires-noop'), + actions: [createAddTextAction(translate('editor.add-noop'))], + }); + return; + } + } + } +}; + +export const createGrantLinter = (grantsSchema, translate) => { + const schema = normalizeGrantsSchema(grantsSchema); + return (context) => grantLinter(context, schema, translate); +}; diff --git a/ui/admin/tests/acceptance/roles/edit-grants-test.js b/ui/admin/tests/acceptance/roles/edit-grants-test.js index 11b897ef0..138c864a1 100644 --- a/ui/admin/tests/acceptance/roles/edit-grants-test.js +++ b/ui/admin/tests/acceptance/roles/edit-grants-test.js @@ -4,7 +4,14 @@ */ import { module, test } from 'qunit'; -import { visit, click, currentURL, find, select, waitFor } 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'; diff --git a/ui/admin/tests/unit/utils/grant-linter-test.js b/ui/admin/tests/unit/utils/grant-linter-test.js new file mode 100644 index 000000000..012a3ae11 --- /dev/null +++ b/ui/admin/tests/unit/utils/grant-linter-test.js @@ -0,0 +1,239 @@ +/** + * 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 { createGrantLinter } from 'admin/utils/grant-linter'; + +const createContext = (lineText) => ({ + pos: lineText.length, + state: { + doc: { + toString() { + return lineText; + }, + }, + }, +}); + +module('Unit | Utility | grant-linter', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(async function () { + const response = await fetch('/grants-schema.json'); + + if (!response.ok) { + throw new Error(`Failed to fetch grants schema: ${response.status}`); + } + this.intl = this.owner.lookup('service:intl'); + const grantsSchemaData = await response.json(); + const translateLintingError = (key, options = {}) => + this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options); + this.grantLinter = createGrantLinter( + grantsSchemaData, + translateLintingError, + ); + }); + + test.each( + 'diagnostics for syntax and formatting errors', + [ + ['ids=*; type=*; actions=*', 'Whitespace is not allowed'], // whitespace around semicolons + ['ids=*;type= *;actions=*', 'Whitespace is not allowed'], // whitespace around equal sign + ['ids=*;type=*;actions=read, write', 'Whitespace is not allowed'], // whitespace around commas + ['ids=*;type=*;actions=read,write;', 'Trailing semicolon is not allowed'], // trailing semicolon + [';ids=*;type=*;actions=read,write', 'Leading semicolon is not allowed'], // leading semicolon + ['ids={{.Account.Id}};type=+;actions=read', 'Invalid character "+"'], // invalid character + + ['ids={{.Account.Id}};type=();actions=read', 'Invalid character "("'], // invalid characters () + ['type=target', 'Missing "actions" or "output_fields" fields'], // missing actions/output_fields field + ['actions=read,write', 'Missing "ids" or "type" fields'], // missing type/ids field + ['ids=hc_123', 'Missing "actions" field'], // missing actions field + [ + 'type=credential-store;ids=;actions=read', + '"ids" value cannot be empty', + ], // empty ids value + ['type=;actions=read', '"type" value cannot be empty'], // empty type field + ['ids=csst_1234;actions=', '"actions" value cannot be empty'], // empty actions value + [ + 'type=credential;ids=csst_1234;actions=read,write;ids=cs_5678', + 'Duplicate field "ids"', + ], // duplicate ids field + [ + 'type=credential;actions=read,write;type=host', + 'Duplicate field "type"', + ], // duplicate type field + [ + 'type=credential;ids=csst_1234;actions=read,write;actions=delete', + 'Duplicate field "actions"', + ], // duplicate actions field + [ + 'type=credential;ids=cs_1234;actions=read;random=name', + 'Unknown field "random"', + ], // unknown field "random" + [ + '=credential;actions=;ids=cs_1234', + 'Invalid format: expected key=value', + ], // missing type key + ], + function (assert, [grantString, errorMsg]) { + const diagnostics = this.grantLinter(createContext(grantString)); + + assert.strictEqual(diagnostics[0].message, errorMsg); + }, + ); + + test.each( + 'diagnostics for type field values', + [ + ['actions=read;type=random', 'Invalid resource type "random"'], // invalid resource type + ['actions=read;type=account', 'Type "account" is not a top-level type'], // invalid resource type + [ + 'type=credential,random;actions=read', + 'Invalid resource type "credential,random"', + ], // invalid resource type + ['ids=*;actions=read', 'Missing "type" field for wildcard ids'], // missing type field with wildcard id + ['type=credential-store;actions=create'], // valid grant string + ], + function (assert, [grantString, errorMsg]) { + const diagnostics = this.grantLinter(createContext(grantString)); + + if (errorMsg) { + assert.strictEqual(diagnostics[0].message, errorMsg); + } else { + assert.strictEqual(diagnostics.length, 0); + } + }, + ); + + test.each( + 'diagnostics for actions field values', + [ + ['type=credential-store;actions=,', 'Invalid syntax'], + [ + 'type=session;actions=read,*,write', + 'Wildcard action "*" cannot be combined with other actions', + ], // wildcard action cannot be combined with other actions + [ + 'type=policy;actions=execute', + 'Only collection actions are allowed. Invalid action "execute"', + ], // invalid action for resource type + [ + 'type=target;actions=list,no-op', + 'Only collection actions are allowed. Invalid action "no-op"', + ], // invalid action when type is not specified + ['ids=ampw_1234;type=*;actions=read,read', 'Duplicate action "read"'], // duplicate action + [ + 'ids=cs_1234;actions=create', + 'Only id actions for "credential-store" type are allowed. Invalid action "create"', + ], // invalid action for specific resource + [ + 'ids=*;type=auth-method;actions=no-op', + '"no-op" action should only be used with "list" action', + ], + [ + 'ids=ampw_098;type=account;actions=list', + '"list" action should be used with "no-op" action to have an effect', + ], + [ + 'ids=*;type=account;actions=list', + '"list" action should be used with "no-op" action to have an effect', + ], + [ + 'ids=ampw_098;type=account;actions=list,read,no-op', + '"no-op" action is unnecessary when other actions are specified', + ], + ['ids=hc_1234;type=*;actions=add-hosts,read'], // valid actions for resource type + ['type=worker;actions=create:controller-led'], // valid actions for resource type + ['type=auth-method;actions=list'], // valid actions for resource type + ], + function (assert, [grantString, errorMsg]) { + const diagnostics = this.grantLinter(createContext(grantString)); + + if (errorMsg) { + assert.strictEqual(diagnostics[0].message, errorMsg); + } else { + assert.strictEqual(diagnostics.length, 0); + } + }, + ); + + test.each( + 'diagnostics for ids field values', + [ + ['type=credential;ids=,,,;actions=read', 'Invalid syntax'], + ['ids=cs_1234,cs_1234;actions=read', 'Duplicate id "cs_1234"'], // duplicate ids + [ + 'ids={{.Account.Id}},{{.Account.Id}};actions=read', + 'Duplicate id "{{.Account.Id}}"', + ], // duplicate template ids + [ + 'ids={{.Account.Id}},{{account.id}};actions=read', + 'Duplicate id "{{.Account.Id}}" and "{{account.id}}"', + ], // duplicate template ids + [ + 'ids={{.User.Id}},{{user.id}};actions=read', + 'Duplicate id "{{.User.Id}}" and "{{user.id}}"', + ], // duplicate template ids + [ + 'actions=read;type=credential;ids=cs_1234,*,cs_5678', + 'Wildcard id "*" cannot be combined with other ids', + ], // wildcard id cannot be combined with other ids + [ + 'ids={{.Account.Id}};type=credential;actions=read', + 'Pinned IDs must be "credential-store" ids. Invalid id "{{.Account.Id}}"', + ], // template ids not valid with type specified + [ + 'ids=hsst_1234;type=*;actions=read', + 'Pinned IDs must support child types. Invalid id "hsst_1234"', + ], // template ids not valid with type specified + ['ids={{.Random.Id}};actions=read', 'Unknown template "{{.Random.Id}}"'], // invalid template id + [ + 'type=host-catalog;ids=hcst_1234;actions=list,delete', + 'Type "host-catalog" is not a child type of the pinned ID', + ], // only non-top level resource types can be pinned by id + [ + 'type=host-set;ids=hcst_1234,g_1234;actions=read', + 'Ids do not have the same type. Invalid id "g_1234"', + ], // all ids must be valid for the resource type + [ + 'type=credential;ids=csst_1234,random;actions=delete', + 'Invalid id "random"', + ], // all ids must be valid for the resource type + [ + 'ids=cs_1234,g_1234;actions=read', + 'Ids do not have the same type. Invalid id "g_1234"', + ], // all ids must be same type when type is not specified + ['ids=none_1234,csvlt_123;actions=list', 'Invalid id "none_1234"'], // valid ids prefixes only + [ + 'ids=g_123,amoidc_456;type=*;actions=read,list', + 'Pinned IDs must support child types. Invalid id "g_123"', + ], // all ids must support child types when type is wildcard + [ + 'ids=amoidc_456,hcst_123;type=*;actions=read,list', + 'Ids do not have the same type. Invalid id "hcst_123"', + ], // all ids must be the same type when type is wildcard + ['ids=cs_1234,csst_5678;actions=read'], // valid ids when they are the same type and type is not specified + ['ids=*;type=*;actions=read'], // valid wildcard id + ['ids=hcst_123,hcplg_456;type=host-set;actions=read'], // valid pinned ids for host-set resource type + ['ids={{.Account.Id}};actions=read'], // valid template id when type is not specified + ['ids={{.Account.Id}},{{.User.Id}};actions=read'], // valid template ids when type is not specified + ['ids=csst_1234,csvlt_5678;actions=*'], // valid id prefixes with same type + ['ids=amldap_123,amoidc_456;type=*;actions=read,list'], // valid ids with wildcard type + ], + function (assert, [grantString, errorMsg]) { + const diagnostics = this.grantLinter(createContext(grantString)); + + if (errorMsg) { + assert.strictEqual(diagnostics[0].message, errorMsg); + } else { + assert.strictEqual(diagnostics.length, 0); + } + }, + ); +});