Files
threshold-ui/ui/admin/app/utils/grant-linter.js
T

708 lines
20 KiB
JavaScript

/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import {
GRANT_FIELDS,
TEMPLATE_RESOURCE_TYPES,
normalizeGrantsSchema,
parseGrantLine,
getValidActions,
getValidOutputFields,
} from './grant-parser';
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 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 {
fields: allFields,
fieldMap: parsedFields,
fieldPositions,
fieldKeyCount,
} = parseGrantLine(line, lineStart);
// Check for invalid format (no '=' in a pair or empty key)
for (const field of allFields) {
if (field.value === undefined || !field.key) {
diagnostics.push({
from: field.pos.keyStart,
to: field.pos.valueEnd,
severity: 'error',
message: translate('general.invalid-format'),
});
}
}
// Validate there are no duplicate fields
for (const field of allFields) {
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 allFields) {
if (!GRANT_FIELDS.includes(field.key)) {
diagnostics.push({
from: field.pos.keyStart,
to: field.pos.valueEnd,
severity: 'error',
message: translate('fields.unknown-field', { field: field.key }),
});
}
}
// Validate field values are not empty
for (const field of Object.keys(fieldPositions)) {
if (!parsedFields[field]) {
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;
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 (!fieldPositions.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 (!fieldPositions.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 (!fieldPositions.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 (!fieldPositions.actions && !fieldPositions.output_fields) {
diagnostics.push({
from: lineStart,
to: lineEnd,
severity: 'error',
message: translate('fields.missing-actions-or-output-fields'),
});
}
}
} else {
if (!fieldPositions.actions && !fieldPositions.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,
);
}
}
// Validate output_fields field
if (parsedFields.output_fields) {
const pos = fieldPositions.output_fields;
const outputFieldList = validateListField(
parsedFields.output_fields,
pos,
diagnostics,
translate,
);
if (outputFieldList.length === 0) {
diagnostics.push({
from: pos.valueStart,
to: pos.valueEnd,
severity: 'error',
message: translate('output-fields.empty-list'),
});
} else if (outputFieldList.includes('*') && outputFieldList.length > 1) {
const wildcardIndex =
pos.valueStart + parsedFields.output_fields.indexOf('*');
diagnostics.push({
from: wildcardIndex,
to: wildcardIndex + 1,
severity: 'error',
message: translate('output-fields.wildcard-cannot-combine'),
actions: [createDeleteTextAction(translate('editor.remove'))],
});
} else {
validateOutputFieldsField(
schema,
diagnostics,
outputFieldList,
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 undefined
const idType = (schema, id) => {
if (id.startsWith('{{') && id.endsWith('}}')) {
return 'template';
}
const prefix = id.split('_')[0];
return schema.resourceTypesByIdPrefix[prefix];
};
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 (!TEMPLATE_RESOURCE_TYPES[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) {
diagnostics.push({
from: idsPos.valueStart,
to: idsPos.valueEnd,
severity: 'error',
message: translate('ids.templates-cannot-combine'),
});
return;
}
return TEMPLATE_RESOURCE_TYPES[idsList[0]];
}
return seenIdType;
};
const validateActionsField = (
schema,
diagnostics,
actionList,
validatedFields,
pos,
translate,
) => {
let validActions = ['*', 'no-op', ...schema.validActions];
let errorMessage = (action) =>
translate('actions.invalid-action', { action });
const { actionsType, actions } = getValidActions(schema, {
typeValue: validatedFields.type?.value,
idsValue: validatedFields.ids?.value,
idsWildcard: validatedFields.ids?.wildcard,
idsKnownType: validatedFields.ids?.knownType,
});
validActions = actions.length > 0 ? actions : validActions;
switch (actionsType) {
case 'id':
errorMessage = (action) =>
translate('actions.id-only', {
type: validatedFields.ids.knownType,
action,
});
break;
case 'collection':
errorMessage = (action) =>
translate('actions.collection-only', { action });
break;
case 'child':
case 'type':
case 'all':
validActions = ['no-op', ...validActions];
break;
}
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: 'warning',
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: 'warning',
message: translate('actions.list-requires-noop'),
actions: [createAddTextAction(translate('editor.add-noop'))],
});
return;
}
}
}
};
const validateOutputFieldsField = (
schema,
diagnostics,
outputFieldList,
validatedFields,
pos,
translate,
) => {
const {
outputFieldsType,
outputFields: validOutputFields,
resolvedType,
} = getValidOutputFields(schema, {
typeValue: validatedFields.type?.value,
idsKnownType: validatedFields.ids?.knownType,
});
const errorMessage =
outputFieldsType === 'type'
? (field) =>
translate('output-fields.invalid-field-for-type', {
field,
type: resolvedType,
})
: (field) => translate('output-fields.invalid-field', { field });
let segmentStart = pos.valueStart;
for (const [i, field] of outputFieldList.entries()) {
let segmentEnd = segmentStart + field.length;
if (i < outputFieldList.length - 1) {
segmentEnd += 1;
}
if (outputFieldList.slice(i + 1).includes(field)) {
diagnostics.push({
from: segmentStart,
to: segmentEnd,
severity: 'error',
message: translate('output-fields.duplicate-field', { field }),
actions: [createDeleteTextAction(translate('editor.remove'))],
});
return;
}
if (!validOutputFields.includes(field)) {
diagnostics.push({
from: pos.valueStart,
to: pos.valueEnd,
severity: 'error',
message: errorMessage(field),
});
return;
}
segmentStart += field.length + 1;
}
};
export const createGrantLinter = (grantsSchema, translate) => {
const schema = normalizeGrantsSchema(grantsSchema);
return (context) => grantLinter(context, schema, translate);
};