diff --git a/moto/elasticache/exceptions.py b/moto/elasticache/exceptions.py
index 876406b4e9c3..0266a151f225 100644
--- a/moto/elasticache/exceptions.py
+++ b/moto/elasticache/exceptions.py
@@ -43,6 +43,26 @@ def __init__(self) -> None:
)
+class InvalidParameterValueException(ElastiCacheException):
+ code = 404
+
+ def __init__(self, message: str) -> None:
+ super().__init__(
+ "InvalidParameterValue",
+ message=message,
+ )
+
+
+class InvalidParameterCombinationException(ElastiCacheException):
+ code = 404
+
+ def __init__(self, message: str) -> None:
+ super().__init__(
+ "InvalidParameterCombination",
+ message=message,
+ )
+
+
class UserAlreadyExists(ElastiCacheException):
code = 404
diff --git a/moto/elasticache/models.py b/moto/elasticache/models.py
index ffade587504e..6861d1bdd704 100644
--- a/moto/elasticache/models.py
+++ b/moto/elasticache/models.py
@@ -12,10 +12,12 @@
CacheClusterAlreadyExists,
CacheClusterNotFound,
InvalidARNFault,
+ InvalidParameterCombinationException,
+ InvalidParameterValueException,
UserAlreadyExists,
UserNotFound,
)
-from .utils import PAGINATION_MODEL
+from .utils import PAGINATION_MODEL, AuthenticationTypes
class User(BaseModel):
@@ -29,10 +31,12 @@ def __init__(
engine: str,
no_password_required: bool,
passwords: Optional[List[str]] = None,
+ authentication_type: Optional[str] = None,
):
self.id = user_id
self.name = user_name
self.engine = engine
+
self.passwords = passwords or []
self.access_string = access_string
self.no_password_required = no_password_required
@@ -41,6 +45,7 @@ def __init__(
self.usergroupids: List[str] = []
self.region = region
self.arn = f"arn:{get_partition(self.region)}:elasticache:{self.region}:{account_id}:user:{self.id}"
+ self.authentication_type = authentication_type
class CacheCluster(BaseModel):
@@ -161,9 +166,34 @@ def create_user(
passwords: List[str],
access_string: str,
no_password_required: bool,
+ authentication_type: str, # contain it to the str in the enums TODO
) -> User:
if user_id in self.users:
raise UserAlreadyExists
+
+ if authentication_type not in AuthenticationTypes._value2member_map_:
+ raise InvalidParameterValueException(
+ f"Input Authentication type: {authentication_type} is not in the allowed list: [password,no-password-required,iam]"
+ )
+
+ if (
+ no_password_required
+ and authentication_type != AuthenticationTypes.NOPASSWORD
+ ):
+ raise InvalidParameterCombinationException(
+ f"No password required flag is true but provided authentication type is {authentication_type}"
+ )
+
+ if passwords and authentication_type != AuthenticationTypes.PASSWORD:
+ raise InvalidParameterCombinationException(
+ f"Password field is not allowed with authentication type: {authentication_type}"
+ )
+
+ if not passwords and authentication_type == AuthenticationTypes.PASSWORD:
+ raise InvalidParameterCombinationException(
+ "A user with Authentication Mode: password, must have at least one password"
+ )
+
user = User(
account_id=self.account_id,
region=self.region_name,
@@ -173,6 +203,7 @@ def create_user(
passwords=passwords,
access_string=access_string,
no_password_required=no_password_required,
+ authentication_type=authentication_type,
)
self.users[user_id] = user
return user
diff --git a/moto/elasticache/responses.py b/moto/elasticache/responses.py
index 225d2b7c861e..82d6ecc0b12c 100644
--- a/moto/elasticache/responses.py
+++ b/moto/elasticache/responses.py
@@ -1,7 +1,12 @@
from moto.core.responses import BaseResponse
-from .exceptions import PasswordRequired, PasswordTooShort
+from .exceptions import (
+ InvalidParameterCombinationException,
+ InvalidParameterValueException,
+ PasswordTooShort,
+)
from .models import ElastiCacheBackend, elasticache_backends
+from .utils import AuthenticationTypes
class ElastiCacheResponse(BaseResponse):
@@ -21,12 +26,41 @@ def create_user(self) -> str:
user_name = params.get("UserName")
engine = params.get("Engine")
passwords = params.get("Passwords", [])
- no_password_required = self._get_bool_param("NoPasswordRequired", False)
- password_required = not no_password_required
- if password_required and not passwords:
- raise PasswordRequired
+ no_password_required = self._get_bool_param("NoPasswordRequired")
+ authentication_mode = params.get("AuthenticationMode")
+ authentication_type = "null"
+
+ if no_password_required is not None:
+ authentication_type = (
+ AuthenticationTypes.NOPASSWORD.value
+ if no_password_required
+ else AuthenticationTypes.PASSWORD.value
+ )
+
+ if passwords:
+ authentication_type = AuthenticationTypes.PASSWORD.value
+
+ if authentication_mode:
+ for key in authentication_mode.keys():
+ if key not in ["Type", "Passwords"]:
+ raise InvalidParameterValueException(
+ f'Unknown parameter in AuthenticationMode: "{key}", must be one of: Type, Passwords'
+ )
+
+ authentication_type = authentication_mode.get("Type")
+ authentication_passwords = authentication_mode.get("Passwords", [])
+
+ if passwords and authentication_passwords:
+ raise InvalidParameterCombinationException(
+ "Passwords provided via multiple arguments. Use only one argument"
+ )
+
+ # if passwords is empty, then we can use the authentication_passwords
+ passwords = passwords if passwords else authentication_passwords
+
if any([len(p) < 16 for p in passwords]):
raise PasswordTooShort
+
access_string = params.get("AccessString")
user = self.elasticache_backend.create_user(
user_id=user_id, # type: ignore[arg-type]
@@ -35,6 +69,7 @@ def create_user(self) -> str:
passwords=passwords,
access_string=access_string, # type: ignore[arg-type]
no_password_required=no_password_required,
+ authentication_type=authentication_type,
)
template = self.response_template(CREATE_USER_TEMPLATE)
return template.render(user=user)
@@ -167,7 +202,9 @@ def list_tags_for_resource(self) -> str:
{% if user.no_password_required %}
no-password
{% else %}
- password
+ {{ user.authentication_type }}
+ {% endif %}
+ {% if user.passwords %}
{{ user.passwords|length }}
{% endif %}
diff --git a/moto/elasticache/utils.py b/moto/elasticache/utils.py
index f0ac58c2001d..9377e997d2b1 100644
--- a/moto/elasticache/utils.py
+++ b/moto/elasticache/utils.py
@@ -1,3 +1,5 @@
+from enum import Enum
+
PAGINATION_MODEL = {
"describe_cache_clusters": {
"input_token": "marker",
@@ -6,3 +8,9 @@
"unique_attribute": "cache_cluster_id",
},
}
+
+
+class AuthenticationTypes(str, Enum):
+ NOPASSWORD = "no-password-required"
+ PASSWORD = "password"
+ IAM = "iam"
diff --git a/tests/test_elasticache/test_elasticache.py b/tests/test_elasticache/test_elasticache.py
index d7b47934b64a..79c387d5a67a 100644
--- a/tests/test_elasticache/test_elasticache.py
+++ b/tests/test_elasticache/test_elasticache.py
@@ -1,6 +1,6 @@
import boto3
import pytest
-from botocore.exceptions import ClientError
+from botocore.exceptions import ClientError, ParamValidationError
from moto import mock_aws
from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
@@ -89,10 +89,279 @@ def test_create_user_without_password():
assert err["Code"] == "InvalidParameterValue"
assert (
err["Message"]
- == "No password was provided. If you want to create/update the user without password, please use the NoPasswordRequired flag."
+ == "Input Authentication type: null is not in the allowed list: [password,no-password-required,iam]"
)
+@mock_aws
+def test_create_user_with_iam():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ user_id = "user1"
+ resp = client.create_user(
+ UserId=user_id,
+ UserName="User1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ AuthenticationMode={"Type": "iam"},
+ )
+
+ assert resp["Status"] == "active"
+ assert resp["Engine"] == "Redis"
+ assert resp["AccessString"] == "on ~* +@all"
+ assert resp["UserGroupIds"] == []
+ assert resp["Authentication"]["Type"] == "iam"
+
+
+@mock_aws
+def test_create_user_invalid_authentication_type():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ClientError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="User1",
+ Engine="Redis",
+ AccessString="?",
+ AuthenticationMode={"Type": "invalidtype"},
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterValue"
+ assert (
+ err["Message"]
+ == "Input Authentication type: invalidtype is not in the allowed list: [password,no-password-required,iam]"
+ )
+
+
+@mock_aws
+def test_create_user_with_iam_with_passwords():
+ # IAM authentication mode should not come with password fields
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ClientError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="?",
+ AuthenticationMode={"Type": "iam"},
+ Passwords=["mysecretpassthatsverylong"],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterCombination"
+ assert (
+ err["Message"] == "Password field is not allowed with authentication type: iam"
+ )
+
+
+# handled by botocore
+@mock_aws
+def test_create_user_with_invalid_authmode_key():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ParamValidationError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="?",
+ AuthenticationMode={"invalidkey": ""},
+ )
+ assert (
+ exc.value.kwargs["report"]
+ == 'Unknown parameter in AuthenticationMode: "invalidkey", must be one of: Type, Passwords'
+ )
+
+
+# handled by botocore
+@mock_aws
+def test_create_user_with_empty_passwords_with_password():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ParamValidationError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="?",
+ Passwords=[],
+ AuthenticationMode={"Type": "password"},
+ )
+ assert (
+ exc.value.kwargs["report"]
+ == "Invalid length for parameter Passwords, value: 0, valid min length: 1"
+ )
+
+
+# handled by botocore
+@mock_aws
+def test_create_user_with_empty_passwords_with_authmode_password():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ParamValidationError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="?",
+ AuthenticationMode={"Type": "password", "Passwords": []},
+ )
+
+ assert (
+ exc.value.kwargs["report"]
+ == "Invalid length for parameter AuthenticationMode.Passwords, value: 0, valid min length: 1"
+ )
+
+
+@mock_aws
+def test_create_user_authmode_password_with_multiple_password_fields():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ClientError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ AuthenticationMode={"Type": "password", "Passwords": ["authmodepassword"]},
+ Passwords=["requestpassword"],
+ NoPasswordRequired=False,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterCombination"
+ assert (
+ err["Message"]
+ == "Passwords provided via multiple arguments. Use only one argument"
+ )
+
+
+@mock_aws
+def test_create_user_with_authmode_password_without_passwords():
+ client = boto3.client("elasticache", region_name="us-east-1")
+ with pytest.raises(ClientError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="?",
+ AuthenticationMode={"Type": "password"},
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterCombination"
+ assert (
+ err["Message"]
+ == "A user with Authentication Mode: password, must have at least one password"
+ )
+
+
+@mock_aws
+def test_create_user_with_authmode_no_password():
+ # AuthenticationMode should be either 'password' or 'iam' or 'no-password'
+ client = boto3.client("elasticache", region_name="us-east-1")
+ user_id = "user1"
+ resp = client.create_user(
+ UserId=user_id,
+ UserName="User1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ AuthenticationMode={"Type": "no-password-required"},
+ )
+
+ assert resp["Status"] == "active"
+ assert resp["Engine"] == "Redis"
+ assert resp["AccessString"] == "on ~* +@all"
+ assert resp["UserGroupIds"] == []
+ assert resp["Authentication"]["Type"] == "no-password-required"
+ assert (
+ "PasswordCount" not in resp["Authentication"]
+ ) # even though optional, we don't expect it for no-password-required
+
+
+@mock_aws
+def test_create_user_with_no_password_required_and_authmode_nopassword():
+ user_id = "user1"
+ client = boto3.client("elasticache", region_name="us-east-1")
+ resp = client.create_user(
+ UserId=user_id,
+ UserName="User1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ NoPasswordRequired=True,
+ AuthenticationMode={"Type": "no-password-required"},
+ )
+
+ assert resp["Status"] == "active"
+ assert resp["Engine"] == "Redis"
+ assert resp["AccessString"] == "on ~* +@all"
+ assert resp["UserGroupIds"] == []
+ assert resp["Authentication"]["Type"] == "no-password"
+ assert (
+ "PasswordCount" not in resp["Authentication"]
+ ) # even though optional, we don't expect it for no-password-required
+
+
+@mock_aws
+def test_create_user_with_no_password_required_and_authmode_different():
+ for auth_mode in ["password", "iam"]:
+ client = boto3.client("elasticache", region_name="ap-southeast-1")
+ with pytest.raises(ClientError) as exc:
+ client.create_user(
+ UserId="user1",
+ UserName="user1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ NoPasswordRequired=True,
+ AuthenticationMode={"Type": auth_mode},
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterCombination"
+ assert (
+ err["Message"]
+ == f"No password required flag is true but provided authentication type is {auth_mode}"
+ )
+
+
+@mock_aws
+def test_create_user_with_authmode_password():
+ # AuthenticationMode should be either 'password' or 'iam' or 'no-password'
+ client = boto3.client("elasticache", region_name="us-east-1")
+ user_id = "user1"
+ resp = client.create_user(
+ UserId=user_id,
+ UserName="User1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ AuthenticationMode={
+ "Type": "password",
+ "Passwords": ["mysecretpassthatsverylong"],
+ },
+ )
+
+ assert resp["Status"] == "active"
+ assert resp["Engine"] == "Redis"
+ assert resp["AccessString"] == "on ~* +@all"
+ assert resp["UserGroupIds"] == []
+ assert resp["Authentication"]["Type"] == "password"
+ assert resp["Authentication"]["PasswordCount"] == 1
+
+
+@mock_aws
+def test_create_user_with_authmode_password_multiple():
+ # AuthenticationMode should be either 'password' or 'iam' or 'no-password'
+ client = boto3.client("elasticache", region_name="us-east-1")
+ user_id = "user1"
+ resp = client.create_user(
+ UserId=user_id,
+ UserName="User1",
+ Engine="Redis",
+ AccessString="on ~* +@all",
+ AuthenticationMode={
+ "Type": "password",
+ "Passwords": ["mysecretpassthatsverylong", "mysecretpassthatsverylong2"],
+ },
+ )
+
+ assert resp["Status"] == "active"
+ assert resp["Engine"] == "Redis"
+ assert resp["AccessString"] == "on ~* +@all"
+ assert resp["UserGroupIds"] == []
+ assert resp["Authentication"]["Type"] == "password"
+ assert resp["Authentication"]["PasswordCount"] == 2
+
+
@mock_aws
def test_create_user_twice():
client = boto3.client("elasticache", region_name="ap-southeast-1")