From 7cddf7cb91a20b610a7a45fffacae98577660640 Mon Sep 17 00:00:00 2001 From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com> Date: Fri, 29 May 2026 16:52:00 -0700 Subject: [PATCH] Refactor utils to share components (#3279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ui/admin/app/utils/grant-completions.js | 338 ++++-------------- ui/admin/app/utils/grant-linter.js | 326 ++++------------- ui/admin/app/utils/grant-parser.js | 279 +++++++++++++++ .../tests/unit/utils/grant-linter-test.js | 5 + 4 files changed, 428 insertions(+), 520 deletions(-) create mode 100644 ui/admin/app/utils/grant-parser.js diff --git a/ui/admin/app/utils/grant-completions.js b/ui/admin/app/utils/grant-completions.js index 16805d073..5e45c263a 100644 --- a/ui/admin/app/utils/grant-completions.js +++ b/ui/admin/app/utils/grant-completions.js @@ -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) => ({ diff --git a/ui/admin/app/utils/grant-linter.js b/ui/admin/app/utils/grant-linter.js index e09b1c53f..8e283cdb5 100644 --- a/ui/admin/app/utils/grant-linter.js +++ b/ui/admin/app/utils/grant-linter.js @@ -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()) { diff --git a/ui/admin/app/utils/grant-parser.js b/ui/admin/app/utils/grant-parser.js new file mode 100644 index 000000000..122505c1a --- /dev/null +++ b/ui/admin/app/utils/grant-parser.js @@ -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 }; +}; diff --git a/ui/admin/tests/unit/utils/grant-linter-test.js b/ui/admin/tests/unit/utils/grant-linter-test.js index 3e4fcfd1b..b8e758de7 100644 --- a/ui/admin/tests/unit/utils/grant-linter-test.js +++ b/ui/admin/tests/unit/utils/grant-linter-test.js @@ -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));