Skip to content

Commit 2d9323f

Browse files
Added support for tags for IAM users
Signed-off-by: Aayush Chouhan <achouhan@redhat.com>
1 parent 69e29e1 commit 2d9323f

File tree

12 files changed

+537
-21
lines changed

12 files changed

+537
-21
lines changed

src/endpoint/iam/iam_constants.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const IAM_ACTIONS = Object.freeze({
1313
UPDATE_ACCESS_KEY: 'update_access_key',
1414
DELETE_ACCESS_KEY: 'delete_access_key',
1515
LIST_ACCESS_KEYS: 'list_access_keys',
16+
TAG_USER: 'tag_user',
17+
UNTAG_USER: 'untag_user',
18+
LIST_USER_TAGS: 'list_user_tags',
1619
PUT_USER_POLICY: 'put_user_policy',
1720
GET_USER_POLICY: 'get_user_policy',
1821
DELETE_USER_POLICY: 'delete_user_policy',
@@ -33,6 +36,9 @@ const ACTION_MESSAGE_TITLE_MAP = Object.freeze({
3336
'update_access_key': 'UpdateAccessKey',
3437
'delete_access_key': 'DeleteAccessKey',
3538
'list_access_keys': 'ListAccessKeys',
39+
'tag_user': 'TagUser',
40+
'untag_user': 'UntagUser',
41+
'list_user_tags': 'ListUserTags',
3642
'put_user_policy': 'PutUserPolicy',
3743
'get_user_policy': 'GetUserPolicy',
3844
'delete_user_policy': 'DeleteUserPolicy',
@@ -51,6 +57,7 @@ const IDENTITY_ENUM = Object.freeze({
5157

5258
// miscellaneous
5359
const DEFAULT_MAX_ITEMS = 100;
60+
const MAX_TAGS = 50;
5461
const MAX_NUMBER_OF_ACCESS_KEYS = 2;
5562
const IAM_DEFAULT_PATH = '/';
5663
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;
7683
exports.ACCESS_KEY_STATUS_ENUM = ACCESS_KEY_STATUS_ENUM;
7784
exports.IDENTITY_ENUM = IDENTITY_ENUM;
7885
exports.DEFAULT_MAX_ITEMS = DEFAULT_MAX_ITEMS;
86+
exports.MAX_TAGS = MAX_TAGS;
7987
exports.MAX_NUMBER_OF_ACCESS_KEYS = MAX_NUMBER_OF_ACCESS_KEYS;
8088
exports.IAM_DEFAULT_PATH = IAM_DEFAULT_PATH;
8189
exports.AWS_NOT_USED = AWS_NOT_USED;

src/endpoint/iam/iam_rest.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const RPC_ERRORS_TO_IAM = Object.freeze({
2121
NO_SUCH_ACCOUNT: IamError.AccessDeniedException,
2222
NO_SUCH_ROLE: IamError.AccessDeniedException,
2323
VALIDATION_ERROR: IamError.ValidationError,
24+
INVALID_INPUT: IamError.InvalidInput,
2425
MALFORMED_POLICY_DOCUMENT: IamError.MalformedPolicyDocument,
2526
});
2627

@@ -35,6 +36,9 @@ const ACTIONS = Object.freeze({
3536
'UpdateAccessKey': 'update_access_key',
3637
'DeleteAccessKey': 'delete_access_key',
3738
'ListAccessKeys': 'list_access_keys',
39+
'TagUser': 'tag_user',
40+
'UntagUser': 'untag_user',
41+
'ListUserTags': 'list_user_tags',
3842
'PutUserPolicy': 'put_user_policy',
3943
'GetUserPolicy': 'get_user_policy',
4044
'DeleteUserPolicy': 'delete_user_policy',
@@ -65,7 +69,6 @@ const ACTIONS = Object.freeze({
6569
'ListServiceSpecificCredentials': 'list_service_specific_credentials',
6670
'ListSigningCertificates': 'list_signing_certificates',
6771
'ListSSHPublicKeys': 'list_ssh_public_keys',
68-
'ListUserTags': 'list_user_tags',
6972
'ListVirtualMFADevices': 'list_virtual_mfa_devices',
7073
});
7174

@@ -83,6 +86,10 @@ const IAM_OPS = js_utils.deep_freeze({
8386
post_update_access_key: require('./ops/iam_update_access_key'),
8487
post_delete_access_key: require('./ops/iam_delete_access_key'),
8588
post_list_access_keys: require('./ops/iam_list_access_keys'),
89+
// user tagging
90+
post_tag_user: require('./ops/iam_tag_user'),
91+
post_untag_user: require('./ops/iam_untag_user'),
92+
post_list_user_tags: require('./ops/iam_list_user_tags'),
8693
// user policy
8794
post_put_user_policy: require('./ops/iam_put_user_policy'),
8895
post_get_user_policy: require('./ops/iam_get_user_policy'),
@@ -115,7 +122,6 @@ const IAM_OPS = js_utils.deep_freeze({
115122
post_list_service_specific_credentials: require('./ops/iam_list_service_specific_credentials'),
116123
post_list_signing_certificates: require('./ops/iam_list_signing_certificates'),
117124
post_list_ssh_public_keys: require('./ops/iam_list_ssh_public_keys'),
118-
post_list_user_tags: require('./ops/iam_list_user_tags'),
119125
post_list_virtual_mfa_devices: require('./ops/iam_list_virtual_mfa_devices'),
120126
});
121127

src/endpoint/iam/iam_utils.js

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@ function parse_max_items(input_max_items) {
8181
}
8282

8383
/**
84-
* validate_params will call the aquivalent function in user or access key
84+
* validate_params will call the equivalent function (for example: user, access key, tagging, etc.)
85+
* Note: The order of conditions matters - check 'tag' before 'user' to avoid misrouting
8586
* @param {string} action
8687
* @param {object} params
8788
*/
8889
function validate_params(action, params) {
89-
if (action.includes('policy') || action.includes('policies')) {
90+
if (action.includes('tag')) {
91+
validate_tagging_params(action, params);
92+
} else if (action.includes('policy') || action.includes('policies')) {
9093
validate_policy_params(action, params);
9194
} else if (action.includes('user')) {
9295
validate_user_params(action, params);
@@ -151,6 +154,27 @@ function validate_access_keys_params(action, params) {
151154
}
152155
}
153156

157+
/**
158+
* validate_tagging_params will call the equivalent function for each action in tagging API
159+
* @param {string} action
160+
* @param {object} params
161+
*/
162+
function validate_tagging_params(action, params) {
163+
switch (action) {
164+
case iam_constants.IAM_ACTIONS.TAG_USER:
165+
validate_tag_user_params(params);
166+
break;
167+
case iam_constants.IAM_ACTIONS.UNTAG_USER:
168+
validate_untag_user_params(params);
169+
break;
170+
case iam_constants.IAM_ACTIONS.LIST_USER_TAGS:
171+
validate_list_user_tags_params(params);
172+
break;
173+
default:
174+
throw new RpcError('INTERNAL_ERROR', `${action} is not supported`);
175+
}
176+
}
177+
154178
/**
155179
* validate_policy_params will call the aquivalent function for each action in user policy API
156180
* @param {string} action
@@ -578,9 +602,111 @@ function translate_rpc_error(err) {
578602
const { code, http_code, type } = IamError.ValidationError;
579603
throw new IamError({ code, message: err.message, http_code, type });
580604
}
605+
if (err.rpc_code === 'INVALID_INPUT') {
606+
const { code, http_code, type } = IamError.InvalidInput;
607+
throw new IamError({ code, message: err.message, http_code, type });
608+
}
581609
throw err;
582610
}
583611

612+
/**
613+
* parse_indexed_members parses indexed array members
614+
* generic parser for AWS-style indexed request parameters
615+
* @param {object} body - request body
616+
* @param {string} base_key - the base key pattern (e.g., 'tags_member_{index}_key)
617+
* @param {Function} [mapper] - optional function to convert each item
618+
*/
619+
function parse_indexed_members(body, base_key, mapper) {
620+
try {
621+
const result = [];
622+
let index = 1;
623+
let check_key = base_key.replace('{index}', String(index));
624+
625+
while (body[check_key] !== undefined) {
626+
result.push(mapper ? mapper(body, index) : body[check_key]);
627+
index += 1;
628+
check_key = base_key.replace('{index}', String(index));
629+
}
630+
return result;
631+
} catch (err) {
632+
throw new RpcError('INVALID_INPUT', `Error parsing request parameters: ${err.message}`);
633+
}
634+
}
635+
636+
/**
637+
* parse_tags_from_request_body parses tags from request body
638+
* converts AWS encoded indexed members to array of tag objects
639+
* example input: { tags_member_1_key: 'env', tags_member_1_value: 'prod', tags_member_2_key: 'team', tags_member_2_value: 'backend' }
640+
* example output: [{ key: 'env', value: 'prod' }, { key: 'team', value: 'backend' }]
641+
* @param {object} body - request body
642+
* @returns {Array<{key: string, value: string}>} array of tag objects
643+
*/
644+
function parse_tags_from_request_body(body) {
645+
return parse_indexed_members(
646+
body,
647+
'tags_member_{index}_key',
648+
(req_body, index) => ({
649+
key: req_body[`tags_member_${index}_key`],
650+
value: req_body[`tags_member_${index}_value`] || ''
651+
})
652+
);
653+
}
654+
655+
/**
656+
* parse_tag_keys_from_request_body parses tag keys from request body
657+
* converts AWS encoded indexed members to array
658+
* example input: { tag_keys_member_1: 'env', tag_keys_member_2: 'team' }
659+
* example output: ['env', 'team']
660+
* @param {object} body - request body
661+
* @returns {Array<string>} array of tag keys
662+
*/
663+
function parse_tag_keys_from_request_body(body) {
664+
return parse_indexed_members(body, 'tag_keys_member_{index}');
665+
}
666+
667+
/**
668+
* validate_tag_user_params checks the params for tag_user action
669+
* @param {object} params
670+
*/
671+
function validate_tag_user_params(params) {
672+
try {
673+
check_required_username(params);
674+
validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME);
675+
validation_utils.validate_iam_tags(params.tags);
676+
} catch (err) {
677+
translate_rpc_error(err);
678+
}
679+
}
680+
681+
/**
682+
* validate_untag_user_params checks the params for untag_user action
683+
* @param {object} params
684+
*/
685+
function validate_untag_user_params(params) {
686+
try {
687+
check_required_username(params);
688+
validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME);
689+
validation_utils.validate_iam_tag_keys(params.tag_keys);
690+
} catch (err) {
691+
translate_rpc_error(err);
692+
}
693+
}
694+
695+
/**
696+
* validate_list_user_tags_params checks the params for list_user_tags action
697+
* @param {object} params
698+
*/
699+
function validate_list_user_tags_params(params) {
700+
try {
701+
check_required_username(params);
702+
validation_utils.validate_username(params.username, iam_constants.IAM_PARAMETER_NAME.USERNAME);
703+
validate_marker(params.marker);
704+
validate_max_items(params.max_items);
705+
} catch (err) {
706+
translate_rpc_error(err);
707+
}
708+
}
709+
584710
// EXPORTS
585711
exports.format_iam_xml_date = format_iam_xml_date;
586712
exports.create_arn_for_user = create_arn_for_user;
@@ -593,4 +719,10 @@ exports.validate_iam_path = validate_iam_path;
593719
exports.validate_marker = validate_marker;
594720
exports.validate_access_key_id = validate_access_key_id;
595721
exports.validate_status = validate_status;
722+
exports.parse_indexed_members = parse_indexed_members;
723+
exports.parse_tags_from_request_body = parse_tags_from_request_body;
724+
exports.parse_tag_keys_from_request_body = parse_tag_keys_from_request_body;
725+
exports.validate_tag_user_params = validate_tag_user_params;
726+
exports.validate_untag_user_params = validate_untag_user_params;
727+
exports.validate_list_user_tags_params = validate_list_user_tags_params;
596728

src/endpoint/iam/ops/iam_list_user_tags.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,16 @@ async function list_user_tags(req, res) {
1717
max_items: iam_utils.parse_max_items(req.body.max_items) ?? iam_constants.DEFAULT_MAX_ITEMS,
1818
};
1919

20-
dbg.log1('To check that we have the user we will run the IAM GET USER', params);
21-
iam_utils.validate_params(iam_constants.IAM_ACTIONS.GET_USER, params);
22-
await req.account_sdk.get_user(params);
23-
24-
dbg.log1('IAM LIST USER TAGS (returns empty list on every request)', params);
20+
dbg.log1('IAM LIST USER TAGS', params);
21+
iam_utils.validate_params(iam_constants.IAM_ACTIONS.LIST_USER_TAGS, params);
22+
const reply = await req.account_sdk.list_user_tags(params);
23+
dbg.log2('list_user_tags reply', reply);
2524

2625
return {
2726
ListUserTagsResponse: {
2827
ListUserTagsResult: {
29-
Tags: [],
30-
IsTruncated: false,
28+
Tags: reply.tags,
29+
IsTruncated: reply.is_truncated
3130
},
3231
ResponseMetadata: {
3332
RequestId: req.request_id,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* Copyright (C) 2024 NooBaa */
2+
'use strict';
3+
4+
const dbg = require('../../../util/debug_module')(__filename);
5+
const iam_utils = require('../iam_utils');
6+
const iam_constants = require('../iam_constants');
7+
const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils');
8+
9+
/**
10+
* https://docs.aws.amazon.com/IAM/latest/APIReference/API_TagUser.html
11+
*/
12+
async function tag_user(req, res) {
13+
14+
// parse tags from AWS encoded format
15+
// input: { tags_member_1_key: 'env', tags_member_1_value: 'prod', ... }
16+
// output: [{ key: 'env', value: 'prod' }, ...]
17+
const params = {
18+
username: req.body.user_name,
19+
tags: iam_utils.parse_tags_from_request_body(req.body),
20+
};
21+
22+
dbg.log1('IAM TAG USER', params);
23+
iam_utils.validate_params(iam_constants.IAM_ACTIONS.TAG_USER, params);
24+
await req.account_sdk.tag_user(params);
25+
26+
return {
27+
TagUserResponse: {
28+
ResponseMetadata: {
29+
RequestId: req.request_id,
30+
}
31+
},
32+
};
33+
}
34+
35+
module.exports = {
36+
handler: tag_user,
37+
body: {
38+
type: CONTENT_TYPE_APP_FORM_URLENCODED,
39+
},
40+
reply: {
41+
type: 'xml',
42+
},
43+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* Copyright (C) 2024 NooBaa */
2+
'use strict';
3+
4+
const dbg = require('../../../util/debug_module')(__filename);
5+
const iam_utils = require('../iam_utils');
6+
const iam_constants = require('../iam_constants');
7+
const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils');
8+
9+
/**
10+
* https://docs.aws.amazon.com/IAM/latest/APIReference/API_UntagUser.html
11+
*/
12+
async function untag_user(req, res) {
13+
14+
// parse tag keys from AWS encoded format
15+
// input: { tag_keys_member_1: 'env', tag_keys_member_2: 'team', ... }
16+
// output: ['env', 'team', ...]
17+
const params = {
18+
username: req.body.user_name,
19+
tag_keys: iam_utils.parse_tag_keys_from_request_body(req.body),
20+
};
21+
22+
dbg.log1('IAM UNTAG USER', params);
23+
iam_utils.validate_params(iam_constants.IAM_ACTIONS.UNTAG_USER, params);
24+
await req.account_sdk.untag_user(params);
25+
26+
return {
27+
UntagUserResponse: {
28+
ResponseMetadata: {
29+
RequestId: req.request_id,
30+
}
31+
},
32+
};
33+
}
34+
35+
module.exports = {
36+
handler: untag_user,
37+
body: {
38+
type: CONTENT_TYPE_APP_FORM_URLENCODED,
39+
},
40+
reply: {
41+
type: 'xml',
42+
},
43+
};

src/sdk/account_sdk.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ class AccountSDK {
117117
return accountspace.list_users(params, this);
118118
}
119119

120+
////////////
121+
// TAGS //
122+
////////////
123+
124+
async tag_user(params) {
125+
const accountspace = this._get_accountspace();
126+
return accountspace.tag_user(params, this);
127+
}
128+
129+
async untag_user(params) {
130+
const accountspace = this._get_accountspace();
131+
return accountspace.untag_user(params, this);
132+
}
133+
134+
async list_user_tags(params) {
135+
const accountspace = this._get_accountspace();
136+
return accountspace.list_user_tags(params, this);
137+
}
138+
120139
////////////////
121140
// ACCESS KEY //
122141
////////////////

0 commit comments

Comments
 (0)