From 3e3d00f29d23ec4921d4746a8e5ab38cae7df9a2 Mon Sep 17 00:00:00 2001 From: Jonathan Lim Date: Tue, 7 Jan 2025 19:22:38 -0500 Subject: [PATCH 1/3] ElastiCache: initial tests for create_user authmode --- tests/test_elasticache/test_elasticache.py | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_elasticache/test_elasticache.py b/tests/test_elasticache/test_elasticache.py index d7b47934b64a..504c99fca27f 100644 --- a/tests/test_elasticache/test_elasticache.py +++ b/tests/test_elasticache/test_elasticache.py @@ -92,6 +92,47 @@ def test_create_user_without_password(): == "No password was provided. If you want to create/update the user without password, please use the NoPasswordRequired flag." ) +@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["UserId"] == user_id + assert resp["UserName"] == "User1" + assert resp["Status"] == "active" + assert resp["Engine"] == "Redis" + assert resp["MinimumEngineVersion"] == "6.0" + assert resp["AccessString"] == "on ~* +@all" + assert resp["UserGroupIds"] == [] + assert resp["Authentication"]["Type"] == "iam" + +@mock_aws +def test_create_user_with_iam_no_password(): + # IAM authentication mode should not come with password fields + assert False + +@mock_aws +def test_create_user_with_invalid_authmode(): + # AuthenticationMode should be either 'password' or 'iam' or 'no-password' + assert False + +@mock_aws +def test_create_user_with_authmode_no_password(): + # AuthenticationMode should be either 'password' or 'iam' or 'no-password' + assert False + +@mock_aws +def test_create_user_with_authmode_password(): + assert False @mock_aws def test_create_user_twice(): From fed7452a824fb111301ee159fb5063699f29cf3c Mon Sep 17 00:00:00 2001 From: Jonathan Lim Date: Wed, 8 Jan 2025 15:26:32 -0500 Subject: [PATCH 2/3] Elasticache: Add tests for authenticationmode --- tests/test_elasticache/test_elasticache.py | 96 +++++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/tests/test_elasticache/test_elasticache.py b/tests/test_elasticache/test_elasticache.py index 504c99fca27f..a8a77cf22486 100644 --- a/tests/test_elasticache/test_elasticache.py +++ b/tests/test_elasticache/test_elasticache.py @@ -92,6 +92,7 @@ def test_create_user_without_password(): == "No password was provided. If you want to create/update the user without password, please use the NoPasswordRequired flag." ) + @mock_aws def test_create_user_with_iam(): client = boto3.client("elasticache", region_name="us-east-1") @@ -101,38 +102,109 @@ def test_create_user_with_iam(): UserName="User1", Engine="Redis", AccessString="on ~* +@all", - AuthenticationMode={ - "Type": "iam" - } + AuthenticationMode={"Type": "iam"}, ) - assert resp["UserId"] == user_id - assert resp["UserName"] == "User1" assert resp["Status"] == "active" assert resp["Engine"] == "Redis" - assert resp["MinimumEngineVersion"] == "6.0" assert resp["AccessString"] == "on ~* +@all" assert resp["UserGroupIds"] == [] assert resp["Authentication"]["Type"] == "iam" + assert resp["Authentication"]["PasswordCount"] == 0 + @mock_aws -def test_create_user_with_iam_no_password(): +def test_create_user_with_iam_with_passwords(): # IAM authentication mode should not come with password fields - assert False + 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" + ) @mock_aws def test_create_user_with_invalid_authmode(): # AuthenticationMode should be either 'password' or 'iam' or 'no-password' - assert False + 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"}, Passwords=["mysecretpassthatsverylong"],) + err = exc.value.response["Error"] + assert err["Code"] == "ParamValidationError" + 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_invalid_authmode_key(): + 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={"invalidkey": ""}) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParameterValueException" + assert ( + err["Message"] + == "Unknown parameter in AuthenticationMode: \"invalidkey\", must be one of: Type, Passwords" + ) @mock_aws -def test_create_user_with_authmode_no_password(): +def test_create_user_with_authmode_no_password(): # AuthenticationMode should be either 'password' or 'iam' or 'no-password' - assert False + 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 resp["Authentication"]["PasswordCount"] == 0 + @mock_aws def test_create_user_with_authmode_password(): - assert False + # 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": ['mysecretpass']}, + ) + + 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"] == 0 + + +@mock_aws +def test_create_user_with_authmode_password_without_password(): + 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"] == "InvalidParameterCombinationException" + assert ( + err["Message"] + == "A user with Authentication Mode: password, must have at least one password" + ) + @mock_aws def test_create_user_twice(): From 8668e76c3690c16b2866b9fa4280288b865a706b Mon Sep 17 00:00:00 2001 From: Jonathan Lim Date: Thu, 9 Jan 2025 17:11:24 -0500 Subject: [PATCH 3/3] Elasticache: Add authenticationmode to create_user --- moto/elasticache/exceptions.py | 20 ++ moto/elasticache/models.py | 33 +++- moto/elasticache/responses.py | 49 ++++- moto/elasticache/utils.py | 8 + tests/test_elasticache/test_elasticache.py | 208 ++++++++++++++++++--- 5 files changed, 285 insertions(+), 33 deletions(-) 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 a8a77cf22486..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,7 +89,7 @@ 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]" ) @@ -110,7 +110,25 @@ def test_create_user_with_iam(): assert resp["AccessString"] == "on ~* +@all" assert resp["UserGroupIds"] == [] assert resp["Authentication"]["Type"] == "iam" - assert resp["Authentication"]["PasswordCount"] == 0 + + +@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 @@ -118,39 +136,117 @@ 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"],) + 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" + err["Message"] == "Password field is not allowed with authentication type: iam" ) + +# handled by botocore @mock_aws -def test_create_user_with_invalid_authmode(): - # AuthenticationMode should be either 'password' or 'iam' or 'no-password' +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="?", AuthenticationMode={"Type": "invalidtype"}, Passwords=["mysecretpassthatsverylong"],) + 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"] == "ParamValidationError" + assert err["Code"] == "InvalidParameterCombination" assert ( err["Message"] - == "Input Authentication type: invalidtype is not in the allowed list: [password,no-password-required,iam]" + == "Passwords provided via multiple arguments. Use only one argument" ) + @mock_aws -def test_create_user_with_invalid_authmode_key(): +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={"invalidkey": ""}) + client.create_user( + UserId="user1", + UserName="user1", + Engine="Redis", + AccessString="?", + AuthenticationMode={"Type": "password"}, + ) err = exc.value.response["Error"] - assert err["Code"] == "InvalidParameterValueException" + assert err["Code"] == "InvalidParameterCombination" assert ( err["Message"] - == "Unknown parameter in AuthenticationMode: \"invalidkey\", must be one of: Type, Passwords" + == "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' @@ -169,7 +265,53 @@ def test_create_user_with_authmode_no_password(): assert resp["AccessString"] == "on ~* +@all" assert resp["UserGroupIds"] == [] assert resp["Authentication"]["Type"] == "no-password-required" - assert resp["Authentication"]["PasswordCount"] == 0 + 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 @@ -182,7 +324,10 @@ def test_create_user_with_authmode_password(): UserName="User1", Engine="Redis", AccessString="on ~* +@all", - AuthenticationMode={"Type": "password", "Passwords": ['mysecretpass']}, + AuthenticationMode={ + "Type": "password", + "Passwords": ["mysecretpassthatsverylong"], + }, ) assert resp["Status"] == "active" @@ -190,21 +335,32 @@ def test_create_user_with_authmode_password(): assert resp["AccessString"] == "on ~* +@all" assert resp["UserGroupIds"] == [] assert resp["Authentication"]["Type"] == "password" - assert resp["Authentication"]["PasswordCount"] == 0 + assert resp["Authentication"]["PasswordCount"] == 1 @mock_aws -def test_create_user_with_authmode_password_without_password(): +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") - 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"] == "InvalidParameterCombinationException" - assert ( - err["Message"] - == "A user with Authentication Mode: password, must have at least one password" + 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():