diff --git a/src/endpoint/iam/iam_constants.js b/src/endpoint/iam/iam_constants.js index 792ae69376..7c63bb80c8 100644 --- a/src/endpoint/iam/iam_constants.js +++ b/src/endpoint/iam/iam_constants.js @@ -13,6 +13,9 @@ const IAM_ACTIONS = Object.freeze({ UPDATE_ACCESS_KEY: 'update_access_key', DELETE_ACCESS_KEY: 'delete_access_key', LIST_ACCESS_KEYS: 'list_access_keys', + TAG_USER: 'tag_user', + UNTAG_USER: 'untag_user', + LIST_USER_TAGS: 'list_user_tags', PUT_USER_POLICY: 'put_user_policy', GET_USER_POLICY: 'get_user_policy', DELETE_USER_POLICY: 'delete_user_policy', @@ -33,6 +36,9 @@ const ACTION_MESSAGE_TITLE_MAP = Object.freeze({ 'update_access_key': 'UpdateAccessKey', 'delete_access_key': 'DeleteAccessKey', 'list_access_keys': 'ListAccessKeys', + 'tag_user': 'TagUser', + 'untag_user': 'UntagUser', + 'list_user_tags': 'ListUserTags', 'put_user_policy': 'PutUserPolicy', 'get_user_policy': 'GetUserPolicy', 'delete_user_policy': 'DeleteUserPolicy', @@ -51,6 +57,7 @@ const IDENTITY_ENUM = Object.freeze({ // miscellaneous const DEFAULT_MAX_ITEMS = 100; +const MAX_TAGS = 50; const MAX_NUMBER_OF_ACCESS_KEYS = 2; const IAM_DEFAULT_PATH = '/'; const AWS_NOT_USED = 'N/A'; // can be used in case the region or the service name were not used @@ -76,6 +83,7 @@ exports.ACTION_MESSAGE_TITLE_MAP = ACTION_MESSAGE_TITLE_MAP; exports.ACCESS_KEY_STATUS_ENUM = ACCESS_KEY_STATUS_ENUM; exports.IDENTITY_ENUM = IDENTITY_ENUM; exports.DEFAULT_MAX_ITEMS = DEFAULT_MAX_ITEMS; +exports.MAX_TAGS = MAX_TAGS; exports.MAX_NUMBER_OF_ACCESS_KEYS = MAX_NUMBER_OF_ACCESS_KEYS; exports.IAM_DEFAULT_PATH = IAM_DEFAULT_PATH; exports.AWS_NOT_USED = AWS_NOT_USED; diff --git a/src/endpoint/iam/iam_rest.js b/src/endpoint/iam/iam_rest.js index c77bfb2257..67725fbae4 100644 --- a/src/endpoint/iam/iam_rest.js +++ b/src/endpoint/iam/iam_rest.js @@ -21,6 +21,7 @@ const RPC_ERRORS_TO_IAM = Object.freeze({ NO_SUCH_ACCOUNT: IamError.AccessDeniedException, NO_SUCH_ROLE: IamError.AccessDeniedException, VALIDATION_ERROR: IamError.ValidationError, + INVALID_INPUT: IamError.InvalidInput, MALFORMED_POLICY_DOCUMENT: IamError.MalformedPolicyDocument, }); @@ -35,6 +36,9 @@ const ACTIONS = Object.freeze({ 'UpdateAccessKey': 'update_access_key', 'DeleteAccessKey': 'delete_access_key', 'ListAccessKeys': 'list_access_keys', + 'TagUser': 'tag_user', + 'UntagUser': 'untag_user', + 'ListUserTags': 'list_user_tags', 'PutUserPolicy': 'put_user_policy', 'GetUserPolicy': 'get_user_policy', 'DeleteUserPolicy': 'delete_user_policy', @@ -65,7 +69,6 @@ const ACTIONS = Object.freeze({ 'ListServiceSpecificCredentials': 'list_service_specific_credentials', 'ListSigningCertificates': 'list_signing_certificates', 'ListSSHPublicKeys': 'list_ssh_public_keys', - 'ListUserTags': 'list_user_tags', 'ListVirtualMFADevices': 'list_virtual_mfa_devices', }); @@ -83,6 +86,10 @@ const IAM_OPS = js_utils.deep_freeze({ post_update_access_key: require('./ops/iam_update_access_key'), post_delete_access_key: require('./ops/iam_delete_access_key'), post_list_access_keys: require('./ops/iam_list_access_keys'), + // user tagging + post_tag_user: require('./ops/iam_tag_user'), + post_untag_user: require('./ops/iam_untag_user'), + post_list_user_tags: require('./ops/iam_list_user_tags'), // user policy post_put_user_policy: require('./ops/iam_put_user_policy'), post_get_user_policy: require('./ops/iam_get_user_policy'), @@ -115,7 +122,6 @@ const IAM_OPS = js_utils.deep_freeze({ post_list_service_specific_credentials: require('./ops/iam_list_service_specific_credentials'), post_list_signing_certificates: require('./ops/iam_list_signing_certificates'), post_list_ssh_public_keys: require('./ops/iam_list_ssh_public_keys'), - post_list_user_tags: require('./ops/iam_list_user_tags'), post_list_virtual_mfa_devices: require('./ops/iam_list_virtual_mfa_devices'), }); diff --git a/src/endpoint/iam/iam_utils.js b/src/endpoint/iam/iam_utils.js index 2234398efe..bcaf2c3c6e 100644 --- a/src/endpoint/iam/iam_utils.js +++ b/src/endpoint/iam/iam_utils.js @@ -81,12 +81,15 @@ function parse_max_items(input_max_items) { } /** - * validate_params will call the aquivalent function in user or access key + * validate_params will call the equivalent function (for example: user, access key, tagging, etc.) + * Note: The order of conditions matters - check 'tag' before 'user' to avoid misrouting * @param {string} action * @param {object} params */ function validate_params(action, params) { - if (action.includes('policy') || action.includes('policies')) { + if (action.includes('tag')) { + validate_tagging_params(action, params); + } else if (action.includes('policy') || action.includes('policies')) { validate_policy_params(action, params); } else if (action.includes('user')) { validate_user_params(action, params); @@ -151,6 +154,27 @@ function validate_access_keys_params(action, params) { } } +/** + * validate_tagging_params will call the equivalent function for each action in tagging API + * @param {string} action + * @param {object} params + */ +function validate_tagging_params(action, params) { + switch (action) { + case iam_constants.IAM_ACTIONS.TAG_USER: + validate_tag_user_params(params); + break; + case iam_constants.IAM_ACTIONS.UNTAG_USER: + validate_untag_user_params(params); + break; + case iam_constants.IAM_ACTIONS.LIST_USER_TAGS: + validate_list_user_tags_params(params); + break; + default: + throw new RpcError('INTERNAL_ERROR', `${action} is not supported`); + } +} + /** * validate_policy_params will call the aquivalent function for each action in user policy API * @param {string} action @@ -578,9 +602,111 @@ function translate_rpc_error(err) { const { code, http_code, type } = IamError.ValidationError; throw new IamError({ code, message: err.message, http_code, type }); } + if (err.rpc_code === 'INVALID_INPUT') { + const { code, http_code, type } = IamError.InvalidInput; + throw new IamError({ code, message: err.message, http_code, type }); + } throw err; } +/** + * parse_indexed_members parses indexed array members + * generic parser for AWS-style indexed request parameters + * @param {object} body - request body + * @param {string} base_key - the base key pattern (e.g., 'tags_member_{index}_key) + * @param {Function} [mapper] - optional function to convert each item + */ +function parse_indexed_members(body, base_key, mapper) { + try { + const result = []; + let index = 1; + let check_key = base_key.replace('{index}', String(index)); + + while (body[check_key] !== undefined) { + result.push(mapper ? mapper(body, index) : body[check_key]); + index += 1; + check_key = base_key.replace('{index}', String(index)); + } + return result; + } catch (err) { + throw new RpcError('INVALID_INPUT', `Error parsing request parameters: ${err.message}`); + } +} + +/** + * parse_tags_from_request_body parses tags from request body + * converts AWS encoded indexed members to array of tag objects + * example input: { tags_member_1_key: 'env', tags_member_1_value: 'prod', tags_member_2_key: 'team', tags_member_2_value: 'backend' } + * example output: [{ key: 'env', value: 'prod' }, { key: 'team', value: 'backend' }] + * @param {object} body - request body + * @returns {Array<{key: string, value: string}>} array of tag objects + */ +function parse_tags_from_request_body(body) { + return parse_indexed_members( + body, + 'tags_member_{index}_key', + (req_body, index) => ({ + key: req_body[`tags_member_${index}_key`], + value: req_body[`tags_member_${index}_value`] || '' + }) + ); +} + +/** + * parse_tag_keys_from_request_body parses tag keys from request body + * converts AWS encoded indexed members to array + * example input: { tag_keys_member_1: 'env', tag_keys_member_2: 'team' } + * example output: ['env', 'team'] + * @param {object} body - request body + * @returns {Array} array of tag keys + */ +function parse_tag_keys_from_request_body(body) { + return parse_indexed_members(body, 'tag_keys_member_{index}'); +} + +/** + * validate_tag_user_params checks the params for tag_user action + * @param {object} params + */ +function validate_tag_user_params(params) { + try { + check_required_username(params); + validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME); + validation_utils.validate_iam_tags(params.tags); + } catch (err) { + translate_rpc_error(err); + } +} + +/** + * validate_untag_user_params checks the params for untag_user action + * @param {object} params + */ +function validate_untag_user_params(params) { + try { + check_required_username(params); + validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME); + validation_utils.validate_iam_tag_keys(params.tag_keys); + } catch (err) { + translate_rpc_error(err); + } +} + +/** + * validate_list_user_tags_params checks the params for list_user_tags action + * @param {object} params + */ +function validate_list_user_tags_params(params) { + try { + check_required_username(params); + validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME); + validate_marker(params.marker); + validate_max_items(params.max_items); + } catch (err) { + translate_rpc_error(err); + } +} + // EXPORTS exports.format_iam_xml_date = format_iam_xml_date; exports.create_arn_for_user = create_arn_for_user; @@ -593,4 +719,10 @@ exports.validate_iam_path = validate_iam_path; exports.validate_marker = validate_marker; exports.validate_access_key_id = validate_access_key_id; exports.validate_status = validate_status; +exports.parse_indexed_members = parse_indexed_members; +exports.parse_tags_from_request_body = parse_tags_from_request_body; +exports.parse_tag_keys_from_request_body = parse_tag_keys_from_request_body; +exports.validate_tag_user_params = validate_tag_user_params; +exports.validate_untag_user_params = validate_untag_user_params; +exports.validate_list_user_tags_params = validate_list_user_tags_params; diff --git a/src/endpoint/iam/ops/iam_list_user_tags.js b/src/endpoint/iam/ops/iam_list_user_tags.js index 96e39191d1..0cd3dc4f38 100644 --- a/src/endpoint/iam/ops/iam_list_user_tags.js +++ b/src/endpoint/iam/ops/iam_list_user_tags.js @@ -17,17 +17,16 @@ async function list_user_tags(req, res) { max_items: iam_utils.parse_max_items(req.body.max_items) ?? iam_constants.DEFAULT_MAX_ITEMS, }; - dbg.log1('To check that we have the user we will run the IAM GET USER', params); - iam_utils.validate_params(iam_constants.IAM_ACTIONS.GET_USER, params); - await req.account_sdk.get_user(params); - - dbg.log1('IAM LIST USER TAGS (returns empty list on every request)', params); + dbg.log1('IAM LIST USER TAGS', params); + iam_utils.validate_params(iam_constants.IAM_ACTIONS.LIST_USER_TAGS, params); + const reply = await req.account_sdk.list_user_tags(params); + dbg.log2('list_user_tags reply', reply); return { ListUserTagsResponse: { ListUserTagsResult: { - Tags: [], - IsTruncated: false, + Tags: reply.tags, + IsTruncated: reply.is_truncated }, ResponseMetadata: { RequestId: req.request_id, diff --git a/src/endpoint/iam/ops/iam_tag_user.js b/src/endpoint/iam/ops/iam_tag_user.js new file mode 100644 index 0000000000..7bb556f628 --- /dev/null +++ b/src/endpoint/iam/ops/iam_tag_user.js @@ -0,0 +1,43 @@ +/* Copyright (C) 2024 NooBaa */ +'use strict'; + +const dbg = require('../../../util/debug_module')(__filename); +const iam_utils = require('../iam_utils'); +const iam_constants = require('../iam_constants'); +const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils'); + +/** + * https://docs.aws.amazon.com/IAM/latest/APIReference/API_TagUser.html + */ +async function tag_user(req, res) { + + // parse tags from AWS encoded format + // input: { tags_member_1_key: 'env', tags_member_1_value: 'prod', ... } + // output: [{ key: 'env', value: 'prod' }, ...] + const params = { + username: req.body.user_name, + tags: iam_utils.parse_tags_from_request_body(req.body), + }; + + dbg.log1('IAM TAG USER', params); + iam_utils.validate_params(iam_constants.IAM_ACTIONS.TAG_USER, params); + await req.account_sdk.tag_user(params); + + return { + TagUserResponse: { + ResponseMetadata: { + RequestId: req.request_id, + } + }, + }; +} + +module.exports = { + handler: tag_user, + body: { + type: CONTENT_TYPE_APP_FORM_URLENCODED, + }, + reply: { + type: 'xml', + }, +}; diff --git a/src/endpoint/iam/ops/iam_untag_user.js b/src/endpoint/iam/ops/iam_untag_user.js new file mode 100644 index 0000000000..8eb2c897d0 --- /dev/null +++ b/src/endpoint/iam/ops/iam_untag_user.js @@ -0,0 +1,43 @@ +/* Copyright (C) 2024 NooBaa */ +'use strict'; + +const dbg = require('../../../util/debug_module')(__filename); +const iam_utils = require('../iam_utils'); +const iam_constants = require('../iam_constants'); +const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils'); + +/** + * https://docs.aws.amazon.com/IAM/latest/APIReference/API_UntagUser.html + */ +async function untag_user(req, res) { + + // parse tag keys from AWS encoded format + // input: { tag_keys_member_1: 'env', tag_keys_member_2: 'team', ... } + // output: ['env', 'team', ...] + const params = { + username: req.body.user_name, + tag_keys: iam_utils.parse_tag_keys_from_request_body(req.body), + }; + + dbg.log1('IAM UNTAG USER', params); + iam_utils.validate_params(iam_constants.IAM_ACTIONS.UNTAG_USER, params); + await req.account_sdk.untag_user(params); + + return { + UntagUserResponse: { + ResponseMetadata: { + RequestId: req.request_id, + } + }, + }; +} + +module.exports = { + handler: untag_user, + body: { + type: CONTENT_TYPE_APP_FORM_URLENCODED, + }, + reply: { + type: 'xml', + }, +}; diff --git a/src/sdk/account_sdk.js b/src/sdk/account_sdk.js index cea7345d93..22c79088bd 100644 --- a/src/sdk/account_sdk.js +++ b/src/sdk/account_sdk.js @@ -117,6 +117,25 @@ class AccountSDK { return accountspace.list_users(params, this); } + //////////// + // TAGS // + //////////// + + async tag_user(params) { + const accountspace = this._get_accountspace(); + return accountspace.tag_user(params, this); + } + + async untag_user(params) { + const accountspace = this._get_accountspace(); + return accountspace.untag_user(params, this); + } + + async list_user_tags(params) { + const accountspace = this._get_accountspace(); + return accountspace.list_user_tags(params, this); + } + //////////////// // ACCESS KEY // //////////////// diff --git a/src/sdk/accountspace_fs.js b/src/sdk/accountspace_fs.js index ffe6910402..92088441b1 100644 --- a/src/sdk/accountspace_fs.js +++ b/src/sdk/accountspace_fs.js @@ -558,6 +558,31 @@ class AccountSpaceFS { return { members, is_truncated }; } + async tag_user(params, account_sdk) { + const action = IAM_ACTIONS.TAG_USER; + dbg.log1(`AccountSpaceFS.${action}`, params); + const { code, http_code, type } = IamError.NotImplemented; + throw new IamError({ code, message: 'NotImplemented', http_code, type }); + } + + async untag_user(params, account_sdk) { + const action = IAM_ACTIONS.UNTAG_USER; + dbg.log1(`AccountSpaceFS.${action}`, params); + const { code, http_code, type } = IamError.NotImplemented; + throw new IamError({ code, message: 'NotImplemented', http_code, type }); + } + + async list_user_tags(params, account_sdk) { + const action = IAM_ACTIONS.LIST_USER_TAGS; + dbg.log1(`AccountSpaceFS.${action}`, params); + dbg.log1('To check that we have the user we will run the IAM GET USER', params); + await account_sdk.get_user(params); + dbg.log1('IAM LIST USER TAGS (returns empty list on every request)', params); + const is_truncated = false; + const tags = []; + return { tags, is_truncated }; + } + //////////////////////// // INTERNAL FUNCTIONS // //////////////////////// diff --git a/src/sdk/accountspace_nb.js b/src/sdk/accountspace_nb.js index f20b80c79c..97f758fad0 100644 --- a/src/sdk/accountspace_nb.js +++ b/src/sdk/accountspace_nb.js @@ -9,7 +9,7 @@ const dbg = require('../util/debug_module')(__filename); const IamError = require('../endpoint/iam/iam_errors').IamError; const system_store = require('..//server/system_services/system_store').get_instance(); const { IAM_ACTIONS, IAM_DEFAULT_PATH, ACCESS_KEY_STATUS_ENUM, - IAM_SPLIT_CHARACTERS } = require('../endpoint/iam/iam_constants'); + IAM_SPLIT_CHARACTERS, MAX_TAGS } = require('../endpoint/iam/iam_constants'); /* TODO: DISCUSS: @@ -328,6 +328,117 @@ class AccountSpaceNB { username: account_util._returned_username(requesting_account, requested_account.name.unwrap(), false) }; } + /////////////////////////// + // ACCOUNT TAGS METHODS // + /////////////////////////// + + async tag_user(params, account_sdk) { + const action = IAM_ACTIONS.TAG_USER; + const requesting_account = system_store.get_account_by_email(account_sdk.requesting_account.email); + const username = account_util.get_account_name_from_username(params.username, requesting_account.name.unwrap()); + + account_util._check_if_requesting_account_is_root_account(action, requesting_account, { username: params.username }); + account_util._check_if_account_exists(action, username); + + const requested_account = system_store.get_account_by_email(username); + account_util._check_if_requested_account_is_root_account_or_IAM_user(action, requesting_account, requested_account); + account_util._check_if_requested_is_owned_by_root_account(action, requesting_account, requested_account); + + const existing_tags = requested_account.tagging || []; + + const tags_map = new Map(); + for (const tag of existing_tags) { + tags_map.set(tag.key, tag.value); + } + for (const tag of params.tags) { + tags_map.set(tag.key, tag.value); + } + + // enforce AWS tag limit after merging + if (tags_map.size > MAX_TAGS) { + const message_with_details = `Failed to tag user. User cannot have more than ${MAX_TAGS} tags.`; + const { code, http_code, type } = IamError.LimitExceeded; + throw new IamError({ code, message: message_with_details, http_code, type }); + } + + const updated_tags = Array.from(tags_map.entries()).map(([key, value]) => ({ key, value })); + + await system_store.make_changes({ + update: { + accounts: [{ + _id: requested_account._id, + $set: { tagging: updated_tags } + }] + } + }); + + dbg.log1('AccountSpaceNB.tag_user: successfully tagged user', params.username, 'with', params.tags.length, 'tags'); + } + + async untag_user(params, account_sdk) { + const action = IAM_ACTIONS.UNTAG_USER; + const requesting_account = system_store.get_account_by_email(account_sdk.requesting_account.email); + const username = account_util.get_account_name_from_username(params.username, requesting_account.name.unwrap()); + + account_util._check_if_requesting_account_is_root_account(action, requesting_account, { username: params.username }); + account_util._check_if_account_exists(action, username); + + const requested_account = system_store.get_account_by_email(username); + account_util._check_if_requested_account_is_root_account_or_IAM_user(action, requesting_account, requested_account); + account_util._check_if_requested_is_owned_by_root_account(action, requesting_account, requested_account); + + const existing_tags = requested_account.tagging || []; + + const tag_keys_set = new Set(params.tag_keys); + const updated_tags = existing_tags.filter(tag => !tag_keys_set.has(tag.key)); + + await system_store.make_changes({ + update: { + accounts: [{ + _id: requested_account._id, + $set: { tagging: updated_tags } + }] + } + }); + + dbg.log1('AccountSpaceNB.untag_user: successfully removed', params.tag_keys.length, 'tags from user', params.username); + } + + async list_user_tags(params, account_sdk) { + const action = IAM_ACTIONS.LIST_USER_TAGS; + const requesting_account = system_store.get_account_by_email(account_sdk.requesting_account.email); + const username = account_util.get_account_name_from_username(params.username, requesting_account.name.unwrap()); + + account_util._check_if_requesting_account_is_root_account(action, requesting_account, { username: params.username }); + account_util._check_if_account_exists(action, username); + + const requested_account = system_store.get_account_by_email(username); + account_util._check_if_requested_account_is_root_account_or_IAM_user(action, requesting_account, requested_account); + account_util._check_if_requested_is_owned_by_root_account(action, requesting_account, requested_account); + + // TODO: Pagination not supported - currently returns all tags, ignoring marker and max_items params + const all_tags = requested_account.tagging || []; + const sorted_tags = all_tags.sort((a, b) => a.key.localeCompare(b.key)); + + const tags = sorted_tags.length > 0 ? sorted_tags.map(tag => ({ + member: { + Key: tag.key, + Value: tag.value + } + })) : []; + + dbg.log1('AccountSpaceNB.list_user_tags: returning', tags.length, 'tags for user', params.username); + + return { + tags: tags, + is_truncated: false + }; + } + + //////////////////// + // POLICY METHODS // + //////////////////// + async put_user_policy(params, account_sdk) { dbg.log0('AccountSpaceNB.put_user_policy:', params); const { code, http_code, type } = IamError.NotImplemented; diff --git a/src/sdk/nb.d.ts b/src/sdk/nb.d.ts index b36a0371c0..c1d14a7227 100644 --- a/src/sdk/nb.d.ts +++ b/src/sdk/nb.d.ts @@ -907,6 +907,10 @@ interface AccountSpace { get_access_key_last_used(params: object, account_sdk: AccountSDK): Promise; delete_access_key(params: object, account_sdk: AccountSDK): Promise; list_access_keys(params: object, account_sdk: AccountSDK): Promise; + // user tagging + tag_user(params: object, account_sdk: AccountSDK): Promise; + untag_user(params: object, account_sdk: AccountSDK): Promise; + list_user_tags(params: object, account_sdk: AccountSDK): Promise; // inline user policy put_user_policy(params: object, account_sdk: AccountSDK): Promise; get_user_policy(params: object, account_sdk: AccountSDK): Promise; diff --git a/src/test/unit_tests/api/iam/test_iam_ops_input_validation.test.js b/src/test/unit_tests/api/iam/test_iam_ops_input_validation.test.js index 4cdd3b89fd..0a7c46f835 100644 --- a/src/test/unit_tests/api/iam/test_iam_ops_input_validation.test.js +++ b/src/test/unit_tests/api/iam/test_iam_ops_input_validation.test.js @@ -255,7 +255,7 @@ describe('input validation flow in IAM ops - IAM USERS API', () => { expect(err).toBeInstanceOf(IamError); expect(err).toHaveProperty('code', IamError.ValidationError.code); expect(err).toHaveProperty('message'); - expect(err.message).toMatch(/invalid/i); + expect(err.message).toMatch(/length greater than/i); } }); }); @@ -319,7 +319,7 @@ describe('input validation flow in IAM ops - IAM ACCESS KEY API', () => { expect(err).toBeInstanceOf(IamError); expect(err).toHaveProperty('code', IamError.ValidationError.code); expect(err).toHaveProperty('message'); - expect(err.message).toMatch(/invalid/i); + expect(err.message).toMatch(/length greater than/i); } }); }); @@ -517,7 +517,7 @@ describe('input validation flow in IAM ops - IAM ACCESS KEY API', () => { expect(err).toBeInstanceOf(IamError); expect(err).toHaveProperty('code', IamError.ValidationError.code); expect(err).toHaveProperty('message'); - expect(err.message).toMatch(/invalid/i); + expect(err.message).toMatch(/length greater than/i); } }); @@ -535,7 +535,7 @@ describe('input validation flow in IAM ops - IAM ACCESS KEY API', () => { expect(err).toBeInstanceOf(IamError); expect(err).toHaveProperty('code', IamError.ValidationError.code); expect(err).toHaveProperty('message'); - expect(err.message).toMatch(/invalid/i); + expect(err.message).toMatch(/length greater than/i); } }); diff --git a/src/util/string_utils.js b/src/util/string_utils.js index 943f5e0787..aeeb5076f2 100644 --- a/src/util/string_utils.js +++ b/src/util/string_utils.js @@ -15,6 +15,7 @@ const AWS_IAM_PATH_REGEXP = /^(\u002F|\u002F[\u0021-\u007E]+\u002F)$/; const AWS_USERNAME_REGEXP = /^[\w+=,.@-]+$/; const AWS_IAM_LIST_MARKER = /^[\u0020-\u00FF]+$/; const AWS_IAM_ACCESS_KEY_INPUT_REGEXP = /^[\w]+$/; +const AWS_IAM_TAG_KEY_AND_VALUE_REGEXP = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u; const AWS_POLICY_NAME_REGEXP = /^[\w+=,.@-]+$/; const AWS_POLICY_DOCUMENT_REGEXP = /^[\u0009\u000A\u000D\u0020-\u00FF]+$/; @@ -167,5 +168,6 @@ exports.AWS_IAM_PATH_REGEXP = AWS_IAM_PATH_REGEXP; exports.AWS_USERNAME_REGEXP = AWS_USERNAME_REGEXP; exports.AWS_IAM_LIST_MARKER = AWS_IAM_LIST_MARKER; exports.AWS_IAM_ACCESS_KEY_INPUT_REGEXP = AWS_IAM_ACCESS_KEY_INPUT_REGEXP; +exports.AWS_IAM_TAG_KEY_AND_VALUE_REGEXP = AWS_IAM_TAG_KEY_AND_VALUE_REGEXP; exports.AWS_POLICY_NAME_REGEXP = AWS_POLICY_NAME_REGEXP; exports.AWS_POLICY_DOCUMENT_REGEXP = AWS_POLICY_DOCUMENT_REGEXP; diff --git a/src/util/validation_utils.js b/src/util/validation_utils.js index 9c4a8316a4..5d3ec1cb98 100644 --- a/src/util/validation_utils.js +++ b/src/util/validation_utils.js @@ -2,10 +2,16 @@ 'use strict'; const _ = require('lodash'); -const { AWS_USERNAME_REGEXP, AWS_POLICY_NAME_REGEXP, AWS_POLICY_DOCUMENT_REGEXP } = require('../util/string_utils'); +const { AWS_USERNAME_REGEXP, AWS_IAM_TAG_KEY_AND_VALUE_REGEXP, AWS_POLICY_NAME_REGEXP, AWS_POLICY_DOCUMENT_REGEXP } = require('../util/string_utils'); const RpcError = require('../rpc/rpc_error'); const iam_constants = require('../endpoint/iam/iam_constants'); +// tag validation constants +const MAX_TAG_KEY_LENGTH = 128; +const MAX_TAG_VALUE_LENGTH = 256; +const TAG_KEY_PATTERN = '[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+'; +const TAG_VALUE_PATTERN = '[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*'; + /** * _type_check_input checks that the input is the same as needed * @param {string} input_type @@ -22,14 +28,18 @@ function _type_check_input(input_type, input_value, parameter_name) { /** * _length_check_input checks that the input length is between the min and the max value - * @param {number} min_length - * @param {number} max_length - * @param {string} input_value + * @param {number} min_length - minimum length (optional) + * @param {number} max_length - maximum length (optional) + * @param {string|Array} input_value * @param {string} parameter_name */ function _length_check_input(min_length, max_length, input_value, parameter_name) { - _length_min_check_input(min_length, input_value, parameter_name); - _length_max_check_input(max_length, input_value, parameter_name); + if (min_length !== undefined) { + _length_min_check_input(min_length, input_value, parameter_name); + } + if (max_length !== undefined) { + _length_max_check_input(max_length, input_value, parameter_name); + } } /** @@ -41,9 +51,9 @@ function _length_check_input(min_length, max_length, input_value, parameter_name function _length_min_check_input(min_length, input_value, parameter_name) { const input_length = input_value.length; if (input_length < min_length) { - const message_with_details = `Invalid length for parameter ${parameter_name}, ` + - `value: ${input_length}, valid min length: ${min_length}`; - throw new RpcError('VALIDATION_ERROR', message_with_details); + const message_with_details = `1 validation error detected: Value at '${parameter_name}' ` + + `failed to satisfy constraint: Member must have length greater than or equal to ${min_length}`; + throw new RpcError('VALIDATION_ERROR', message_with_details); } } @@ -169,9 +179,123 @@ function validate_policy_document(input_policy_document, parameter_name = iam_co return true; } +/** + * _validate_tag_pattern validates tag key or value against AWS pattern + * @param {string} value - tag key or value to validate + * @param {string} position - position identifier for error message (e.g., 'tags.1.member.key') + * @param {boolean} is_value - true if validating value (allows empty), false for key + */ +function _validate_tag_pattern(value, position, is_value = false) { + if (!AWS_IAM_TAG_KEY_AND_VALUE_REGEXP.test(value)) { + const pattern = is_value ? TAG_VALUE_PATTERN : TAG_KEY_PATTERN; + const message_with_details = `1 validation error detected: Value at '${position}' ` + + `failed to satisfy constraint: Member must satisfy regular expression pattern: ${pattern}`; + throw new RpcError('VALIDATION_ERROR', message_with_details); + } +} + +/** + * _has_tag_key_violation checks if tag key violates any constraint + * @param {string} key - tag key to validate + */ +function _has_tag_key_violation(key) { + return key === null || key === undefined || + key.length > MAX_TAG_KEY_LENGTH || key.length < 1 || + !AWS_IAM_TAG_KEY_AND_VALUE_REGEXP.test(key); +} + +/** + * validate_iam_tags will validate tags array for TagUser operation: + * 1. Must be an array + * 2. Maximum 50 tags per user (AWS limit) + * 3. Each tag must have key and value + * 4. Tag key: 1-128 characters + * 5. Tag value: 0-256 characters + * @param {Array} tags + */ +function validate_iam_tags(tags) { + if (!Array.isArray(tags)) { + throw new RpcError('VALIDATION_ERROR', 'Invalid type for parameter Tags, value must be an array'); + } + + if (tags.length === 0) { + throw new RpcError('INVALID_INPUT', 'Error occurred while validating input.'); + } + + if (tags.length > iam_constants.MAX_TAGS) { + const message_with_details = `1 validation error detected: Value at 'tags' failed to satisfy constraint: ` + + `Member must have length less than or equal to ${iam_constants.MAX_TAGS}`; + throw new RpcError('VALIDATION_ERROR', message_with_details); + } + + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + const tag_position = `tags.${i + 1}.member`; + + _type_check_input('object', tag, tag_position); + if (tag === null || Array.isArray(tag)) { + throw new RpcError('VALIDATION_ERROR', + `1 validation error detected: Value ${tag} at '${tag_position}' failed to satisfy constraint: Member must be object`); + } + + _type_check_input('string', tag.key, `${tag_position}.key`); + _length_check_input(1, MAX_TAG_KEY_LENGTH, tag.key, `${tag_position}.key`); + _validate_tag_pattern(tag.key, `${tag_position}.key`, false); + + _type_check_input('string', tag.value, `${tag_position}.value`); + _length_check_input(undefined, MAX_TAG_VALUE_LENGTH, tag.value, `${tag_position}.value`); + + if (tag.value.length > 0) { + _validate_tag_pattern(tag.value, `${tag_position}.value`, true); + } + } + + return true; +} + +/** + * validate_iam_tag_keys will validate tag keys array for UntagUser operation: + * 1. Must be an array + * 2. Can be empty (AWS accepts empty array) + * 3. Maximum 50 tag keys per request (AWS limit) + * 4. Each key must be a string + * 5. Tag key: 1-128 characters + * @param {Array} tag_keys + */ +function validate_iam_tag_keys(tag_keys) { + const TAG_KEY_CONSTRAINTS = [ + `Member must have length less than or equal to ${MAX_TAG_KEY_LENGTH}`, + 'Member must have length greater than or equal to 1', + `Member must satisfy regular expression pattern: ${TAG_KEY_PATTERN}`, + 'Member must not be null' + ]; + + if (!Array.isArray(tag_keys)) { + throw new RpcError('VALIDATION_ERROR', 'Invalid type for parameter TagKeys, value must be an array'); + } + + if (tag_keys.length > iam_constants.MAX_TAGS) { + const message_with_details = `1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: ` + + `Member must have length less than or equal to ${iam_constants.MAX_TAGS}`; + throw new RpcError('VALIDATION_ERROR', message_with_details); + } + + for (let i = 0; i < tag_keys.length; i++) { + _type_check_input('string', tag_keys[i], 'tagKeys'); + + if (_has_tag_key_violation(tag_keys[i])) { + throw new RpcError('VALIDATION_ERROR', + `1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [${TAG_KEY_CONSTRAINTS.join(', ')}]`); + } + } + + return true; +} // EXPORTS exports.validate_username = validate_username; +exports.validate_iam_tags = validate_iam_tags; +exports.validate_iam_tag_keys = validate_iam_tag_keys; exports._type_check_input = _type_check_input; exports._length_check_input = _length_check_input; exports._length_min_check_input = _length_min_check_input;