Refactor utils to share components (#3279)

* refactor: 💡 initial refactor for utils to share components

* refactor: 💡 actions logic

* Add missing copyright headers

* refactor: 💡 simplify actions getter logic

* refactor: 💡 convert output fields logic into a separate getter

* test: 💍 fix unit test

* fix: 🐛 bug with unknown fields lint error

* refactor: 💡 pr feedback - simplify template check

* refactor: 💡 remove unnecessary unit test file
This commit is contained in:
Lisbet Alvarez authored and GitHub committed 2026-05-29 16:52:00 -07:00
1 parent 878bd241f3
commit 7cddf7cb91
4 files changed
+428 -520

No files matched your search

+71 -267
View File
@@ -3,19 +3,22 @@
* SPDX-License-Identifier: BUSL-1.1
*/
const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields'];
import {
GRANT_FIELDS,
TEMPLATE_RESOURCE_TYPES,
normalizeGrantsSchema,
parseGrantLine,
getValidActions,
getValidOutputFields,
} from './grant-parser';
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',
'{{user.id}}': 'user',
'{{account.id}}': 'account',
};
const filterByPrefix = (values, partial) =>
values.filter((value) => value.startsWith(partial));
const withWildCard = (values = []) => ['*', ...new Set(values)];
const parseIds = (idsValue = '') => idsValue.split(',').filter(Boolean);
const withWildCard = (values = []) => ['*', ...new Set(values)];
const getNoSuggestionsOption = (noSuggestionsLabel) => ({
label: noSuggestionsLabel,
@@ -23,88 +26,15 @@ const getNoSuggestionsOption = (noSuggestionsLabel) => ({
apply: () => {},
});
const parseGrantFields = (lineText = '') =>
lineText
.split(';')
.map((pair) => {
const [fieldName, ...valueParts] = pair.split('=');
// There should only be one '=' in a valid pair, but if there are more,
// we'll keep the value parts together and let the user know it's
// invalid through validation rather than breaking the parsing
return {
fieldName: fieldName,
fieldValue: valueParts.join('='),
};
})
.filter(({ fieldName }) => fieldName);
const getGrantFieldValue = (parsedFields, targetFieldName) =>
parsedFields.find(({ fieldName }) => fieldName === targetFieldName)
?.fieldValue;
const parseGrantLine = (lineText = '') => {
const parsedFields = parseGrantFields(lineText);
const parseGrantLineForCompletions = (lineText = '') => {
const { fields, fieldMap } = parseGrantLine(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 ?? [];
const idActions = resourceType.id_actions ?? [];
const idPrefixes = resourceType.id_prefixes ?? [];
resources[resourceType.type] = {
actions: [...collectionActions, ...idActions],
collectionActions,
idActions,
idPrefixes,
parentType: resourceType.parent_type,
outputFields: resourceType.output_fields ?? [],
};
return resources;
}, {});
const childResourceTypesByParentType = resourceTypes.reduce(
(childrenByParentType, resourceType) => {
const parentType = resourceType.parent_type;
if (!parentType) {
return childrenByParentType;
}
childrenByParentType[parentType] = [
...(childrenByParentType[parentType] ?? []),
resourceType.type,
];
return childrenByParentType;
},
{},
);
const resourceTypesByIdPrefix = resourceTypes.flatMap((resourceType) =>
(resourceType.id_prefixes ?? []).map((idPrefix) => ({
idPrefix,
type: resourceType.type,
})),
);
return {
resourceTypes,
resourcesByType,
childResourceTypesByParentType,
resourceTypesByIdPrefix,
parsedFieldNames: fields.map(({ key }) => key),
idsValue: fieldMap['ids'],
typeValue: fieldMap['type'],
actionsValue: fieldMap['actions'],
outputFieldsValue: fieldMap['output_fields'],
};
};
@@ -114,15 +44,15 @@ const getResourceTypesForId = (schema, id) => {
return [templateType].filter((type) => schema.resourcesByType[type]);
}
return id.includes('_')
? [
schema.resourceTypesByIdPrefix.find(
({ idPrefix }) => id.split('_')[0] === idPrefix,
)?.type,
].filter(Boolean)
: schema.resourceTypesByIdPrefix
.filter(({ idPrefix }) => idPrefix.startsWith(id))
.map(({ type }) => type);
if (id.includes('_')) {
const prefix = id.split('_')[0];
const type = schema.resourceTypesByIdPrefix[prefix];
return type ? [type] : [];
}
return Object.entries(schema.resourceTypesByIdPrefix)
.filter(([prefix]) => prefix.startsWith(id))
.map(([, type]) => type);
};
const getCompatibleResourceTypeForIds = (schema, idsValue) => {
@@ -143,142 +73,30 @@ const getCompatibleResourceTypeForIds = (schema, idsValue) => {
return firstResourceType;
};
const getIdActions = (schema, idsValue) => {
const matchedType = getCompatibleResourceTypeForIds(schema, idsValue);
if (!matchedType) {
return [];
}
return schema.resourcesByType[matchedType]?.idActions;
};
const getChildResourceValues = (schema, idsValue, getValues) => {
const parentType = getCompatibleResourceTypeForIds(schema, idsValue);
if (
!parentType ||
!schema.childResourceTypesByParentType[parentType]?.length
) {
return [];
}
const childTypes = schema.childResourceTypesByParentType[parentType] ?? [];
return [
...new Set(
childTypes.flatMap((type) => getValues(schema.resourcesByType[type])),
),
];
};
const getChildResourceActions = (schema, idsValue) =>
getChildResourceValues(
schema,
idsValue,
(resource) => resource?.actions ?? [],
);
const getChildResourceOutputFields = (schema, idsValue) =>
getChildResourceValues(
schema,
idsValue,
(resource) => resource?.outputFields ?? [],
);
const getTypeOptions = (schema, idsValue) => {
const getTypeOptions = (schema, idsValue, compatibleType) => {
// If there is no ids value, we can only suggest top-level resource types that have collection actions
if (!idsValue) {
return schema.resourceTypes
.filter(
(resource) =>
!resource.parent_type &&
resource.collection_actions.some(
(action) => action === 'list' || action === 'create',
),
)
.map((resource) => resource.type);
return schema.topLevelTypes;
}
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
if (hasSpecificIds) {
const parentType = getCompatibleResourceTypeForIds(schema, idsValue);
if (!parentType) {
if (!compatibleType) {
return [];
}
return schema.childResourceTypesByParentType[parentType] ?? [];
return schema.childResourceTypesByParentType[compatibleType] ?? [];
}
// Return the ones that have id based actions (currently only billing doesn't have them)
return schema.resourceTypes
.filter((resource) => resource.id_actions?.length > 0)
.map((resource) => resource.type);
};
const getActionOptions = (schema, typeValue, idsValue) => {
const actionOptions = withWildCard(
Object.values(schema.resourcesByType).flatMap(({ actions }) => actions),
);
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
const hasOnlyType = Boolean(typeValue) && !idsValue;
const selectedResource = schema.resourcesByType[typeValue];
// Just show ID specific actions as collection actions are invalid here
if (!typeValue && hasSpecificIds) {
const idActions = getIdActions(schema, idsValue);
return idActions.length ? withWildCard(idActions) : [];
}
if (!typeValue || (typeValue === '*' && idsValue === '*')) {
return actionOptions;
}
if (hasOnlyType) {
if (selectedResource?.parentType) {
return [];
}
return selectedResource?.collectionActions ?? [];
}
if (hasSpecificIds) {
// These are pinned IDs with a wildcard type
if (typeValue === '*') {
const childResourceActions = getChildResourceActions(schema, idsValue);
return childResourceActions.length
? withWildCard(childResourceActions)
: [];
}
const matchedType = getCompatibleResourceTypeForIds(schema, idsValue);
const isSelectedChildType = (
schema.childResourceTypesByParentType[matchedType] ?? []
).includes(typeValue);
// A specific ID paired with its own type is not a valid combination.
// For pinned IDs, only child types are valid.
if (!selectedResource || !isSelectedChildType) {
return [];
}
// Return all the actions for the selected type from a pinned ID
return withWildCard([
...selectedResource.collectionActions,
...selectedResource.idActions,
]);
}
// Should be wildcard IDs with a specific type, so show all actions for that type
return selectedResource ? withWildCard(selectedResource.actions) : [];
return Object.entries(schema.resourcesByType)
.filter(([, resource]) => resource.idActions.length > 0)
.map(([type]) => type);
};
export const analyzeGrantString = (grantsSchema, grantString = '') => {
const schema = normalizeGrantsSchema(grantsSchema);
const { idsValue, typeValue } = parseGrantLine(grantString);
const { idsValue, typeValue } = parseGrantLineForCompletions(grantString);
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
const hasExplicitType = Boolean(typeValue) && typeValue !== '*';
@@ -316,21 +134,27 @@ export const analyzeGrantString = (grantsSchema, grantString = '') => {
detectedResourceType = childTypes.length ? childTypes : null;
} else if (typeValue !== '*' && idsValue) {
// No type field, infer resource type from the ID prefix
detectedResourceType = getCompatibleResourceTypeForIds(schema, idsValue);
detectedResourceType = compatibleIdsResourceType;
}
const hasWildcardIds = idsValue === '*';
const crudlOnly = hasWildcardIds && !hasExplicitType;
const actions =
const actionOptions =
idsValue || typeValue
? getActionOptions(schema, typeValue, idsValue)
.filter(
(action) =>
action !== '*' && (!crudlOnly || CRUDL_ACTIONS.has(action)),
)
.sort((left, right) => left.localeCompare(right))
? getValidActions(schema, {
typeValue,
idsValue,
idsWildcard: hasWildcardIds,
idsKnownType: compatibleIdsResourceType,
}).actions
: [];
const actions =
actionOptions
.filter(
(action) => action !== '*' && (!crudlOnly || CRUDL_ACTIONS.has(action)),
)
.sort((left, right) => left.localeCompare(right)) ?? [];
return {
actions,
@@ -341,37 +165,6 @@ export const analyzeGrantString = (grantsSchema, grantString = '') => {
};
};
const getOutputFieldOptions = (schema, typeValue, idsValue) => {
const selectedResource = schema.resourcesByType[typeValue];
if (typeValue && typeValue !== '*' && selectedResource) {
return selectedResource.outputFields.length
? withWildCard(selectedResource.outputFields)
: ['*'];
}
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
// Pinned IDs with wildcard type
if (typeValue === '*' && hasSpecificIds) {
const childOutputFields = getChildResourceOutputFields(schema, idsValue);
return childOutputFields.length ? withWildCard(childOutputFields) : ['*'];
}
if (!typeValue && hasSpecificIds) {
const matchedType = getCompatibleResourceTypeForIds(schema, idsValue);
if (matchedType) {
const resourceOutputFields =
schema.resourcesByType[matchedType]?.outputFields ?? [];
return resourceOutputFields.length
? withWildCard(resourceOutputFields)
: ['*'];
}
}
return ['*'];
};
const getIdLookupTypes = (schema, typeValue, enteredIds) => {
if (enteredIds.length > 0) {
const compatibleResourceType = getCompatibleResourceTypeForIds(
@@ -407,8 +200,11 @@ async function grantCompletions(
const line = context.state.doc.lineAt(context.pos);
const beforeCursor = line.text.slice(0, context.pos - line.from);
const { parsedFields, idsValue, typeValue } = parseGrantLine(line.text);
const parsedFieldNames = parsedFields.map(({ fieldName }) => fieldName);
const { parsedFieldNames, idsValue, typeValue } =
parseGrantLineForCompletions(line.text);
const compatibleType = idsValue
? getCompatibleResourceTypeForIds(schema, idsValue)
: null;
// 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
@@ -435,7 +231,11 @@ async function grantCompletions(
const typeValueMatch = beforeCursor.match(/type=([a-z-*]*)$/);
if (typeValueMatch) {
const partial = typeValueMatch[1];
const matchingTypeOptions = getTypeOptions(schema, idsValue);
const matchingTypeOptions = getTypeOptions(
schema,
idsValue,
compatibleType,
);
const typeOptions =
partial === '' && matchingTypeOptions.length
? withWildCard(matchingTypeOptions)
@@ -525,10 +325,13 @@ async function grantCompletions(
const partial = enteredActions.pop() ?? '';
const hasEnteredActions = enteredActions.length > 0;
const options = filterByPrefix(
getActionOptions(schema, typeValue, idsValue),
partial,
)
const actionOptions = getValidActions(schema, {
typeValue,
idsValue,
idsWildcard: idsValue?.includes('*'),
idsKnownType: compatibleType,
}).actions;
const options = filterByPrefix(actionOptions, partial)
.filter((action) => !hasEnteredActions || action !== '*')
.filter((action) => !enteredActions.includes(action))
.map((action) => ({
@@ -554,10 +357,11 @@ async function grantCompletions(
const partial = enteredOutputFields.pop() ?? '';
const hasEnteredOutputFields = enteredOutputFields.length > 0;
const options = filterByPrefix(
getOutputFieldOptions(schema, typeValue, idsValue),
partial,
)
const { outputFields } = getValidOutputFields(schema, {
typeValue,
idsKnownType: compatibleType,
});
const options = filterByPrefix(outputFields, partial)
.filter((value) => !hasEnteredOutputFields || value !== '*')
.filter((value) => !enteredOutputFields.includes(value))
.map((value) => ({
+73 -253
View File
@@ -2,14 +2,14 @@
* 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}}',
];
import {
GRANT_FIELDS,
TEMPLATE_RESOURCE_TYPES,
normalizeGrantsSchema,
parseGrantLine,
getValidActions,
getValidOutputFields,
} from './grant-parser';
const createDeleteTextAction = (name) => ({
name,
@@ -29,86 +29,6 @@ const createAddTextAction = (name) => ({
},
});
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,
outputFields: resourceType.output_fields ?? [],
};
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();
@@ -175,50 +95,27 @@ const grantLinter = (context, schema, translate) => {
}
// Parse key-value pairs separated by semicolons
const pairs = trimmedLine.split(';').filter((p) => p.trim());
const parsedFields = {};
const fieldPositions = {};
const allFields = [];
const fieldKeyCount = {};
const {
fields: allFields,
fieldMap: parsedFields,
fieldPositions,
fieldKeyCount,
} = parseGrantLine(line, lineStart);
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;
// 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: pairStart,
to: pairStart + pair.length,
from: field.pos.keyStart,
to: field.pos.valueEnd,
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
// Validate there are no duplicate fields
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,
@@ -232,21 +129,20 @@ const grantLinter = (context, schema, translate) => {
}
// Validate all fields are recognized
for (const field of Object.keys(parsedFields)) {
if (!GRANT_FIELDS.includes(field)) {
const pos = fieldPositions[field];
for (const field of allFields) {
if (!GRANT_FIELDS.includes(field.key)) {
diagnostics.push({
from: pos.keyStart,
to: pos.valueEnd,
from: field.pos.keyStart,
to: field.pos.valueEnd,
severity: 'error',
message: translate('fields.unknown-field', { field }),
message: translate('fields.unknown-field', { field: field.key }),
});
}
}
// Validate field values are not empty
for (const [field, value] of Object.entries(parsedFields)) {
if (!value.trim()) {
for (const field of Object.keys(fieldPositions)) {
if (!parsedFields[field]) {
const pos = fieldPositions[field];
diagnostics.push({
from: pos.valueStart,
@@ -262,7 +158,7 @@ const grantLinter = (context, schema, translate) => {
// Validate type field value is a valid resource type
if (parsedFields.type) {
validatedFields.type = { value: '', wildcard: false };
const typeValue = parsedFields.type.trim();
const typeValue = parsedFields.type;
const pos = fieldPositions.type;
if (!schema.validResourceTypes.includes(typeValue)) {
diagnostics.push({
@@ -273,7 +169,7 @@ const grantLinter = (context, schema, translate) => {
});
} else {
validatedFields.type.value = typeValue;
if (!parsedFields.ids) {
if (!fieldPositions.ids) {
if (!schema.topLevelTypes.includes(typeValue)) {
diagnostics.push({
from: pos.valueStart,
@@ -317,7 +213,7 @@ const grantLinter = (context, schema, translate) => {
});
} else {
validatedFields.ids.wildcard = true;
if (!parsedFields.type) {
if (!fieldPositions.type) {
diagnostics.push({
from: lineStart,
to: lineEnd,
@@ -343,14 +239,14 @@ const grantLinter = (context, schema, translate) => {
}
if (!validatedFields.ids) {
if (!validatedFields.type) {
if (!fieldPositions.type) {
diagnostics.push({
from: lineStart,
to: lineEnd,
severity: 'error',
message: translate('fields.missing-ids-or-type'),
});
} else if (validatedFields.type.wildcard) {
} else if (validatedFields.type?.wildcard) {
diagnostics.push({
from: lineStart,
to: lineEnd,
@@ -358,7 +254,7 @@ const grantLinter = (context, schema, translate) => {
message: translate('type.wildcard-requires-ids'),
});
} else {
if (!parsedFields.actions && !parsedFields.output_fields) {
if (!fieldPositions.actions && !fieldPositions.output_fields) {
diagnostics.push({
from: lineStart,
to: lineEnd,
@@ -368,7 +264,7 @@ const grantLinter = (context, schema, translate) => {
}
}
} else {
if (!parsedFields.actions && !parsedFields.output_fields) {
if (!fieldPositions.actions && !fieldPositions.output_fields) {
diagnostics.push({
from: lineStart,
to: lineEnd,
@@ -489,30 +385,13 @@ const validateListField = (fieldValue, pos, diagnostics, translate) => {
return valList;
};
// Returns template, resource type, or null;
// Returns 'template', resource type, or undefined
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 ?? []),
),
];
return schema.resourceTypesByIdPrefix[prefix];
};
const validateIdsField = (
@@ -573,7 +452,7 @@ const validateIdsField = (
}
const currentIdType = idType(schema, id);
if (currentIdType === 'template') {
if (!ID_TEMPLATES.includes(id)) {
if (!TEMPLATE_RESOURCE_TYPES[id]) {
diagnostics.push({
from: segmentStart,
to: segmentEnd,
@@ -652,29 +531,12 @@ const validateIdsField = (
message: translate('ids.templates-cannot-combine'),
});
return;
} 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 TEMPLATE_RESOURCE_TYPES[idsList[0]];
}
return seenIdType;
};
const getIdActionsForResourceType = (schema, resourceType) => {
switch (resourceType) {
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,
@@ -683,49 +545,34 @@ const validateActionsField = (
pos,
translate,
) => {
let validActions = schema.validActions;
let validActions = ['*', 'no-op', ...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,
);
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,
});
}
} 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'),
);
break;
case 'collection':
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,
];
}
break;
case 'child':
case 'type':
case 'all':
validActions = ['no-op', ...validActions];
break;
}
let segmentStart = pos.valueStart;
@@ -808,49 +655,22 @@ const validateOutputFieldsField = (
pos,
translate,
) => {
let validOutputFields = ['*'];
let errorMessage = (field) =>
translate('output-fields.invalid-field', { field });
if (validatedFields.type?.value && !validatedFields.type.wildcard) {
const resourceOutputFields =
schema.resourcesByType[validatedFields.type.value]?.outputFields ?? [];
validOutputFields = ['*', ...resourceOutputFields];
errorMessage = (field) =>
translate('output-fields.invalid-field-for-type', {
field,
type: validatedFields.type.value,
});
} else if (validatedFields.type?.wildcard && validatedFields.ids?.knownType) {
// Pinned IDs with wildcard type: validate against union of child types' output fields
const parentType = validatedFields.ids.knownType;
const childTypes = schema.childResourceTypesByParentType[parentType] ?? [];
const childOutputFields = [
...new Set(
childTypes.flatMap(
(type) => schema.resourcesByType[type]?.outputFields ?? [],
),
),
];
validOutputFields = ['*', ...childOutputFields];
errorMessage = (field) =>
translate('output-fields.invalid-field', { field });
} else if (validatedFields.ids?.knownType && !validatedFields.type) {
const knownType = validatedFields.ids.knownType;
const TEMPLATE_RESOURCE_TYPES = {
'account-template': 'account',
'user-template': 'user',
};
const resolvedType = TEMPLATE_RESOURCE_TYPES[knownType] ?? knownType;
const resourceOutputFields =
schema.resourcesByType[resolvedType]?.outputFields ?? [];
validOutputFields = ['*', ...resourceOutputFields];
errorMessage = (field) =>
translate('output-fields.invalid-field-for-type', {
field,
type: resolvedType,
});
}
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()) {
+279
View File
@@ -0,0 +1,279 @@
/**
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
export const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields'];
export const TEMPLATE_RESOURCE_TYPES = {
'{{.User.Id}}': 'user',
'{{.Account.Id}}': 'account',
'{{user.id}}': 'user',
'{{account.id}}': 'account',
};
export 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,
outputFields: resourceType.output_fields ?? [],
};
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);
const resourceTypesByIdPrefix = 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,
resourceTypesByIdPrefix,
childResourceTypesByParentType,
parentTypes,
topLevelTypes,
validResourceTypes,
validActions,
};
};
export const parseGrantLine = (lineText = '', lineStart = 0) => {
const pairs = lineText.split(';').filter((p) => p.trim());
const fields = [];
const fieldMap = {};
const fieldPositions = {};
const fieldKeyCount = {};
let pairOffset = 0;
for (const pair of pairs) {
const pairInLine = lineText.indexOf(pair, pairOffset);
const [key, ...valueParts] = pair.split('=');
const value = valueParts.join('=');
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,
};
const hasValue = valueParts.length > 0;
fields.push({
key: trimmedKey,
value: hasValue ? value.trim() : undefined,
pos,
});
fieldKeyCount[trimmedKey] = (fieldKeyCount[trimmedKey] ?? 0) + 1;
if (hasValue && trimmedKey) {
fieldPositions[trimmedKey] = pos;
const trimmedValue = value.trim();
if (trimmedValue) {
fieldMap[trimmedKey] = trimmedValue;
}
}
pairOffset = pairInLine + pair.length + 1;
}
return { fields, fieldMap, fieldPositions, fieldKeyCount };
};
const getChildResourceValues = (schema, compatibleType, getValues) => {
if (
!compatibleType ||
!schema.childResourceTypesByParentType[compatibleType]?.length
) {
return [];
}
const childTypes =
schema.childResourceTypesByParentType[compatibleType] ?? [];
return [
...new Set(
childTypes.flatMap((type) => getValues(schema.resourcesByType[type])),
),
];
};
const getChildResourceActions = (schema, compatibleType) =>
getChildResourceValues(
schema,
compatibleType,
(resource) => resource?.actions ?? [],
);
const getChildResourceOutputFields = (schema, compatibleType) =>
getChildResourceValues(
schema,
compatibleType,
(resource) => resource?.outputFields ?? [],
);
const withWildCard = (values = []) => ['*', ...new Set(values)];
export const getValidActions = (
schema,
{ typeValue, idsValue, idsWildcard, idsKnownType },
) => {
const typeWildcard = typeValue === '*';
const hasExplicitType = Boolean(typeValue) && !typeWildcard;
if (idsValue) {
// No type, ids resolve to a known type → that type's id actions.
if (!typeValue && idsKnownType) {
return {
actionsType: 'id',
actions: withWildCard(
schema.resourcesByType[idsKnownType]?.idActions ?? [],
),
};
}
// Wildcard type, known id type → child resource actions.
if (typeWildcard && idsKnownType) {
return {
actionsType: 'child',
actions: withWildCard(getChildResourceActions(schema, idsKnownType)),
};
}
// Explicit type → validate parent relationship then return type actions.
if (hasExplicitType) {
if (idsKnownType) {
const selectedResource = schema.resourcesByType[typeValue];
if (selectedResource?.parentType !== idsKnownType) {
return { actionsType: 'invalid', actions: [] };
}
}
return {
actionsType: 'type',
actions: withWildCard(schema.resourcesByType[typeValue]?.actions ?? []),
};
}
// Wildcard ids with no explicit type → all actions.
if (idsWildcard && !hasExplicitType) {
return { actionsType: 'all', actions: withWildCard(schema.validActions) };
}
} else {
// No ids and no type → all actions.
if (!typeValue) {
return { actionsType: 'all', actions: withWildCard(schema.validActions) };
}
// No ids, explicit top-level type → collection actions.
if (hasExplicitType && schema.topLevelTypes.includes(typeValue)) {
return {
actionsType: 'collection',
actions: schema.resourcesByType[typeValue]?.collectionActions ?? [],
};
}
}
return { actionsType: 'invalid', actions: [] };
};
export const getValidOutputFields = (schema, { typeValue, idsKnownType }) => {
const typeWildcard = typeValue === '*';
const hasExplicitType = Boolean(typeValue) && !typeWildcard;
if (hasExplicitType) {
const resourceOutputFields =
schema.resourcesByType[typeValue]?.outputFields ?? [];
return {
outputFieldsType: 'type',
outputFields: resourceOutputFields.length
? withWildCard(resourceOutputFields)
: ['*'],
resolvedType: typeValue,
};
}
if (typeWildcard && idsKnownType) {
const childOutputFields = getChildResourceOutputFields(
schema,
idsKnownType,
);
return {
outputFieldsType: 'child',
outputFields: childOutputFields.length
? withWildCard(childOutputFields)
: ['*'],
resolvedType: null,
};
}
if (!typeValue && idsKnownType) {
const resourceOutputFields =
schema.resourcesByType[idsKnownType]?.outputFields ?? [];
return {
outputFieldsType: 'type',
outputFields: resourceOutputFields.length
? withWildCard(resourceOutputFields)
: ['*'],
resolvedType: idsKnownType,
};
}
return { outputFieldsType: 'all', outputFields: ['*'], resolvedType: null };
};
@@ -72,6 +72,10 @@ module('Unit | Utility | grant-linter', function (hooks) {
'type=credential;ids=csst_1234;actions=read,write;actions=delete',
'Duplicate field "actions"',
], // duplicate actions field
[
'type=credential;ids=cs_1234;actions=read;random=',
'Unknown field "random"',
], // unknown field "random"
[
'type=credential;ids=cs_1234;actions=read;random=name',
'Unknown field "random"',
@@ -80,6 +84,7 @@ module('Unit | Utility | grant-linter', function (hooks) {
'=credential;actions=;ids=cs_1234',
'Invalid format: expected key=value',
], // missing type key
['type;actions=;ids=cs_1234', 'Invalid format: expected key=value'], // missing type value and equal sign
],
function (assert, [grantString, errorMsg]) {
const diagnostics = this.grantLinter(createContext(grantString));