feat: 🎸 Add linting to code editor (#3211)

* feat: 🎸 Add linting to code editor

* refactor: 💡 add missing case for wildcard type & ids field

* refactor: 💡 simplify condition + add more tests

* refactor: 💡 cleanup ids validation logic and add more lint erro

* refactor: 💡 simplify logic and add actions linting cases

* feat: 🎸 Add autocompletion to code editor (#3207)

* refactor: 💡 apply PR feedback

* refactor: 💡 PR feedback

* refactor: 💡 add noop action

* fix: 🐛 noop should not be suggested with resource type format

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18462

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
Lisbet AlvarezandZhihe Li authored and Zhihe Li committed 2026-05-14 15:21:29 -04:00
1 parent dc224268f5
commit 1d126dc074
7 files changed
+1078 -8

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ export default factory.extend({
'set-grant-scopes',
],
grant_strings: () => [
'ids=*;action=*',
'ids=*;actions=*',
'ids=*;type=host-catalog;actions=create,read',
],
@@ -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
+4 -4
View File
@@ -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:
@@ -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() {
+767
View File
@@ -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);
};
@@ -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';
@@ -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);
}
},
);
});