Repository: getmoto/moto

Issue:
IAM - NotImplementedError: The tag_user action has not been implemented (and untag_user)
# Issue

When attempting to add or remove user tags, these exceptions are thrown

```sh
NotImplementedError: The tag_user action has not been implemented
```

```sh
NotImplementedError: The untag_user action has not been implemented
```

# Code

```python
import pytest
from moto import mock_iam
import boto3


@mock_iam
def test_tag_user():
    iam = boto3.client("iam")
    iam.create_user(UserName="user")

    iam.tag_user(
        UserName="user",
        Tags=[
            {"Key": "testkey", "Value": "testval"},
        ],
    )

    iam.untag_user(
        UserName="user",
        TagKeys=[
            "testkey",
        ],
    )
```

# Stack Trace

```sh
tests/test_iam.py:11:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/local/lib/python3.8/site-packages/botocore/client.py:316: in _api_call
    return self._make_api_call(operation_name, kwargs)
/usr/local/lib/python3.8/site-packages/botocore/client.py:621: in _make_api_call
    http, parsed_response = self._make_request(
/usr/local/lib/python3.8/site-packages/botocore/client.py:641: in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
/usr/local/lib/python3.8/site-packages/botocore/endpoint.py:102: in make_request
    return self._send_request(request_dict, operation_model)
/usr/local/lib/python3.8/site-packages/botocore/endpoint.py:136: in _send_request
    while self._needs_retry(attempts, operation_model, request_dict,
/usr/local/lib/python3.8/site-packages/botocore/endpoint.py:253: in _needs_retry
    responses = self._event_emitter.emit(
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:356: in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:228: in emit
    return self._emit(event_name, kwargs)
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:211: in _emit
    response = handler(**kwargs)
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:183: in __call__
    if self._checker(attempts, response, caught_exception):
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:250: in __call__
    should_retry = self._should_retry(attempt_number, response,
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:269: in _should_retry
    return self._checker(attempt_number, response, caught_exception)
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:316: in __call__
    checker_response = checker(attempt_number, response,
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:222: in __call__
    return self._check_caught_exception(
/usr/local/lib/python3.8/site-packages/botocore/retryhandler.py:359: in _check_caught_exception
    raise caught_exception
/usr/local/lib/python3.8/site-packages/botocore/endpoint.py:197: in _do_get_response
    responses = self._event_emitter.emit(event_name, request=request)
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:356: in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:228: in emit
    return self._emit(event_name, kwargs)
/usr/local/lib/python3.8/site-packages/botocore/hooks.py:211: in _emit
    response = handler(**kwargs)
/usr/local/lib/python3.8/site-packages/moto/core/models.py:322: in __call__
    status, headers, body = response_callback(
/usr/local/lib/python3.8/site-packages/moto/core/responses.py:202: in dispatch
    return cls()._dispatch(*args, **kwargs)
/usr/local/lib/python3.8/site-packages/moto/core/responses.py:312: in _dispatch
    return self.call_action()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <moto.iam.responses.IamResponse object at 0x7f71017c7220>

    def call_action(self):
        headers = self.response_headers

        try:
            self._authenticate_and_authorize_normal_action()
        except HTTPException as http_error:
            response = http_error.description, dict(status=http_error.code)
            return self._send_response(headers, response)

        action = camelcase_to_underscores(self._get_action())
        method_names = method_names_from_class(self.__class__)
        if action in method_names:
            method = getattr(self, action)
            try:
                response = method()
            except HTTPException as http_error:
                response = http_error.description, dict(status=http_error.code)

            if isinstance(response, six.string_types):
                return 200, headers, response
            else:
                return self._send_response(headers, response)

        if not action:
            return 404, headers, ""

>       raise NotImplementedError(
            "The {0} action has not been implemented".format(action)
        )
E       NotImplementedError: The tag_user action has not been implemented

/usr/local/lib/python3.8/site-packages/moto/core/responses.py:409: NotImplementedError
```

# System Info

MacOS 10.15.7
Running in a Docker container,
Docker 20.10.0, build 7287ab3
Debian x86_64 Linux 4.19.121-linuxkit

Python 3.8.5
moto 1.3.16
boto3 1.14.31
botocore 1.17.31

# Similar Issues

https://github.com/spulec/moto/issues/3239
https://github.com/spulec/moto/issues/3450


Discussion:
Thanks @ryyan - marking it as an enhancement

Resolving patch:
diff --git a/moto/iam/models.py b/moto/iam/models.py
--- a/moto/iam/models.py
+++ b/moto/iam/models.py
@@ -45,6 +45,7 @@
     random_resource_id,
     random_policy_id,
 )
+from ..utilities.tagging_service import TaggingService
 
 
 class MFADevice(object):
@@ -924,7 +925,7 @@ def delete_policy(self, policy_name):
 
 
 class User(CloudFormationModel):
-    def __init__(self, name, path=None, tags=None):
+    def __init__(self, name, path=None):
         self.name = name
         self.id = random_resource_id()
         self.path = path if path else "/"
@@ -937,7 +938,6 @@ def __init__(self, name, path=None, tags=None):
         self.password = None
         self.password_reset_required = False
         self.signing_certificates = {}
-        self.tags = tags
 
     @property
     def arn(self):
@@ -1135,7 +1135,8 @@ def create_from_cloudformation_json(
     ):
         properties = cloudformation_json.get("Properties", {})
         path = properties.get("Path")
-        return iam_backend.create_user(resource_physical_name, path)
+        user, _ = iam_backend.create_user(resource_physical_name, path)
+        return user
 
     @classmethod
     def update_from_cloudformation_json(
@@ -1415,6 +1416,8 @@ def __init__(self):
         self.account_summary = AccountSummary(self)
         self.inline_policies = {}
         self.access_keys = {}
+
+        self.tagger = TaggingService()
         super(IAMBackend, self).__init__()
 
     def _init_managed_policies(self):
@@ -1978,16 +1981,16 @@ def create_user(self, user_name, path="/", tags=None):
                 "EntityAlreadyExists", "User {0} already exists".format(user_name)
             )
 
-        user = User(user_name, path, tags)
+        user = User(user_name, path)
+        self.tagger.tag_resource(user.arn, tags or [])
         self.users[user_name] = user
-        return user
+        return user, self.tagger.list_tags_for_resource(user.arn)
 
-    def get_user(self, user_name):
-        user = None
-        try:
-            user = self.users[user_name]
-        except KeyError:
-            raise IAMNotFoundException("User {0} not found".format(user_name))
+    def get_user(self, name):
+        user = self.users.get(name)
+
+        if not user:
+            raise NoSuchEntity("The user with name {} cannot be found.".format(name))
 
         return user
 
@@ -2147,7 +2150,7 @@ def list_user_policies(self, user_name):
 
     def list_user_tags(self, user_name):
         user = self.get_user(user_name)
-        return user.tags
+        return self.tagger.list_tags_for_resource(user.arn)
 
     def put_user_policy(self, user_name, policy_name, policy_json):
         user = self.get_user(user_name)
@@ -2204,7 +2207,7 @@ def delete_access_key_by_name(self, name):
         try:  # User may have been deleted before their access key...
             user = self.get_user(key.user_name)
             user.delete_access_key(key.access_key_id)
-        except IAMNotFoundException:
+        except NoSuchEntity:
             pass
         del self.access_keys[name]
 
@@ -2250,7 +2253,7 @@ def enable_mfa_device(
                 "CreateDate": user.created_iso_8601,
                 "PasswordLastUsed": None,  # not supported
                 "PermissionsBoundary": {},  # ToDo: add put_user_permissions_boundary() functionality
-                "Tags": {},  # ToDo: add tag_user() functionality
+                "Tags": self.tagger.list_tags_for_resource(user.arn)["Tags"],
             }
 
         user.enable_mfa_device(
@@ -2355,6 +2358,7 @@ def delete_user(self, user_name):
                 code="DeleteConflict",
                 message="Cannot delete entity, must delete policies first.",
             )
+        self.tagger.delete_all_tags_for_resource(user.arn)
         del self.users[user_name]
 
     def report_generated(self):
@@ -2574,5 +2578,15 @@ def delete_inline_policy(self, policy_id):
         inline_policy.unapply_policy(self)
         del self.inline_policies[policy_id]
 
+    def tag_user(self, name, tags):
+        user = self.get_user(name)
+
+        self.tagger.tag_resource(user.arn, tags)
+
+    def untag_user(self, name, tag_keys):
+        user = self.get_user(name)
+
+        self.tagger.untag_resource_using_names(user.arn, tag_keys)
+
 
 iam_backend = IAMBackend()
diff --git a/moto/iam/responses.py b/moto/iam/responses.py
--- a/moto/iam/responses.py
+++ b/moto/iam/responses.py
@@ -471,9 +471,9 @@ def create_user(self):
         user_name = self._get_param("UserName")
         path = self._get_param("Path")
         tags = self._get_multi_param("Tags.member")
-        user = iam_backend.create_user(user_name, path, tags)
+        user, user_tags = iam_backend.create_user(user_name, path, tags)
         template = self.response_template(USER_TEMPLATE)
-        return template.render(action="Create", user=user)
+        return template.render(action="Create", user=user, tags=user_tags["Tags"])
 
     def get_user(self):
         user_name = self._get_param("UserName")
@@ -572,7 +572,7 @@ def list_user_tags(self):
         user_name = self._get_param("UserName")
         tags = iam_backend.list_user_tags(user_name)
         template = self.response_template(LIST_USER_TAGS_TEMPLATE)
-        return template.render(user_tags=tags or [])
+        return template.render(user_tags=tags["Tags"])
 
     def put_user_policy(self):
         user_name = self._get_param("UserName")
@@ -989,6 +989,24 @@ def get_account_summary(self):
         template = self.response_template(GET_ACCOUNT_SUMMARY_TEMPLATE)
         return template.render(summary_map=account_summary.summary_map)
 
+    def tag_user(self):
+        name = self._get_param("UserName")
+        tags = self._get_multi_param("Tags.member")
+
+        iam_backend.tag_user(name, tags)
+
+        template = self.response_template(TAG_USER_TEMPLATE)
+        return template.render()
+
+    def untag_user(self):
+        name = self._get_param("UserName")
+        tag_keys = self._get_multi_param("TagKeys.member")
+
+        iam_backend.untag_user(name, tag_keys)
+
+        template = self.response_template(UNTAG_USER_TEMPLATE)
+        return template.render()
+
 
 LIST_ENTITIES_FOR_POLICY_TEMPLATE = """<ListEntitiesForPolicyResponse>
  <ListEntitiesForPolicyResult>
@@ -1684,9 +1702,9 @@ def get_account_summary(self):
          <UserId>{{ user.id }}</UserId>
          <CreateDate>{{ user.created_iso_8601 }}</CreateDate>
          <Arn>{{ user.arn }}</Arn>
-         {% if user.tags %}
+         {% if tags %}
          <Tags>
-            {% for tag in user.tags %}
+            {% for tag in tags %}
             <member>
                 <Key>{{ tag['Key'] }}</Key>
                 <Value>{{ tag['Value'] }}</Value>
@@ -2039,13 +2057,23 @@ def get_account_summary(self):
       {% if device.enable_date %}
       <EnableDate>{{ device.enabled_iso_8601 }}</EnableDate>
       {% endif %}
-      {% if device.user %}
+      {% if device.user_attribute %}
       <User>
-        <Path>{{ device.user.path }}</Path>
-        <UserName>{{ device.user.name }}</UserName>
-        <UserId>{{ device.user.id }}</UserId>
-        <CreateDate>{{ device.user.created_iso_8601 }}</CreateDate>
-        <Arn>{{ device.user.arn }}</Arn>
+        <Path>{{ device.user_attribute.Path }}</Path>
+        <UserName>{{ device.user_attribute.UserName }}</UserName>
+        <UserId>{{ device.user_attribute.UserId }}</UserId>
+        <CreateDate>{{ device.user_attribute.CreateDate }}</CreateDate>
+        <Arn>{{ device.user_attribute.Arn }}</Arn>
+        {% if device.user_attribute.Tags %}
+        <Tags>
+          {% for tag in device.user_attribute.Tags %}
+          <member>
+            <Key>{{ tag['Key'] }}</Key>
+            <Value>{{ tag['Value'] }}</Value>
+          </member>
+          {% endfor %}
+        </Tags>
+        {% endif %}
       </User>
       {% endif %}
     </member>
@@ -2514,3 +2542,17 @@ def get_account_summary(self):
     <RequestId>85cb9b90-ac28-11e4-a88d-97964EXAMPLE</RequestId>
   </ResponseMetadata>
 </GetAccountSummaryResponse>"""
+
+
+TAG_USER_TEMPLATE = """<TagUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
+  <ResponseMetadata>
+    <RequestId>EXAMPLE8-90ab-cdef-fedc-ba987EXAMPLE</RequestId>
+  </ResponseMetadata>
+</TagUserResponse>"""
+
+
+UNTAG_USER_TEMPLATE = """<UntagUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
+  <ResponseMetadata>
+    <RequestId>EXAMPLE8-90ab-cdef-fedc-ba987EXAMPLE</RequestId>
+  </ResponseMetadata>
+</UntagUserResponse>"""


Test patch:
diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py
--- a/tests/test_iam/test_iam.py
+++ b/tests/test_iam/test_iam.py
@@ -1140,8 +1140,9 @@ def test_enable_virtual_mfa_device():
     client = boto3.client("iam", region_name="us-east-1")
     response = client.create_virtual_mfa_device(VirtualMFADeviceName="test-device")
     serial_number = response["VirtualMFADevice"]["SerialNumber"]
+    tags = [{"Key": "key", "Value": "value"}]
 
-    client.create_user(UserName="test-user")
+    client.create_user(UserName="test-user", Tags=tags)
     client.enable_mfa_device(
         UserName="test-user",
         SerialNumber=serial_number,
@@ -1165,6 +1166,7 @@ def test_enable_virtual_mfa_device():
         "arn:aws:iam::{}:user/test-user".format(ACCOUNT_ID)
     )
     device["User"]["CreateDate"].should.be.a(datetime)
+    device["User"]["Tags"].should.equal(tags)
     device["EnableDate"].should.be.a(datetime)
     response["IsTruncated"].should_not.be.ok
 
@@ -2924,7 +2926,7 @@ def test_list_user_tags():
         ],
     )
     response = conn.list_user_tags(UserName="kenny-bania")
-    response["Tags"].should.equal([])
+    response["Tags"].should.have.length_of(0)
     response["IsTruncated"].should_not.be.ok
 
     response = conn.list_user_tags(UserName="jackie-chiles")
@@ -4047,3 +4049,80 @@ def test_create_user_with_tags():
 
     resp = conn.create_user(UserName="test-create-user-no-tags")
     assert "Tags" not in resp["User"]
+
+
+@mock_iam
+def test_tag_user():
+    # given
+    client = boto3.client("iam", region_name="eu-central-1")
+    name = "test-user"
+    tags = sorted(
+        [{"Key": "key", "Value": "value"}, {"Key": "key-2", "Value": "value-2"}],
+        key=lambda item: item["Key"],
+    )
+    client.create_user(UserName=name)
+
+    # when
+    client.tag_user(UserName=name, Tags=tags)
+
+    # then
+    response = client.list_user_tags(UserName=name)
+    sorted(response["Tags"], key=lambda item: item["Key"],).should.equal(tags)
+
+
+@mock_iam
+def test_tag_user_error_unknown_user_name():
+    # given
+    client = boto3.client("iam", region_name="eu-central-1")
+    name = "unknown"
+
+    # when
+    with pytest.raises(ClientError) as e:
+        client.tag_user(UserName=name, Tags=[{"Key": "key", "Value": "value"}])
+
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("TagUser")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(404)
+    ex.response["Error"]["Code"].should.contain("NoSuchEntity")
+    ex.response["Error"]["Message"].should.equal(
+        "The user with name {} cannot be found.".format(name)
+    )
+
+
+@mock_iam
+def test_untag_user():
+    # given
+    client = boto3.client("iam", region_name="eu-central-1")
+    name = "test-user"
+    client.create_user(
+        UserName=name,
+        Tags=[{"Key": "key", "Value": "value"}, {"Key": "key-2", "Value": "value"}],
+    )
+
+    # when
+    client.untag_user(UserName=name, TagKeys=["key-2"])
+
+    # then
+    response = client.list_user_tags(UserName=name)
+    response["Tags"].should.equal([{"Key": "key", "Value": "value"}])
+
+
+@mock_iam
+def test_untag_user_error_unknown_user_name():
+    # given
+    client = boto3.client("iam", region_name="eu-central-1")
+    name = "unknown"
+
+    # when
+    with pytest.raises(ClientError) as e:
+        client.untag_user(UserName=name, TagKeys=["key"])
+
+    # then
+    ex = e.value
+    ex.operation_name.should.equal("UntagUser")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(404)
+    ex.response["Error"]["Code"].should.contain("NoSuchEntity")
+    ex.response["Error"]["Message"].should.equal(
+        "The user with name {} cannot be found.".format(name)
+    )
diff --git a/tests/test_iam/test_iam_cloudformation.py b/tests/test_iam/test_iam_cloudformation.py
--- a/tests/test_iam/test_iam_cloudformation.py
+++ b/tests/test_iam/test_iam_cloudformation.py
@@ -967,59 +967,6 @@ def test_iam_cloudformation_access_key_get_attr():
     pass
 
 
-@mock_iam
-@mock_cloudformation
-def test_iam_cloudformation_delete_users_access_key():
-    cf_client = boto3.client("cloudformation", region_name="us-east-1")
-
-    stack_name = "MyStack"
-
-    template = """
-    Resources:
-      TheUser:
-        Type: AWS::IAM::User
-      TheAccessKey:
-        Type: AWS::IAM::AccessKey
-        Properties:
-          UserName: !Ref TheUser
-    """.strip()
-
-    cf_client.create_stack(StackName=stack_name, TemplateBody=template)
-
-    provisioned_resources = cf_client.list_stack_resources(StackName=stack_name)[
-        "StackResourceSummaries"
-    ]
-
-    provisioned_user = [
-        resource
-        for resource in provisioned_resources
-        if resource["LogicalResourceId"] == "TheUser"
-    ][0]
-    user_name = provisioned_user["PhysicalResourceId"]
-
-    provisioned_access_key = [
-        resource
-        for resource in provisioned_resources
-        if resource["LogicalResourceId"] == "TheAccessKey"
-    ][0]
-    access_key_id = provisioned_access_key["PhysicalResourceId"]
-
-    iam_client = boto3.client("iam", region_name="us-east-1")
-    user = iam_client.get_user(UserName=user_name)
-    access_keys = iam_client.list_access_keys(UserName=user_name)
-
-    access_key_id.should.equal(access_keys["AccessKeyMetadata"][0]["AccessKeyId"])
-
-    cf_client.delete_stack(StackName=stack_name)
-
-    iam_client.get_user.when.called_with(UserName=user_name).should.throw(
-        iam_client.exceptions.NoSuchEntityException
-    )
-    iam_client.list_access_keys.when.called_with(UserName=user_name).should.throw(
-        iam_client.exceptions.NoSuchEntityException
-    )
-
-
 @mock_iam
 @mock_cloudformation
 def test_iam_cloudformation_delete_users_access_key():
@@ -1055,13 +1002,15 @@ def test_iam_cloudformation_delete_users_access_key():
         for resource in provisioned_resources
         if resource["LogicalResourceId"] == "TheAccessKey"
     ]
-    len(provisioned_access_keys).should.equal(1)
+    provisioned_access_keys.should.have.length_of(1)
+    access_key_id = provisioned_access_keys[0]["PhysicalResourceId"]
 
     iam_client = boto3.client("iam", region_name="us-east-1")
     user = iam_client.get_user(UserName=user_name)["User"]
     user["UserName"].should.equal(user_name)
     access_keys = iam_client.list_access_keys(UserName=user_name)
     access_keys["AccessKeyMetadata"][0]["UserName"].should.equal(user_name)
+    access_key_id.should.equal(access_keys["AccessKeyMetadata"][0]["AccessKeyId"])
 
     cf_client.delete_stack(StackName=stack_name)