diff --git a/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.json b/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.json new file mode 100644 index 0000000000..ead92b3ae4 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.awsiam", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/aws/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.awsiam/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.ts b/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.ts new file mode 100644 index 0000000000..1d7c90d1f8 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/AwsIam.node.ts @@ -0,0 +1,69 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { user, group } from './descriptions'; +import { BASE_URL } from './helpers/constants'; +import { encodeBodyAsFormUrlEncoded } from './helpers/utils'; +import { searchGroups, searchUsers, searchGroupsForUser } from './methods/listSearch'; + +export class AwsIam implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS IAM', + name: 'awsIam', + icon: 'file:AwsIam.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interacts with Amazon IAM', + defaults: { name: 'AWS IAM' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'aws', + required: true, + }, + ], + requestDefaults: { + baseURL: BASE_URL, + json: true, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + default: 'user', + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'Group', + value: 'group', + }, + ], + routing: { + send: { + preSend: [encodeBodyAsFormUrlEncoded], + }, + }, + }, + ...user.description, + ...group.description, + ], + }; + + methods = { + listSearch: { + searchGroups, + searchUsers, + searchGroupsForUser, + }, + }; +} diff --git a/packages/nodes-base/nodes/Aws/IAM/AwsIam.svg b/packages/nodes-base/nodes/Aws/IAM/AwsIam.svg new file mode 100644 index 0000000000..618d0b4a39 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/AwsIam.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/64/Arch_AWS-Identity-and-Access-Management_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/common.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/common.ts new file mode 100644 index 0000000000..a501397f38 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/common.ts @@ -0,0 +1,158 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { validateName } from '../helpers/utils'; + +export const paginationParameters: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + default: 100, + type: 'number', + validateType: 'number', + typeOptions: { + minValue: 1, + }, + description: 'Max number of results to return', + displayOptions: { + hide: { + returnAll: [true], + }, + }, + routing: { + send: { + property: 'MaxItems', + type: 'body', + value: '={{ $value }}', + }, + }, + }, +]; + +export const userLocator: INodeProperties = { + displayName: 'User', + name: 'user', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'userName', + type: 'string', + placeholder: 'e.g. Admins', + hint: 'Enter the user name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The user name must follow the allowed pattern', + }, + }, + ], + }, + ], +}; + +export const groupLocator: INodeProperties = { + displayName: 'Group', + name: 'group', + required: true, + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroups', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'groupName', + type: 'string', + placeholder: 'e.g. Admins', + hint: 'Enter the group name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The group name must follow the allowed pattern.', + }, + }, + ], + }, + ], +}; + +export const pathParameter: INodeProperties = { + displayName: 'Path', + name: 'path', + type: 'string', + validateType: 'string', + default: '/', +}; + +export const groupNameParameter: INodeProperties = { + displayName: 'Group Name', + name: 'groupName', + required: true, + type: 'string', + validateType: 'string', + typeOptions: { + maxLength: 128, + regex: '^[+=,.@\\-_A-Za-z0-9]+$', + }, + default: '', + placeholder: 'e.g. GroupName', + routing: { + send: { + preSend: [validateName], + }, + }, +}; + +export const userNameParameter: INodeProperties = { + displayName: 'User Name', + name: 'userName', + required: true, + type: 'string', + validateType: 'string', + default: '', + placeholder: 'e.g. JohnSmith', + typeOptions: { + maxLength: 64, + regex: '^[A-Za-z0-9+=,\\.@_-]+$', + }, + routing: { + send: { + preSend: [validateName], + }, + }, +}; diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/Group.resource.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/Group.resource.ts new file mode 100644 index 0000000000..9abdd1be8a --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/Group.resource.ts @@ -0,0 +1,145 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as del from './delete.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import * as update from './update.operation'; +import { CURRENT_VERSION } from '../../helpers/constants'; +import { handleError } from '../../helpers/errorHandler'; +import { + deleteGroupMembers, + simplifyGetAllGroupsResponse, + simplifyGetGroupsResponse, +} from '../../helpers/utils'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { + show: { + resource: ['group'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create group', + description: 'Create a new group', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'CreateGroup', + Version: CURRENT_VERSION, + GroupName: '={{ $parameter["groupName"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete group', + description: 'Delete an existing group', + routing: { + send: { + preSend: [deleteGroupMembers], + }, + request: { + method: 'POST', + url: '', + body: { + Action: 'DeleteGroup', + Version: CURRENT_VERSION, + GroupName: '={{ $parameter["group"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Get', + value: 'get', + action: 'Get group', + description: 'Retrieve details of an existing group', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: '={{ $parameter["group"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError, simplifyGetGroupsResponse], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many groups', + description: 'Retrieve a list of groups', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'ListGroups', + Version: CURRENT_VERSION, + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError, simplifyGetAllGroupsResponse], + }, + }, + }, + { + name: 'Update', + value: 'update', + action: 'Update group', + description: 'Update an existing group', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'UpdateGroup', + Version: CURRENT_VERSION, + GroupName: '={{ $parameter["group"] }}', + NewGroupName: '={{ $parameter["groupName"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + ], + }, + + ...create.description, + ...del.description, + ...get.description, + ...getAll.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/create.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/create.operation.ts new file mode 100644 index 0000000000..e12c5e9b87 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/create.operation.ts @@ -0,0 +1,43 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { validatePath } from '../../helpers/utils'; +import { groupNameParameter, pathParameter } from '../common'; + +const properties: INodeProperties[] = [ + { + ...groupNameParameter, + description: 'The name of the new group to create', + placeholder: 'e.g. GroupName', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + options: [ + { + ...pathParameter, + placeholder: 'e.g. /division_abc/engineering/', + description: 'The path to the group, if it is not included, it defaults to a slash (/)', + routing: { + send: { + preSend: [validatePath], + property: 'Path', + type: 'query', + }, + }, + }, + ], + placeholder: 'Add Option', + type: 'collection', + }, +]; + +const displayOptions = { + show: { + resource: ['group'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/delete.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/delete.operation.ts new file mode 100644 index 0000000000..c6f865eb62 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/delete.operation.ts @@ -0,0 +1,20 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { groupLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...groupLocator, + description: 'Select the group you want to delete', + }, +]; + +const displayOptions = { + show: { + resource: ['group'], + operation: ['delete'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/get.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/get.operation.ts new file mode 100644 index 0000000000..11bde3fe93 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/get.operation.ts @@ -0,0 +1,27 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { groupLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...groupLocator, + description: 'Select the group you want to retrieve', + }, + { + displayName: 'Include Users', + name: 'includeUsers', + type: 'boolean', + default: false, + description: 'Whether to include a list of users in the group', + }, +]; + +const displayOptions = { + show: { + resource: ['group'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/getAll.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/getAll.operation.ts new file mode 100644 index 0000000000..ec0ae87131 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/getAll.operation.ts @@ -0,0 +1,24 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { paginationParameters } from '../common'; + +const properties: INodeProperties[] = [ + ...paginationParameters, + { + displayName: 'Include Users', + name: 'includeUsers', + type: 'boolean', + default: false, + description: 'Whether to include a list of users in the group', + }, +]; + +const displayOptions = { + show: { + resource: ['group'], + operation: ['getAll'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/group/update.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/update.operation.ts new file mode 100644 index 0000000000..53f3acb8d5 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/group/update.operation.ts @@ -0,0 +1,47 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { validatePath } from '../../helpers/utils'; +import { groupLocator, groupNameParameter, pathParameter } from '../common'; + +const properties: INodeProperties[] = [ + { + ...groupLocator, + description: 'Select the group you want to update', + }, + { + ...groupNameParameter, + description: 'The new name of the group', + placeholder: 'e.g. GroupName', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + ...pathParameter, + placeholder: 'e.g. /division_abc/engineering/', + description: 'The new path to the group, if it is not included, it defaults to a slash (/)', + routing: { + send: { + preSend: [validatePath], + property: 'NewPath', + type: 'query', + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['group'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/index.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/index.ts new file mode 100644 index 0000000000..ad27c9ef85 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/index.ts @@ -0,0 +1,2 @@ +export * as group from './group/Group.resource'; +export * as user from './user/User.resource'; diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/User.resource.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/User.resource.ts new file mode 100644 index 0000000000..a7905c4f7f --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/User.resource.ts @@ -0,0 +1,197 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as addToGroup from './addToGroup.operation'; +import * as create from './create.operation'; +import * as del from './delete.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import * as removeFromGroup from './removeFromGroup.operation'; +import * as update from './update.operation'; +import { CURRENT_VERSION } from '../../helpers/constants'; +import { handleError } from '../../helpers/errorHandler'; +import { removeUserFromGroups, simplifyGetAllUsersResponse } from '../../helpers/utils'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Add to Group', + value: 'addToGroup', + description: 'Add an existing user to a group', + action: 'Add user to group', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'AddUserToGroup', + Version: CURRENT_VERSION, + UserName: '={{ $parameter["user"] }}', + GroupName: '={{ $parameter["group"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Create', + value: 'create', + description: 'Create a new user', + action: 'Create user', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'CreateUser', + Version: CURRENT_VERSION, + UserName: '={{ $parameter["userName"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a user', + action: 'Delete user', + routing: { + send: { + preSend: [removeUserFromGroups], + }, + request: { + method: 'POST', + url: '', + body: { + Action: 'DeleteUser', + Version: CURRENT_VERSION, + UserName: '={{ $parameter["user"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a user', + action: 'Get user', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'GetUser', + Version: CURRENT_VERSION, + UserName: '={{ $parameter["user"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'GetUserResponse.GetUserResult.User', + }, + }, + handleError, + ], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of users', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'ListUsers', + Version: CURRENT_VERSION, + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError, simplifyGetAllUsersResponse], + }, + }, + action: 'Get many users', + }, + { + name: 'Remove From Group', + value: 'removeFromGroup', + description: 'Remove a user from a group', + action: 'Remove user from group', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'RemoveUserFromGroup', + Version: CURRENT_VERSION, + UserName: '={{ $parameter["user"] }}', + GroupName: '={{ $parameter["group"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Update a user', + action: 'Update user', + routing: { + request: { + method: 'POST', + url: '', + body: { + Action: 'UpdateUser', + Version: CURRENT_VERSION, + NewUserName: '={{ $parameter["userName"] }}', + UserName: '={{ $parameter["user"] }}', + }, + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleError], + }, + }, + }, + ], + }, + + ...addToGroup.description, + ...create.description, + ...del.description, + ...get.description, + ...getAll.description, + ...update.description, + ...removeFromGroup.description, +]; diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/addToGroup.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/addToGroup.operation.ts new file mode 100644 index 0000000000..b1e93edbf9 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/addToGroup.operation.ts @@ -0,0 +1,24 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { groupLocator, userLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userLocator, + description: 'Select the user you want to add to the group', + }, + { + ...groupLocator, + description: 'Select the group you want to add the user to', + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['addToGroup'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/create.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/create.operation.ts new file mode 100644 index 0000000000..1620c1c472 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/create.operation.ts @@ -0,0 +1,96 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { preprocessTags, validatePath, validatePermissionsBoundary } from '../../helpers/utils'; +import { pathParameter, userNameParameter } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userNameParameter, + description: 'The username of the new user to create', + placeholder: 'e.g. UserName', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + ...pathParameter, + description: 'The path for the user name', + placeholder: 'e.g. /division_abc/subdivision_xyz/', + routing: { + send: { + preSend: [validatePath], + property: 'Path', + type: 'query', + }, + }, + }, + { + displayName: 'Permissions Boundary', + name: 'permissionsBoundary', + default: '', + description: + 'The ARN of the managed policy that is used to set the permissions boundary for the user', + placeholder: 'e.g. arn:aws:iam::123456789012:policy/ExampleBoundaryPolicy', + type: 'string', + validateType: 'string', + routing: { + send: { + preSend: [validatePermissionsBoundary], + }, + }, + }, + { + displayName: 'Tags', + name: 'tags', + type: 'fixedCollection', + description: 'A list of tags that you want to attach to the new user', + default: [], + placeholder: 'Add Tag', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'tags', + displayName: 'Tag', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'e.g., Department', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'e.g., Engineering', + }, + ], + }, + ], + routing: { + send: { + preSend: [preprocessTags], + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/delete.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/delete.operation.ts new file mode 100644 index 0000000000..b6bbe568f2 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/delete.operation.ts @@ -0,0 +1,20 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { userLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userLocator, + description: 'Select the user you want to delete', + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['delete'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/get.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/get.operation.ts new file mode 100644 index 0000000000..8bd5644518 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/get.operation.ts @@ -0,0 +1,20 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { userLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userLocator, + description: 'Select the user you want to retrieve', + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/getAll.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/getAll.operation.ts new file mode 100644 index 0000000000..4e8dd67375 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/getAll.operation.ts @@ -0,0 +1,43 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { validateUserPath } from '../../helpers/utils'; +import { paginationParameters } from '../common'; + +const properties: INodeProperties[] = [ + ...paginationParameters, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Path Prefix', + name: 'pathPrefix', + type: 'string', + validateType: 'string', + default: '/', + description: 'The path prefix for filtering the results', + placeholder: 'e.g. /division_abc/subdivision_xyz/', + routing: { + send: { + preSend: [validateUserPath], + property: 'PathPrefix', + value: '={{ $value }}', + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['getAll'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/removeFromGroup.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/removeFromGroup.operation.ts new file mode 100644 index 0000000000..101c0e6dfa --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/removeFromGroup.operation.ts @@ -0,0 +1,51 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { groupLocator, userLocator } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userLocator, + description: 'Select the user you want to remove from the group', + }, + { + ...groupLocator, + description: 'Select the group you want to remove the user from', + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchGroupsForUser', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'groupName', + type: 'string', + hint: 'Enter the group name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The group name must follow the allowed pattern', + }, + }, + ], + placeholder: 'e.g. Admins', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['removeFromGroup'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/descriptions/user/update.operation.ts b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/update.operation.ts new file mode 100644 index 0000000000..606e4f0747 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/descriptions/user/update.operation.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { validatePath } from '../../helpers/utils'; +import { pathParameter, userLocator, userNameParameter } from '../common'; + +const properties: INodeProperties[] = [ + { + ...userLocator, + description: 'Select the user you want to update', + }, + { + ...userNameParameter, + description: 'The new name of the user', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + ...pathParameter, + placeholder: 'e.g. /division_abc/subdivision_xyz/', + routing: { + send: { + preSend: [validatePath], + property: 'NewPath', + type: 'query', + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['user'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Aws/IAM/helpers/constants.ts b/packages/nodes-base/nodes/Aws/IAM/helpers/constants.ts new file mode 100644 index 0000000000..66ca22edee --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/helpers/constants.ts @@ -0,0 +1,15 @@ +export const CURRENT_VERSION = '2010-05-08'; +export const BASE_URL = 'https://iam.amazonaws.com'; +export const ERROR_DESCRIPTIONS = { + EntityAlreadyExists: { + User: 'The given user name already exists - try entering a unique name for the user.', + Group: 'The given group name already exists - try entering a unique name for the group.', + }, + NoSuchEntity: { + User: 'The given user was not found - try entering a different user.', + Group: 'The given group was not found - try entering a different group.', + }, + DeleteConflict: { + Default: 'Cannot delete entity, please remove users from group first.', + }, +}; diff --git a/packages/nodes-base/nodes/Aws/IAM/helpers/errorHandler.ts b/packages/nodes-base/nodes/Aws/IAM/helpers/errorHandler.ts new file mode 100644 index 0000000000..3993074b3e --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/helpers/errorHandler.ts @@ -0,0 +1,86 @@ +import type { + JsonObject, + IDataObject, + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { ERROR_DESCRIPTIONS } from './constants'; +import type { AwsError, ErrorMessage } from './types'; + +function mapErrorToResponse(errorCode: string, errorMessage: string): ErrorMessage | undefined { + const isUser = /user/i.test(errorMessage); + const isGroup = /group/i.test(errorMessage); + + switch (errorCode) { + case 'EntityAlreadyExists': + if (isUser) { + return { + message: errorMessage, + description: ERROR_DESCRIPTIONS.EntityAlreadyExists.User, + }; + } + if (isGroup) { + return { + message: errorMessage, + description: ERROR_DESCRIPTIONS.EntityAlreadyExists.Group, + }; + } + break; + + case 'NoSuchEntity': + if (isUser) { + return { + message: errorMessage, + description: ERROR_DESCRIPTIONS.NoSuchEntity.User, + }; + } + if (isGroup) { + return { + message: errorMessage, + description: ERROR_DESCRIPTIONS.NoSuchEntity.Group, + }; + } + break; + + case 'DeleteConflict': + return { + message: errorMessage, + description: ERROR_DESCRIPTIONS.DeleteConflict.Default, + }; + } + + return undefined; +} + +export async function handleError( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + const statusCode = String(response.statusCode); + + if (!statusCode.startsWith('4') && !statusCode.startsWith('5')) { + return data; + } + + const responseBody = response.body as IDataObject; + const error = responseBody.Error as AwsError; + + if (!error) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject); + } + + const specificError = mapErrorToResponse(error.Code, error.Message); + + if (specificError) { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, specificError); + } else { + throw new NodeApiError(this.getNode(), response as unknown as JsonObject, { + message: error.Code, + description: error.Message, + }); + } +} diff --git a/packages/nodes-base/nodes/Aws/IAM/helpers/types.ts b/packages/nodes-base/nodes/Aws/IAM/helpers/types.ts new file mode 100644 index 0000000000..ee9dff6100 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/helpers/types.ts @@ -0,0 +1,76 @@ +export type Group = { + Arn: string; + CreateDate: number; + GroupId: string; + GroupName: string; + Path?: string; +}; + +export type User = { + Arn: string; + CreateDate: number; + PasswordLastUsed?: number; + Path?: string; + PermissionsBoundary?: string; + Tags: Array<{ Key: string; Value: string }>; + UserId: string; + UserName: string; +}; + +export type Tags = { + tags: Array<{ key: string; value: string }>; +}; + +export type GetUserResponseBody = { + GetUserResponse: { + GetUserResult: { + User: User; + }; + }; +}; + +export type GetGroupResponseBody = { + GetGroupResponse: { + GetGroupResult: { + Group: Group; + Users?: User[]; + }; + }; +}; + +export type GetAllUsersResponseBody = { + ListUsersResponse: { + ListUsersResult: { + Users: User[]; + IsTruncated: boolean; + Marker: string; + }; + }; +}; + +export type GetAllGroupsResponseBody = { + ListGroupsResponse: { + ListGroupsResult: { + Groups: Group[]; + IsTruncated: boolean; + Marker: string; + }; + }; +}; + +export type AwsError = { + Code: string; + Message: string; +}; + +export type ErrorResponse = { + Error: { + Code: string; + Message: string; + }; +}; + +export type ErrorMessage = { + message: string; + description: string; +}; diff --git a/packages/nodes-base/nodes/Aws/IAM/helpers/utils.ts b/packages/nodes-base/nodes/Aws/IAM/helpers/utils.ts new file mode 100644 index 0000000000..01a66b515a --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/helpers/utils.ts @@ -0,0 +1,323 @@ +import type { + IHttpRequestOptions, + IDataObject, + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +import { CURRENT_VERSION } from './constants'; +import type { + GetAllGroupsResponseBody, + GetAllUsersResponseBody, + GetGroupResponseBody, + Tags, +} from './types'; +import { searchGroupsForUser } from '../methods/listSearch'; +import { awsApiRequest } from '../transport'; + +export async function encodeBodyAsFormUrlEncoded( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + if (requestOptions.body) { + requestOptions.body = new URLSearchParams( + requestOptions.body as Record, + ).toString(); + } + return requestOptions; +} + +export async function findUsersForGroup( + this: IExecuteSingleFunctions, + groupName: string, +): Promise { + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: new URLSearchParams({ + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: groupName, + }).toString(), + }; + const responseData = (await awsApiRequest.call(this, options)) as GetGroupResponseBody; + return responseData?.GetGroupResponse?.GetGroupResult?.Users ?? []; +} + +export async function simplifyGetGroupsResponse( + this: IExecuteSingleFunctions, + _: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + const includeUsers = this.getNodeParameter('includeUsers', false); + const responseBody = response.body as GetGroupResponseBody; + const groupData = responseBody.GetGroupResponse.GetGroupResult; + const group = groupData.Group; + return [ + { json: includeUsers ? { ...group, Users: groupData.Users ?? [] } : group }, + ] as INodeExecutionData[]; +} + +export async function simplifyGetAllGroupsResponse( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + const includeUsers = this.getNodeParameter('includeUsers', false); + const responseBody = response.body as GetAllGroupsResponseBody; + const groups = responseBody.ListGroupsResponse.ListGroupsResult.Groups ?? []; + + if (groups.length === 0) { + return items; + } + + if (!includeUsers) { + return this.helpers.returnJsonArray(groups); + } + + const processedItems: IDataObject[] = []; + for (const group of groups) { + const users = await findUsersForGroup.call(this, group.GroupName); + processedItems.push({ ...group, Users: users }); + } + return this.helpers.returnJsonArray(processedItems); +} + +export async function simplifyGetAllUsersResponse( + this: IExecuteSingleFunctions, + _items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (!response.body) { + return []; + } + const responseBody = response.body as GetAllUsersResponseBody; + const users = responseBody?.ListUsersResponse?.ListUsersResult?.Users ?? []; + return this.helpers.returnJsonArray(users); +} + +export async function deleteGroupMembers( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const groupName = this.getNodeParameter('group', undefined, { extractValue: true }) as string; + + const users = await findUsersForGroup.call(this, groupName); + if (!users.length) { + return requestOptions; + } + + await Promise.all( + users.map(async (user) => { + const userName = user.UserName as string; + if (!user.UserName) { + return; + } + + try { + await awsApiRequest.call(this, { + method: 'POST', + url: '', + body: { + Action: 'RemoveUserFromGroup', + GroupName: groupName, + UserName: userName, + Version: CURRENT_VERSION, + }, + ignoreHttpStatusErrors: true, + }); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: `Failed to remove user "${userName}" from "${groupName}"!`, + }); + } + }), + ); + + return requestOptions; +} + +export async function validatePath( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const path = this.getNodeParameter('additionalFields.path') as string; + if (path.length < 1 || path.length > 512) { + throw new NodeOperationError( + this.getNode(), + 'The "Path" parameter must be between 1 and 512 characters long.', + ); + } + + const validPathRegex = /^\/[\u0021-\u007E]*\/$/; + if (!validPathRegex.test(path) && path !== '/') { + throw new NodeOperationError( + this.getNode(), + 'Ensure the path is structured correctly, e.g. /division_abc/subdivision_xyz/', + ); + } + + return requestOptions; +} + +export async function validateUserPath( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const prefix = this.getNodeParameter('additionalFields.pathPrefix') as string; + + let formattedPrefix = prefix; + if (!formattedPrefix.startsWith('/')) { + formattedPrefix = '/' + formattedPrefix; + } + if (!formattedPrefix.endsWith('/') && formattedPrefix !== '/') { + formattedPrefix = formattedPrefix + '/'; + } + + if (requestOptions.body && typeof requestOptions.body === 'object') { + Object.assign(requestOptions.body, { PathPrefix: formattedPrefix }); + } + + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: { + Action: 'ListUsers', + Version: CURRENT_VERSION, + }, + }; + const responseData = (await awsApiRequest.call(this, options)) as GetAllUsersResponseBody; + + const users = responseData.ListUsersResponse.ListUsersResult.Users; + if (!users || users.length === 0) { + throw new NodeOperationError( + this.getNode(), + 'No users found. Please adjust the "Path" parameter and try again.', + ); + } + + const userPaths = users.map((user) => user.Path).filter(Boolean); + const isPathValid = userPaths.some((path) => path?.startsWith(formattedPrefix)); + if (!isPathValid) { + throw new NodeOperationError( + this.getNode(), + `The "${formattedPrefix}" path was not found in your users. Try entering a different path.`, + ); + } + return requestOptions; +} + +export async function validateName( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const resource = this.getNodeParameter('resource') as string; + const nameParam = resource === 'user' ? 'userName' : 'groupName'; + const name = this.getNodeParameter(nameParam) as string; + + const maxLength = resource === 'user' ? 64 : 128; + const capitalizedResource = resource.replace(/^./, (c) => c.toUpperCase()); + const validNamePattern = /^[a-zA-Z0-9-_]+$/; + + const isInvalid = !validNamePattern.test(name) || name.length > maxLength; + + if (/\s/.test(name)) { + throw new NodeOperationError( + this.getNode(), + `${capitalizedResource} name should not contain spaces.`, + ); + } + + if (isInvalid) { + throw new NodeOperationError( + this.getNode(), + `${capitalizedResource} name can have up to ${maxLength} characters. Valid characters: letters, numbers, hyphens (-), and underscores (_).`, + ); + } + + return requestOptions; +} + +export async function validatePermissionsBoundary( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const permissionsBoundary = this.getNodeParameter( + 'additionalFields.permissionsBoundary', + ) as string; + + if (permissionsBoundary) { + const arnPattern = /^arn:aws:iam::\d{12}:policy\/[\w\-+\/=._]+$/; + + if (!arnPattern.test(permissionsBoundary)) { + throw new NodeOperationError( + this.getNode(), + 'Permissions boundaries must be provided in ARN format (e.g. arn:aws:iam::123456789012:policy/ExampleBoundaryPolicy). These can be found at the top of the permissions boundary detail page in the IAM dashboard.', + ); + } + + if (requestOptions.body) { + Object.assign(requestOptions.body, { PermissionsBoundary: permissionsBoundary }); + } else { + requestOptions.body = { + PermissionsBoundary: permissionsBoundary, + }; + } + } + return requestOptions; +} + +export async function preprocessTags( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const tagsData = this.getNodeParameter('additionalFields.tags') as Tags; + const tags = tagsData?.tags || []; + + let bodyObj: Record = {}; + if (typeof requestOptions.body === 'string') { + const params = new URLSearchParams(requestOptions.body); + bodyObj = Object.fromEntries(params.entries()); + } + + tags.forEach((tag, index) => { + if (!tag.key || !tag.value) { + throw new NodeOperationError( + this.getNode(), + `Tag at position ${index + 1} is missing '${!tag.key ? 'Key' : 'Value'}'. Both 'Key' and 'Value' are required.`, + ); + } + bodyObj[`Tags.member.${index + 1}.Key`] = tag.key; + bodyObj[`Tags.member.${index + 1}.Value`] = tag.value; + }); + + requestOptions.body = new URLSearchParams(bodyObj).toString(); + + return requestOptions; +} + +export async function removeUserFromGroups( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const userName = this.getNodeParameter('user', undefined, { extractValue: true }); + const userGroups = await searchGroupsForUser.call(this); + + for (const group of userGroups.results) { + await awsApiRequest.call(this, { + method: 'POST', + url: '', + body: { + Action: 'RemoveUserFromGroup', + Version: CURRENT_VERSION, + GroupName: group.value, + UserName: userName, + }, + }); + } + + return requestOptions; +} diff --git a/packages/nodes-base/nodes/Aws/IAM/methods/index.ts b/packages/nodes-base/nodes/Aws/IAM/methods/index.ts new file mode 100644 index 0000000000..c7fb720e47 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Aws/IAM/methods/listSearch.ts b/packages/nodes-base/nodes/Aws/IAM/methods/listSearch.ts new file mode 100644 index 0000000000..e4bb83f5ff --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/methods/listSearch.ts @@ -0,0 +1,159 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { CURRENT_VERSION } from '../helpers/constants'; +import type { + GetAllGroupsResponseBody, + GetAllUsersResponseBody, + GetGroupResponseBody, +} from '../helpers/types'; +import { awsApiRequest } from '../transport'; + +function formatSearchResults( + items: IDataObject[], + propertyName: string, + filter?: string, +): INodeListSearchItems[] { + return items + .map((item) => ({ + name: String(item[propertyName] ?? ''), + value: String(item[propertyName] ?? ''), + })) + .filter(({ name }) => !filter || name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function searchUsers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: { + Action: 'ListUsers', + Version: CURRENT_VERSION, + ...(paginationToken ? { Marker: paginationToken } : {}), + }, + }; + const responseData = (await awsApiRequest.call(this, options)) as GetAllUsersResponseBody; + + const users = responseData.ListUsersResponse.ListUsersResult.Users || []; + const nextMarker = responseData.ListUsersResponse.ListUsersResult.IsTruncated + ? responseData.ListUsersResponse.ListUsersResult.Marker + : undefined; + + return { + results: formatSearchResults(users, 'UserName', filter), + paginationToken: nextMarker, + }; +} + +export async function searchGroups( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: { + Action: 'ListGroups', + Version: CURRENT_VERSION, + ...(paginationToken ? { Marker: paginationToken } : {}), + }, + }; + + const responseData = (await awsApiRequest.call(this, options)) as GetAllGroupsResponseBody; + + const groups = responseData.ListGroupsResponse.ListGroupsResult.Groups || []; + const nextMarker = responseData.ListGroupsResponse.ListGroupsResult.IsTruncated + ? responseData.ListGroupsResponse.ListGroupsResult.Marker + : undefined; + + return { + results: formatSearchResults(groups, 'GroupName', filter), + paginationToken: nextMarker, + }; +} + +export async function searchGroupsForUser( + this: ILoadOptionsFunctions | IExecuteSingleFunctions, + filter?: string, +): Promise { + const userName = this.getNodeParameter('user', undefined, { extractValue: true }); + let allGroups: IDataObject[] = []; + let nextMarkerGroups: string | undefined; + do { + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: { + Action: 'ListGroups', + Version: CURRENT_VERSION, + ...(nextMarkerGroups ? { Marker: nextMarkerGroups } : {}), + }, + }; + + const groupsData = (await awsApiRequest.call(this, options)) as GetAllGroupsResponseBody; + + const groups = groupsData.ListGroupsResponse?.ListGroupsResult?.Groups || []; + nextMarkerGroups = groupsData.ListGroupsResponse?.ListGroupsResult?.IsTruncated + ? groupsData.ListGroupsResponse?.ListGroupsResult?.Marker + : undefined; + + allGroups = [...allGroups, ...groups]; + } while (nextMarkerGroups); + + if (allGroups.length === 0) { + return { results: [] }; + } + + const groupCheckPromises = allGroups.map(async (group) => { + const groupName = group.GroupName as string; + if (!groupName) { + return null; + } + + try { + const options: IHttpRequestOptions = { + method: 'POST', + url: '', + body: { + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: groupName, + }, + }; + + const getGroupResponse = (await awsApiRequest.call(this, options)) as GetGroupResponseBody; + const groupResult = getGroupResponse?.GetGroupResponse?.GetGroupResult; + const userExists = groupResult?.Users?.some((user) => user.UserName === userName); + + if (userExists) { + return { UserName: userName, GroupName: groupName }; + } + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject, { + message: `Failed to get group ${groupName}: ${error?.message ?? 'Unknown error'}`, + }); + } + + return null; + }); + + const validUserGroups = (await Promise.all(groupCheckPromises)).filter(Boolean) as IDataObject[]; + + return { + results: formatSearchResults(validUserGroups, 'GroupName', filter), + }; +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/create.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/group/create.test.ts new file mode 100644 index 0000000000..5137aef4fe --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/create.test.ts @@ -0,0 +1,45 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Create Group', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'CreateGroup', + Version: CURRENT_VERSION, + GroupName: 'NewGroupTest', + }) + .reply(200, { + CreateGroupResponse: { + CreateGroupResult: { + Group: { + GroupName: 'NewGroupTest', + Arn: 'arn:aws:iam::130450532146:group/NewGroupTest', + GroupId: 'AGPAR4X3VE4ZI7H42C2XW', + Path: '/', + CreateDate: 1743409792, + }, + }, + ResponseMetadata: { + RequestId: '50bf4fdb-34b9-4c99-8da8-358d50783c8d', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['create.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/create.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/group/create.workflow.json new file mode 100644 index 0000000000..7f15f0887e --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/create.workflow.json @@ -0,0 +1,78 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -120], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "group", + "operation": "create", + "groupName": "NewGroupTest", + "additionalFields": {}, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [160, -120], + "id": "23fd4c79-516d-49dc-81c8-e68052a294d1", + "name": "createGroup", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "createGroup": [ + { + "json": { + "CreateGroupResponse": { + "CreateGroupResult": { + "Group": { + "Arn": "arn:aws:iam::130450532146:group/NewGroupTest", + "CreateDate": 1743409792, + "GroupId": "AGPAR4X3VE4ZI7H42C2XW", + "GroupName": "NewGroupTest", + "Path": "/" + } + }, + "ResponseMetadata": { + "RequestId": "50bf4fdb-34b9-4c99-8da8-358d50783c8d" + } + } + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "createGroup", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "da88fb89-6234-4eb3-b6fb-31a7a299c7ec", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/delete.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/group/delete.test.ts new file mode 100644 index 0000000000..cd2a097ab8 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/delete.test.ts @@ -0,0 +1,61 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Delete Group', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupForTest1', + }) + .reply(200, { + GetGroupResponse: { + GetGroupResult: { + Users: [{ UserName: 'User1' }], + }, + }, + }); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'RemoveUserFromGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupForTest1', + UserName: 'User1', + }) + .reply(200, {}); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'DeleteGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupForTest1', + }) + .reply(200, { + DeleteGroupResponse: { + ResponseMetadata: { + RequestId: 'b9cc2642-db2c-4935-aaaf-eacf10e4f00a', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['delete.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/delete.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/group/delete.workflow.json new file mode 100644 index 0000000000..e828136fee --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/delete.workflow.json @@ -0,0 +1,62 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [200, 120], + "id": "b4205abf-7102-4e53-8aed-7bd047acfaf4", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "group", + "operation": "delete", + "group": { + "__rl": true, + "value": "GroupForTest1", + "mode": "list", + "cachedResultName": "GroupForTest1" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [380, 120], + "id": "bba99f6d-ed9c-4603-95f0-4ecce6e6f976", + "name": "AWS IAM13", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "AWS IAM13", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "AWS IAM13": [ + { + "json": { + "DeleteGroupResponse": { + "ResponseMetadata": { + "RequestId": "b9cc2642-db2c-4935-aaaf-eacf10e4f00a" + } + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/get.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/group/get.test.ts new file mode 100644 index 0000000000..bc63cc8324 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/get.test.ts @@ -0,0 +1,54 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Get Group', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupNameUpdated2', + }) + .reply(200, { + GetGroupResponse: { + GetGroupResult: { + Group: { + Arn: 'arn:aws:iam::130450532146:group/New/Path/GroupNameUpdated2', + CreateDate: 1739193696, + GroupId: 'AGPAR4X3VE4ZKHNKBQHBZ', + GroupName: 'GroupNameUpdated2', + Path: '/New/Path/', + }, + Users: [ + { + Arn: 'arn:aws:iam::130450532146:user/rhis/path/Jonas', + CreateDate: 1739198295, + PasswordLastUsed: null, + PermissionsBoundary: null, + Tags: null, + Path: '/rhis/path/', + UserId: 'AIDAR4X3VE4ZDJJFKI6OU', + UserName: 'Jonas', + }, + ], + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['get.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/get.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/group/get.workflow.json new file mode 100644 index 0000000000..a6d9020ef3 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/get.workflow.json @@ -0,0 +1,86 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-80, -100], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "group", + "operation": "get", + "group": { + "__rl": true, + "value": "GroupNameUpdated2", + "mode": "list", + "cachedResultName": "GroupNameUpdated2" + }, + "includeUsers": true, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [160, -100], + "id": "7990c8f3-738e-4150-855e-a9c0a965b75d", + "name": "getGroup", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "getGroup": [ + { + "json": { + "Arn": "arn:aws:iam::130450532146:group/New/Path/GroupNameUpdated2", + "CreateDate": 1739193696, + "GroupId": "AGPAR4X3VE4ZKHNKBQHBZ", + "GroupName": "GroupNameUpdated2", + "Path": "/New/Path/", + "Users": [ + { + "Arn": "arn:aws:iam::130450532146:user/rhis/path/Jonas", + "CreateDate": 1739198295, + "PasswordLastUsed": null, + "Path": "/rhis/path/", + "PermissionsBoundary": null, + "Tags": null, + "UserId": "AIDAR4X3VE4ZDJJFKI6OU", + "UserName": "Jonas" + } + ] + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getGroup", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "47e9f15a-50b7-4f12-ac5b-c7b24bb9a400", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.test.ts new file mode 100644 index 0000000000..45728e2cd1 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.test.ts @@ -0,0 +1,58 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Get All Groups', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'ListGroups', + Version: CURRENT_VERSION, + MaxItems: 100, + }) + .reply(200, { + ListGroupsResponse: { + ListGroupsResult: { + Groups: [ + { + Arn: 'arn:aws:iam::130450532146:group/test/Admin7', + CreateDate: 1733436631, + GroupId: 'AGPAR4X3VE4ZAFFY5EDUJ', + GroupName: 'Admin7', + Path: '/test/', + }, + { + Arn: 'arn:aws:iam::130450532146:group/cognito', + CreateDate: 1730804196, + GroupId: 'AGPAR4X3VE4ZMVEFLBSRB', + GroupName: 'cognito', + Path: '/', + }, + { + Arn: 'arn:aws:iam::130450532146:group/GroupCreatedAfter', + CreateDate: 1741589366, + GroupId: 'AGPAR4X3VE4ZF5VE6UF2U', + GroupName: 'GroupCreatedAfter', + Path: '/', + }, + ], + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['getAll.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.workflow.json new file mode 100644 index 0000000000..2dd72f5be8 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/getAll.workflow.json @@ -0,0 +1,85 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -220], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "group", + "includeUsers": false, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [160, -220], + "id": "23fd4c79-516d-49dc-81c8-e68052a294d1", + "name": "getAllGroups", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "getAllGroups": [ + { + "json": { + "Arn": "arn:aws:iam::130450532146:group/test/Admin7", + "CreateDate": 1733436631, + "GroupId": "AGPAR4X3VE4ZAFFY5EDUJ", + "GroupName": "Admin7", + "Path": "/test/" + } + }, + { + "json": { + "Arn": "arn:aws:iam::130450532146:group/cognito", + "CreateDate": 1730804196, + "GroupId": "AGPAR4X3VE4ZMVEFLBSRB", + "GroupName": "cognito", + "Path": "/" + } + }, + { + "json": { + "Arn": "arn:aws:iam::130450532146:group/GroupCreatedAfter", + "CreateDate": 1741589366, + "GroupId": "AGPAR4X3VE4ZF5VE6UF2U", + "GroupName": "GroupCreatedAfter", + "Path": "/" + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getAllGroups", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "51beb4c0-4163-4c57-a715-6eb45f1ffb9b", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/update.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/group/update.test.ts new file mode 100644 index 0000000000..274d2f88e2 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/update.test.ts @@ -0,0 +1,36 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Update Group', () => { + beforeEach(() => { + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'UpdateGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupNameUpdated', + NewGroupName: 'GroupNameUpdated2', + }) + .reply(200, { + UpdateGroupResponse: { + ResponseMetadata: { + RequestId: '16ada465-a981-44ab-841f-3ca3247f7405', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['update.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/group/update.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/group/update.workflow.json new file mode 100644 index 0000000000..2e3d5deea0 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/group/update.workflow.json @@ -0,0 +1,74 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -120], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "group", + "operation": "update", + "group": { + "__rl": true, + "value": "GroupNameUpdated", + "mode": "list", + "cachedResultName": "GroupNameUpdated" + }, + "groupName": "GroupNameUpdated2", + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [140, -120], + "id": "6299cf80-7aea-4cf6-8604-368a42290da7", + "name": "updateGroup", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "updateGroup": [ + { + "json": { + "UpdateGroupResponse": { + "ResponseMetadata": { + "RequestId": "16ada465-a981-44ab-841f-3ca3247f7405" + } + } + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "updateGroup", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "bce6ece0-c45a-4389-9e39-6edea3d2e0da", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/helpers/errorHandler.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/helpers/errorHandler.test.ts new file mode 100644 index 0000000000..624a95607d --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/helpers/errorHandler.test.ts @@ -0,0 +1,130 @@ +import { NodeApiError } from 'n8n-workflow'; +import type { INodeExecutionData, IN8nHttpFullResponse, JsonObject } from 'n8n-workflow'; + +import { ERROR_DESCRIPTIONS } from '../../helpers/constants'; +import { handleError } from '../../helpers/errorHandler'; + +const mockExecuteSingleFunctions = { + getNode: jest.fn(() => ({ name: 'MockNode' })), + getNodeParameter: jest.fn(), +} as any; + +describe('handleError', () => { + let response: IN8nHttpFullResponse; + let data: INodeExecutionData[]; + + beforeEach(() => { + data = [{}] as INodeExecutionData[]; + response = { statusCode: 200, body: {} } as IN8nHttpFullResponse; + }); + + test('should return data when no error occurs', async () => { + const result = await handleError.call(mockExecuteSingleFunctions, data, response); + expect(result).toBe(data); + }); + + test('should throw NodeApiError for EntityAlreadyExists with user conflict', async () => { + mockExecuteSingleFunctions.getNodeParameter = jest + .fn() + .mockReturnValueOnce('user') + .mockReturnValueOnce('existingUserName'); + + response.statusCode = 400; + response.body = { + Error: { Code: 'EntityAlreadyExists', Message: 'User "existingUserName" already exists' }, + } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'User "existingUserName" already exists', + description: ERROR_DESCRIPTIONS.EntityAlreadyExists.User, + }), + ); + }); + + test('should throw NodeApiError for NoSuchEntity with user not found', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('user') + .mockReturnValueOnce('nonExistentUser'); + + response.statusCode = 404; + response.body = { + Error: { Code: 'NoSuchEntity', Message: 'User "nonExistentUser" does not exist' }, + } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrowError( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'User "nonExistentUser" does not exist', + description: ERROR_DESCRIPTIONS.NoSuchEntity.User, + }), + ); + }); + + test('should throw generic error if no specific mapping exists', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('container'); + + response.statusCode = 400; + response.body = { Error: { Code: 'BadRequest', Message: 'Invalid request' } } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'BadRequest', + description: 'Invalid request', + }), + ); + }); + + test('should throw NodeApiError for EntityAlreadyExists with group conflict', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('group') + .mockReturnValue('existingGroupName'); + + response.statusCode = 400; + response.body = { + Error: { Code: 'EntityAlreadyExists', Message: 'Group "existingGroupName" already exists' }, + } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'Group "existingGroupName" already exists', + description: ERROR_DESCRIPTIONS.EntityAlreadyExists.Group, + }), + ); + }); + + test('should throw NodeApiError for NoSuchEntity with group not found', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('group') + .mockReturnValue('nonExistentGroup'); + + response.statusCode = 404; + response.body = { + Error: { Code: 'NoSuchEntity', Message: 'Group "nonExistentGroup" does not exist' }, + } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'Group "nonExistentGroup" does not exist', + description: ERROR_DESCRIPTIONS.NoSuchEntity.Group, + }), + ); + }); + + test('should throw NodeApiError for DeleteConflict', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('user') + .mockReturnValue('userInGroup'); + + response.statusCode = 400; + response.body = { + Error: { Code: 'DeleteConflict', Message: 'User "userIngroup" is in a group' }, + } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'User "userIngroup" is in a group', + description: 'This entity is still in use. Remove users from the group before deleting.', + }), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/helpers/utils.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/helpers/utils.test.ts new file mode 100644 index 0000000000..a413a034e5 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/helpers/utils.test.ts @@ -0,0 +1,421 @@ +import type { IHttpRequestOptions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { + preprocessTags, + deleteGroupMembers, + validatePath, + validateUserPath, + removeUserFromGroups, + findUsersForGroup, + simplifyGetGroupsResponse, + simplifyGetAllGroupsResponse, + simplifyGetAllUsersResponse, + validateName, + validatePermissionsBoundary, + encodeBodyAsFormUrlEncoded, +} from '../../helpers/utils'; +import { awsApiRequest } from '../../transport'; + +jest.mock('../../transport', () => ({ + awsApiRequest: jest.fn(), +})); + +describe('AWS IAM - Helper Functions', () => { + let mockNode: any; + + beforeEach(() => { + mockNode = { + getNodeParameter: jest.fn(), + getNode: jest.fn(), + helpers: { + returnJsonArray: jest.fn((input: unknown[]) => input.map((i) => ({ json: i }))), + }, + }; + }); + + describe('encodeBodyAsFormUrlEncoded', () => { + it('should encode the body as application/x-www-form-urlencoded', async () => { + const requestOptions: IHttpRequestOptions = { + body: { + client_id: 'myClient', + client_secret: 'mySecret', + grant_type: 'client_credentials', + }, + url: '', + headers: {}, + }; + + const result = await encodeBodyAsFormUrlEncoded.call(mockNode, requestOptions); + + expect(result.body).toBe( + 'client_id=myClient&client_secret=mySecret&grant_type=client_credentials', + ); + }); + + it('should return unchanged options if no body is present', async () => { + const requestOptions: IHttpRequestOptions = { url: '', headers: {} }; + const result = await encodeBodyAsFormUrlEncoded.call(mockNode, requestOptions); + + expect(result).toEqual({ url: '', headers: {} }); + }); + }); + + describe('findUsersForGroup', () => { + it('should return users for a valid groupName', async () => { + mockNode.getNodeParameter.mockReturnValue('groupName'); + const mockResponse = { + GetGroupResponse: { GetGroupResult: { Users: [{ UserName: 'user1' }] } }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(mockResponse); + + const result = await findUsersForGroup.call(mockNode, 'groupName'); + expect(result).toEqual([{ UserName: 'user1' }]); + }); + }); + + describe('preprocessTags', () => { + it('should preprocess tags correctly into request body', async () => { + mockNode.getNodeParameter.mockReturnValue({ + tags: [ + { key: 'Department', value: 'Engineering' }, + { key: 'Role', value: 'Developer' }, + ], + }); + + const requestOptions = { body: '', headers: {}, url: '' }; + const result = await preprocessTags.call(mockNode, requestOptions); + + expect(result.body).toBe( + 'Tags.member.1.Key=Department&Tags.member.1.Value=Engineering&Tags.member.2.Key=Role&Tags.member.2.Value=Developer', + ); + }); + + it('should throw error if a tag is missing a key', async () => { + mockNode.getNodeParameter.mockReturnValue({ + tags: [{ key: '', value: 'Engineering' }], + }); + + const requestOptions = { body: '', headers: {}, url: '' }; + + await expect(preprocessTags.call(mockNode, requestOptions)).rejects.toThrow( + NodeOperationError, + ); + + await expect(preprocessTags.call(mockNode, requestOptions)).rejects.toThrow( + "Tag at position 1 is missing 'Key'. Both 'Key' and 'Value' are required.", + ); + }); + + it('should throw error if a tag is missing a value', async () => { + mockNode.getNodeParameter.mockReturnValue({ + tags: [{ key: 'Department', value: '' }], + }); + + const requestOptions = { body: '', headers: {}, url: '' }; + + await expect(preprocessTags.call(mockNode, requestOptions)).rejects.toThrow( + NodeOperationError, + ); + + await expect(preprocessTags.call(mockNode, requestOptions)).rejects.toThrow( + "Tag at position 1 is missing 'Value'. Both 'Key' and 'Value' are required.", + ); + }); + }); + + describe('deleteGroupMembers', () => { + it('should attempt to remove users from a group', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'group') { + return 'groupName'; + } + return null; + }); + + const mockUsers = [{ UserName: 'user1' }]; + (awsApiRequest as jest.Mock).mockResolvedValue(mockUsers); + + const requestOptions = { headers: {}, url: '' }; + const result = await deleteGroupMembers.call(mockNode, requestOptions); + + expect(result).toEqual(requestOptions); + + expect(awsApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('GroupName=groupName'), + }), + ); + }); + }); + + describe('validatePath', () => { + it('should throw an error for invalid path length', async () => { + mockNode.getNodeParameter.mockReturnValue(''); + await expect(validatePath.call(mockNode, { headers: {}, url: '' })).rejects.toThrowError( + NodeOperationError, + ); + }); + + it('should throw an error for invalid path format', async () => { + mockNode.getNodeParameter.mockReturnValue('/invalidPath'); + await expect(validatePath.call(mockNode, { url: '' })).rejects.toThrowError( + NodeOperationError, + ); + }); + + it('should pass for a valid path', async () => { + mockNode.getNodeParameter.mockReturnValue('/valid/path/'); + const result = await validatePath.call(mockNode, { headers: {}, url: '' }); + expect(result).toEqual({ headers: {}, url: '' }); + }); + }); + + describe('validateUserPath', () => { + it('should throw an error for invalid path prefix', async () => { + mockNode.getNodeParameter.mockReturnValue('/invalidPrefix'); + + const mockResponse = { + ListUsersResponse: { + ListUsersResult: { + Users: [{ UserName: 'user1', Path: '/validPrefix/user1' }], + }, + }, + }; + + (awsApiRequest as jest.Mock).mockResolvedValue(mockResponse); + + await expect(validateUserPath.call(mockNode, { headers: {}, url: '' })).rejects.toThrowError( + NodeOperationError, + ); + }); + + it('should modify the request body with a valid path', async () => { + mockNode.getNodeParameter.mockReturnValue('/validPrefix/'); + const requestOptions = { body: {}, headers: {}, url: '' }; + + const mockResponse = { + ListUsersResponse: { + ListUsersResult: { + Users: [{ UserName: 'user1', Path: '/validPrefix/user1' }], + }, + }, + }; + + (awsApiRequest as jest.Mock).mockResolvedValue(mockResponse); + + const result = await validateUserPath.call(mockNode, requestOptions); + expect(result.body).toHaveProperty('PathPrefix', '/validPrefix/'); + }); + }); + + describe('validateName', () => { + const requestOptions: IHttpRequestOptions = { body: {}, url: '' }; + + it('should throw an error if userName contains spaces', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'user'; + if (param === 'userName') return 'John Doe'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).rejects.toThrowError( + new NodeOperationError(mockNode.getNode(), 'User name should not contain spaces.'), + ); + }); + + it('should throw an error if userName contains invalid characters', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'user'; + if (param === 'userName') return 'John@Doe'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).rejects.toThrowError( + new NodeOperationError( + mockNode.getNode(), + 'User name can have up to 64 characters. Valid characters: letters, numbers, hyphens (-), and underscores (_).', + ), + ); + }); + + it('should pass validation for valid userName', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'user'; + if (param === 'userName') return 'John_Doe123'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).resolves.toEqual(requestOptions); + }); + + it('should throw an error if groupName contains spaces', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'group'; + if (param === 'groupName') return 'Group Name'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).rejects.toThrowError( + new NodeOperationError(mockNode.getNode(), 'Group name should not contain spaces.'), + ); + }); + + it('should throw an error if groupName contains invalid characters', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'group'; + if (param === 'groupName') return 'Group@Name'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).rejects.toThrowError( + new NodeOperationError( + mockNode.getNode(), + 'Group name can have up to 128 characters. Valid characters: letters, numbers, hyphens (-), and underscores (_).', + ), + ); + }); + + it('should pass validation for valid groupName', async () => { + mockNode.getNodeParameter.mockImplementation((param: string) => { + if (param === 'resource') return 'group'; + if (param === 'groupName') return 'Group_Name-123'; + return ''; + }); + + await expect(validateName.call(mockNode, requestOptions)).resolves.toEqual(requestOptions); + }); + }); + + describe('validatePermissionsBoundary', () => { + const requestOptions: IHttpRequestOptions = { body: {}, url: '' }; + + it('should return the request options unchanged if no permissions boundary is set', async () => { + mockNode.getNodeParameter.mockReturnValue(undefined); + + const result = await validatePermissionsBoundary.call(mockNode, requestOptions); + + expect(result).toEqual(requestOptions); + }); + + it('should add a valid permissions boundary to the request body', async () => { + const validArn = 'arn:aws:iam::123456789012:policy/ExamplePolicy'; + mockNode.getNodeParameter.mockReturnValue(validArn); + + const result = await validatePermissionsBoundary.call(mockNode, requestOptions); + + expect(result.body).toEqual({ PermissionsBoundary: validArn }); + }); + + it('should throw an error for invalid permissions boundary format', async () => { + const invalidArn = 'invalid:arn:format'; + mockNode.getNodeParameter.mockReturnValue(invalidArn); + + await expect( + validatePermissionsBoundary.call(mockNode, { body: {}, url: '', headers: {} }), + ).rejects.toThrow(NodeOperationError); + }); + }); + + describe('simplifyGetGroupsResponse', () => { + it('should return group data', async () => { + mockNode.getNodeParameter.mockReturnValue(false); + const mockResponse = { + body: { GetGroupResponse: { GetGroupResult: { Group: { GroupName: 'TestGroup' } } } }, + headers: {}, + url: '', + statusCode: 200, + }; + + const result = await simplifyGetGroupsResponse.call(mockNode, [], mockResponse); + + expect(result).toEqual([{ json: { GroupName: 'TestGroup' } }]); + }); + + it('should include users if "includeUsers" is true', async () => { + mockNode.getNodeParameter.mockReturnValue(true); + const mockResponse = { + body: { + GetGroupResponse: { + GetGroupResult: { Group: { GroupName: 'TestGroup' }, Users: [{ UserName: 'user1' }] }, + }, + }, + headers: {}, + url: '', + statusCode: 200, + }; + + const result = await simplifyGetGroupsResponse.call(mockNode, [], mockResponse); + + expect(result).toEqual([ + { json: { GroupName: 'TestGroup', Users: [{ UserName: 'user1' }] } }, + ]); + }); + }); + + describe('simplifyGetAllGroupsResponse', () => { + it('should return groups without users if "includeUsers" is false', async () => { + mockNode.getNodeParameter.mockReturnValue(false); + const mockResponse = { + body: { + ListGroupsResponse: { ListGroupsResult: { Groups: [{ GroupName: 'TestGroup' }] } }, + }, + headers: {}, + url: '', + statusCode: 200, + }; + + const result = await simplifyGetAllGroupsResponse.call(mockNode, [], mockResponse); + + expect(result).toEqual([{ json: { GroupName: 'TestGroup' } }]); + }); + + it('should return groups with users if "includeUsers" is true', async () => { + mockNode.getNodeParameter.mockReturnValue(true); + const mockResponse = { + body: { + ListGroupsResponse: { ListGroupsResult: { Groups: [{ GroupName: 'TestGroup' }] } }, + }, + headers: {}, + url: '', + statusCode: 200, + }; + + const mockUsers = [{ UserName: 'user1' }]; + (awsApiRequest as jest.Mock).mockResolvedValueOnce({ + GetGroupResponse: { GetGroupResult: { Users: mockUsers } }, + }); + + const result = await simplifyGetAllGroupsResponse.call(mockNode, [], mockResponse); + + expect(result).toEqual([{ json: { GroupName: 'TestGroup', Users: mockUsers } }]); + }); + }); + + describe('simplifyGetAllUsersResponse', () => { + it('should return all users', async () => { + const mockResponse = { + body: { ListUsersResponse: { ListUsersResult: { Users: [{ UserName: 'user1' }] } } }, + headers: {}, + url: '', + statusCode: 200, + }; + const result = await simplifyGetAllUsersResponse.call(mockNode, [], mockResponse); + expect(result).toEqual([{ json: { UserName: 'user1' } }]); + }); + }); + + describe('removeUserFromGroups', () => { + it('should remove a user from all groups', async () => { + mockNode.getNodeParameter.mockReturnValue('user1'); + const mockUserGroups = { results: [{ value: 'group1' }, { value: 'group2' }] }; + (awsApiRequest as jest.Mock).mockResolvedValue(mockUserGroups); + + const requestOptions = { headers: {}, url: '' }; + const result = await removeUserFromGroups.call(mockNode, requestOptions); + + expect(result).toEqual(requestOptions); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/listSearch/listSearch.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/listSearch/listSearch.test.ts new file mode 100644 index 0000000000..03190a5fd2 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/listSearch/listSearch.test.ts @@ -0,0 +1,119 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchUsers, searchGroups, searchGroupsForUser } from '../../methods/listSearch'; +import { awsApiRequest } from '../../transport'; + +jest.mock('../../transport', () => ({ + awsApiRequest: jest.fn(), +})); + +describe('AWS IAM - List search', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const mockContext = { + helpers: { + requestWithAuthentication: jest.fn(), + }, + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + describe('searchUsers', () => { + it('should return an empty result if no users are found', async () => { + const responseData = { ListUsersResponse: { ListUsersResult: { Users: [] } } }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchUsers.call(mockContext); + expect(result.results).toEqual([]); + }); + + it('should return formatted user results when users are found', async () => { + const responseData = { + ListUsersResponse: { + ListUsersResult: { + Users: [{ UserName: 'User1' }, { UserName: 'User2' }], + }, + }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchUsers.call(mockContext); + expect(result.results).toEqual([ + { name: 'User1', value: 'User1' }, + { name: 'User2', value: 'User2' }, + ]); + }); + + it('should apply filter to the user results', async () => { + const responseData = { + ListUsersResponse: { + ListUsersResult: { + Users: [{ UserName: 'User1' }, { UserName: 'User2' }], + }, + }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchUsers.call(mockContext, 'User1'); + expect(result.results).toEqual([{ name: 'User1', value: 'User1' }]); + }); + }); + + describe('searchGroups', () => { + it('should return an empty result if no groups are found', async () => { + const responseData = { ListGroupsResponse: { ListGroupsResult: { Groups: [] } } }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchGroups.call(mockContext); + expect(result.results).toEqual([]); + }); + + it('should return formatted group results when groups are found', async () => { + const responseData = { + ListGroupsResponse: { + ListGroupsResult: { + Groups: [{ GroupName: 'Group1' }, { GroupName: 'Group2' }], + }, + }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchGroups.call(mockContext); + expect(result.results).toEqual([ + { name: 'Group1', value: 'Group1' }, + { name: 'Group2', value: 'Group2' }, + ]); + }); + + it('should apply filter to the group results', async () => { + const responseData = { + ListGroupsResponse: { + ListGroupsResult: { + Groups: [{ GroupName: 'Group1' }, { GroupName: 'Group2' }], + }, + }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchGroups.call(mockContext, 'Group1'); + expect(result.results).toEqual([{ name: 'Group1', value: 'Group1' }]); + }); + }); + + describe('searchGroupsForUser', () => { + it('should return empty if no user groups are found', async () => { + mockContext.getNodeParameter = jest.fn().mockReturnValue('user1'); + + const responseData = { + ListGroupsResponse: { ListGroupsResult: { Groups: [] } }, + }; + (awsApiRequest as jest.Mock).mockResolvedValue(responseData); + + const result = await searchGroupsForUser.call(mockContext); + + expect(result.results).toEqual([]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.test.ts new file mode 100644 index 0000000000..18af309772 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.test.ts @@ -0,0 +1,38 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Add User to Group', () => { + beforeEach(() => { + const baseUrl = 'https://iam.amazonaws.com/'; + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'AddUserToGroup', + Version: CURRENT_VERSION, + UserName: 'Jonas', + GroupName: 'GroupNameUpdated2', + }) + .reply(200, { + AddUserToGroupResponse: { + ResponseMetadata: { + RequestId: '8192250c-9225-4903-af62-a521ce939968', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['addToGroup.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.workflow.json new file mode 100644 index 0000000000..a0955f4e3f --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/addToGroup.workflow.json @@ -0,0 +1,78 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -120], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "addToGroup", + "user": { + "__rl": true, + "value": "Jonas", + "mode": "list", + "cachedResultName": "Jonas" + }, + "group": { + "__rl": true, + "value": "GroupNameUpdated2", + "mode": "list", + "cachedResultName": "GroupNameUpdated2" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [160, -120], + "id": "982d8c6e-e94a-414c-90ba-35743676eef8", + "name": "addToGroup", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "addToGroup": [ + { + "json": { + "AddUserToGroupResponse": { + "ResponseMetadata": { + "RequestId": "8192250c-9225-4903-af62-a521ce939968" + } + } + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "addToGroup", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "2e9e5c7a-705f-49a7-83db-397f1bb48798", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/create.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/create.test.ts new file mode 100644 index 0000000000..333d176002 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/create.test.ts @@ -0,0 +1,47 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Create user', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'CreateUser', + Version: CURRENT_VERSION, + UserName: 'UserTest1', + }) + .reply(200, { + CreateUserResponse: { + CreateUserResult: { + User: { + Arn: 'arn:aws:iam::130450532146:user/UserTest1', + CreateDate: 1744115235, + PasswordLastUsed: null, + Path: '/', + PermissionsBoundary: null, + UserId: 'AIDAR4X3VE4ZHHMNF7NBB', + UserName: 'UserTest1', + }, + }, + ResponseMetadata: { + RequestId: 'ce14481c-5629-4ae4-9eae-3722f48bb3e0', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['create.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/create.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/create.workflow.json new file mode 100644 index 0000000000..fbb7f5ece0 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/create.workflow.json @@ -0,0 +1,70 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [200, 120], + "id": "b4205abf-7102-4e53-8aed-7bd047acfaf4", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "create", + "userName": "UserTest1", + "additionalFields": { + "path": "/new/path/" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [400, 120], + "id": "64bbfce7-cc7d-4e69-82d3-d4ecac5ad389", + "name": "AWS IAM12", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "AWS IAM12", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "AWS IAM12": [ + { + "json": { + "CreateUserResponse": { + "CreateUserResult": { + "User": { + "Arn": "arn:aws:iam::130450532146:user/UserTest1", + "CreateDate": 1744115235, + "PasswordLastUsed": null, + "Path": "/", + "PermissionsBoundary": null, + "UserId": "AIDAR4X3VE4ZHHMNF7NBB", + "UserName": "UserTest1" + } + }, + "ResponseMetadata": { + "RequestId": "ce14481c-5629-4ae4-9eae-3722f48bb3e0" + } + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/delete.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/delete.test.ts new file mode 100644 index 0000000000..963344150e --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/delete.test.ts @@ -0,0 +1,96 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Delete user', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'GetUser', + Version: CURRENT_VERSION, + UserName: 'JohnThis10', + }) + .reply(200, { + GetUserResponse: { + GetUserResult: { + User: { + UserName: 'JohnThis10', + }, + }, + }, + }); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'ListGroups', + Version: CURRENT_VERSION, + }) + .reply(200, { + ListGroupsResponse: { + ListGroupsResult: { + Groups: [{ GroupName: 'GroupA' }], + }, + }, + }); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'GetGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupA', + }) + .reply(200, { + GetGroupResponse: { + GetGroupResult: { + Users: [{ UserName: 'JohnThis10' }], + }, + }, + }); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'RemoveUserFromGroup', + Version: CURRENT_VERSION, + GroupName: 'GroupA', + UserName: 'JohnThis10', + }) + .reply(200, { + ResponseMetadata: { + RequestId: 'remove-groupA-id', + }, + }); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'DeleteUser', + Version: CURRENT_VERSION, + UserName: 'JohnThis10', + }) + .reply(200, { + DeleteUserResponse: { + ResponseMetadata: { + RequestId: '44c7c6c0-260b-4dfd-beee-2cce8f05bed3', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['delete.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/delete.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/delete.workflow.json new file mode 100644 index 0000000000..a1a66d9d84 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/delete.workflow.json @@ -0,0 +1,72 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-80, -100], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "delete", + "user": { + "__rl": true, + "value": "JohnThis10", + "mode": "list", + "cachedResultName": "JohnThis10" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [180, -100], + "id": "f8bf0d9d-78aa-48e6-80bd-3018c0bac0ea", + "name": "deleteUser", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "deleteUser": [ + { + "json": { + "DeleteUserResponse": { + "ResponseMetadata": { + "RequestId": "44c7c6c0-260b-4dfd-beee-2cce8f05bed3" + } + } + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "deleteUser", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "731b81df-c6a0-4074-a3f8-e0ad0e9c884a", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/get.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/get.test.ts new file mode 100644 index 0000000000..338e0327f1 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/get.test.ts @@ -0,0 +1,49 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Get User', () => { + beforeEach(() => { + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'GetUser', + Version: CURRENT_VERSION, + UserName: 'accounts@this.de', + }) + .reply(200, { + GetUserResponse: { + GetUserResult: { + User: { + UserName: 'accounts@this.de', + UserId: 'AIDAR4X3VE4ZANWXRN2L2', + Arn: 'arn:aws:iam::130450532146:user/accounts@this.de', + CreateDate: 1733911052, + Path: '/', + Tags: [ + { + Key: 'AKIAR4X3VE4ZALQYFEMT', + Value: 'API dev', + }, + ], + PasswordLastUsed: null, + PermissionsBoundary: null, + }, + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['get.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/get.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/get.workflow.json new file mode 100644 index 0000000000..774885c2c1 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/get.workflow.json @@ -0,0 +1,79 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -120], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "get", + "user": { + "__rl": true, + "mode": "list", + "value": "accounts@this.de" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [140, -100], + "id": "982d8c6e-e94a-414c-90ba-35743676eef8", + "name": "getUser", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "getUser": [ + { + "json": { + "Arn": "arn:aws:iam::130450532146:user/accounts@this.de", + "CreateDate": 1733911052, + "PasswordLastUsed": null, + "Path": "/", + "PermissionsBoundary": null, + "Tags": [ + { + "Key": "AKIAR4X3VE4ZALQYFEMT", + "Value": "API dev" + } + ], + "UserId": "AIDAR4X3VE4ZANWXRN2L2", + "UserName": "accounts@this.de" + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getUser", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "5ce54c9d-de6e-499a-b042-0bb89c41208c", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.test.ts new file mode 100644 index 0000000000..95dcd7937c --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.test.ts @@ -0,0 +1,57 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Get All Users', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'ListUsers', + Version: CURRENT_VERSION, + MaxItems: 100, + }) + .reply(200, { + ListUsersResponse: { + ListUsersResult: { + Users: [ + { + Arn: 'arn:aws:iam::130450532146:user/Johnnn', + UserName: 'Johnnn', + UserId: 'AIDAR4X3VE4ZAJGXLCVOP', + Path: '/', + CreateDate: 1739198010, + PasswordLastUsed: null, + PermissionsBoundary: null, + Tags: null, + }, + { + Arn: 'arn:aws:iam::130450532146:user/rhis/path/Jonas', + UserName: 'Jonas', + UserId: 'AIDAR4X3VE4ZDJJFKI6OU', + Path: '/rhis/path/', + CreateDate: 1739198295, + PasswordLastUsed: null, + PermissionsBoundary: null, + Tags: null, + }, + ], + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['getAll.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.workflow.json new file mode 100644 index 0000000000..a5474e4a6c --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/getAll.workflow.json @@ -0,0 +1,81 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -220], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "additionalFields": {}, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [160, -220], + "id": "23fd4c79-516d-49dc-81c8-e68052a294d1", + "name": "getAllUsers", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "getAllUsers": [ + { + "json": { + "Arn": "arn:aws:iam::130450532146:user/Johnnn", + "CreateDate": 1739198010, + "PasswordLastUsed": null, + "Path": "/", + "PermissionsBoundary": null, + "Tags": null, + "UserId": "AIDAR4X3VE4ZAJGXLCVOP", + "UserName": "Johnnn" + } + }, + { + "json": { + "Arn": "arn:aws:iam::130450532146:user/rhis/path/Jonas", + "CreateDate": 1739198295, + "PasswordLastUsed": null, + "Path": "/rhis/path/", + "PermissionsBoundary": null, + "Tags": null, + "UserId": "AIDAR4X3VE4ZDJJFKI6OU", + "UserName": "Jonas" + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getAllUsers", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "c23e7348-6add-4711-b567-53922f951cbe", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.test.ts new file mode 100644 index 0000000000..8c9ec02e17 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.test.ts @@ -0,0 +1,37 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Remove User From Group', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'RemoveUserFromGroup', + Version: CURRENT_VERSION, + UserName: 'UserTest1', + GroupName: 'GroupCreatedAfter', + }) + .reply(200, { + RemoveUserFromGroupResponse: { + ResponseMetadata: { + RequestId: '48508b51-1506-496c-8455-7135269209f0', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['removeFromGroup.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.workflow.json new file mode 100644 index 0000000000..4ee3d12299 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/removeFromGroup.workflow.json @@ -0,0 +1,78 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-40, -120], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "removeFromGroup", + "user": { + "__rl": true, + "value": "UserTest1", + "mode": "list", + "cachedResultName": "UserTest1" + }, + "group": { + "__rl": true, + "value": "GroupCreatedAfter", + "mode": "list", + "cachedResultName": "GroupCreatedAfter" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [140, -120], + "id": "5f05f1ad-aaae-49c1-b03d-c77c1442a0c9", + "name": "removeFromGroup", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "pinData": { + "removeFromGroup": [ + { + "json": { + "RemoveUserFromGroupResponse": { + "ResponseMetadata": { + "RequestId": "48508b51-1506-496c-8455-7135269209f0" + } + } + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "removeFromGroup", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "9cb635bc-9e0f-4903-8705-c0ec4495c39f", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "26f38eb23ad84214537831cfef4032299ffac994ff18d5cb72e82d31ac4ceac4" + }, + "id": "0qqycx08fTBpfbC9", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/update.test.ts b/packages/nodes-base/nodes/Aws/IAM/test/user/update.test.ts new file mode 100644 index 0000000000..d35ab7ba12 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/update.test.ts @@ -0,0 +1,37 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { BASE_URL, CURRENT_VERSION } from '../../helpers/constants'; + +describe('AWS IAM - Update User', () => { + beforeEach(() => { + nock.cleanAll(); + nock(BASE_URL) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/x-amz-json-1.1' }) + .post('/', { + Action: 'UpdateUser', + Version: CURRENT_VERSION, + UserName: 'NewUser', + NewUserName: 'UserTest', + }) + .reply(200, { + UpdateUserResponse: { + ResponseMetadata: { + RequestId: 'bdb4a8b5-627a-41a7-aba9-5733b7869c16', + }, + }, + }); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['update.workflow.json'], + credentials: { + aws: { + region: 'eu-central-1', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + }, + }); +}); diff --git a/packages/nodes-base/nodes/Aws/IAM/test/user/update.workflow.json b/packages/nodes-base/nodes/Aws/IAM/test/user/update.workflow.json new file mode 100644 index 0000000000..9cfe70391f --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/test/user/update.workflow.json @@ -0,0 +1,62 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [200, 120], + "id": "b4205abf-7102-4e53-8aed-7bd047acfaf4", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "update", + "user": { + "__rl": true, + "value": "NewUser", + "mode": "list", + "cachedResultName": "NewUser" + }, + "userName": "UserTest", + "requestOptions": {} + }, + "type": "n8n-nodes-base.awsIam", + "typeVersion": 1, + "position": [400, 120], + "id": "64bbfce7-cc7d-4e69-82d3-d4ecac5ad389", + "name": "AWS IAM12", + "credentials": { + "aws": { + "id": "exampleId", + "name": "AWS US EAST" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "AWS IAM12", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "AWS IAM12": [ + { + "json": { + "UpdateUserResponse": { + "ResponseMetadata": { + "RequestId": "bdb4a8b5-627a-41a7-aba9-5733b7869c16" + } + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Aws/IAM/transport/index.ts b/packages/nodes-base/nodes/Aws/IAM/transport/index.ts new file mode 100644 index 0000000000..1bb898487b --- /dev/null +++ b/packages/nodes-base/nodes/Aws/IAM/transport/index.ts @@ -0,0 +1,54 @@ +import type { + IExecuteSingleFunctions, + IDataObject, + IHttpRequestOptions, + ILoadOptionsFunctions, + IPollFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { BASE_URL } from '../helpers/constants'; + +const errorMapping: IDataObject = { + 403: 'The AWS credentials are not valid!', +}; + +export async function awsApiRequest( + this: ILoadOptionsFunctions | IPollFunctions | IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const requestOptions: IHttpRequestOptions = { + baseURL: BASE_URL, + json: true, + ...opts, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(opts.headers ?? {}), + }, + }; + + if (opts.body) { + requestOptions.body = new URLSearchParams(opts.body as Record).toString(); + } + + try { + const response = (await this.helpers.requestWithAuthentication.call( + this, + 'aws', + requestOptions, + )) as IDataObject; + + return response; + } catch (error) { + const statusCode = (error?.statusCode || error?.cause?.statusCode) as string; + + if (statusCode && errorMapping[statusCode]) { + throw new NodeApiError(this.getNode(), { + message: `AWS error response [${statusCode}]: ${errorMapping[statusCode] as string}`, + }); + } else { + throw new NodeApiError(this.getNode(), error as JsonObject); + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e7c85fbe87..c6a3c6d795 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -435,6 +435,7 @@ "dist/nodes/Aws/Comprehend/AwsComprehend.node.js", "dist/nodes/Aws/DynamoDB/AwsDynamoDB.node.js", "dist/nodes/Aws/ELB/AwsElb.node.js", + "dist/nodes/Aws/IAM/AwsIam.node.js", "dist/nodes/Aws/Rekognition/AwsRekognition.node.js", "dist/nodes/Aws/S3/AwsS3.node.js", "dist/nodes/Aws/SES/AwsSes.node.js",