diff --git a/.config/dictionaries/project.txt b/.config/dictionaries/project.txt index e69de29..2dc5e93 100644 --- a/.config/dictionaries/project.txt +++ b/.config/dictionaries/project.txt @@ -0,0 +1,11 @@ +abstractmethod +awscurrent +awspending +boto +classmethod +localstack +mypy +powertools +pydantic +pytest +rotatation diff --git a/.config/dictionaries/python.txt b/.config/dictionaries/python.txt index e69de29..533cec3 100644 --- a/.config/dictionaries/python.txt +++ b/.config/dictionaries/python.txt @@ -0,0 +1,9 @@ +dists +doctest +hoverkraft +junitxml +pycache +pypa +pypi +pytest +setuptools diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..855c710 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,53 @@ +--- +name: Publish Python 🐍 distribution πŸ“¦ to PyPI + +# yamllint disable-line rule:truthy +on: + release: + types: [published] + +jobs: + build: + name: Build distribution πŸ“¦ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: 0 + - name: Set up Python 3.10 + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + with: + python-version: "3.10" + cache: 'pip' + cache-dependency-path: setup.cfg + - name: Install pypa/build + run: >- + python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution πŸ“¦ to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/rds-proxy-password-rotation # Replace with your PyPI project name + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution πŸ“¦ to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..c8c734c --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,69 @@ +--- +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python build + +# yamllint disable-line rule:truthy +on: + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: setup.cfg + + - name: Install dependencies + run: | + python -m pip install -e "." + + - uses: astral-sh/ruff-action@e6390afda04da2e9ef69fe1e2ae0264164550c21 # v3.0.1 + name: Lint on ${{ matrix.python-version }} + with: + args: "check" + # renovate: datasource=github-releases depName=astral-sh/ruff + version: "0.8.6" + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: setup.cfg + + - name: Install dependencies + run: | + python -m pip install -e ".[test]" + + - name: Run docker-compose + uses: hoverkraft-tech/compose-action@v2.0.1 + with: + compose-file: "./tests/docker-compose.yml" + + - name: Test on ${{ matrix.python-version }} + run: | + pytest -v --doctest-modules --junitxml=junit/test-results.xml --cov-report=xml --cov-report=html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3dc1b27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.egg-info +__pycache__/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/aws.xml b/.idea/aws.xml new file mode 100644 index 0000000..de54e2d --- /dev/null +++ b/.idea/aws.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_blame.xml b/.idea/git_toolbox_blame.xml new file mode 100644 index 0000000..7dc1249 --- /dev/null +++ b/.idea/git_toolbox_blame.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..760bdec --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e72d3a4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/rds-proxy-password-rotation.iml b/.idea/rds-proxy-password-rotation.iml new file mode 100644 index 0000000..572a535 --- /dev/null +++ b/.idea/rds-proxy-password-rotation.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 47bff4e..ec3e7eb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ # rds-proxy-password-rotation -Python script to rotate the RDS credentials using a separate credential for the application. + +:warning: **Work in progress** :warning: + +- implement step CREATE_SECRET +- implement step SET_SECRET +- implement step TEST_SECRET +- implement step FINISH_SECRET +- add Terraform module + +Python script for multi-user password rotation using RDS and RDS proxy. It supports a separate credential for the application. + +## Pre-requisites + +1. Python 3.10 or later +2. For each db user: + 1. Create a secret in AWS Secrets Manager with the following key-value pairs: + - `username`: The username for the user + - `password`: The password for the user + This credential will be used by the application to connect to the proxy. You may add additional key-value pairs as needed. + 2. Clone the user in the database and grant the necessary permissions. We suggest to add a `-clone` suffix to the username. + 3. Create two secrets (for the original user and the clone) in AWS Secrets Manager with the following key-value pairs: + - `username`: The username for the user + - `password`: The password for the user + These credentials are used by the proxy to connect to the database. You may add additional key-value pairs as needed. + +## Architecture + +![Architecture](assets/architecture.png) + +## Challenges with RDS and RDS Proxy + +RDS Proxy is a fully managed, highly available database proxy for Amazon Relational Database Service (RDS) that makes applications +more scalable, more resilient to database failures, and more secure. It allows applications to pool and share database connections +to improve efficiency and reduce the load on your database instances. + +However, RDS Proxy does not support multi-user password rotation out of the box. This script provides a solution to this problem. + +Using an RDS Proxy requires a secret in AWS Secrets Manager with the credentials to connect to the database. This secret is used by +the proxy to connect to the database. The proxy allows the application to connect to the database using the same credentials and +then forwards the requests to the database with the same credentials. This means that the credentials in the secret must be valid +in the database at all times. But what if you want to rotate the password for the user that the proxy uses to connect to the +database? You can’t just update the secret in SecretsManager because the proxy will stop working as soon as the secret is updated. +And you can’t just update the password in the database because the proxy will stop working as soon as the password is updated. + +## Why password rotation is a good practice + +Password rotation is a good idea for several reasons: + +1. **Enhanced Security**: Regularly changing passwords reduces the risk of unauthorized access due to compromised credentials. +2. **Mitigates Risk**: Limits the time window an attacker has to exploit a stolen password. +3. **Compliance**: Many regulatory standards and security policies require periodic password changes. +4. **Reduces Impact of Breaches**: If a password is compromised, rotating it ensures that the compromised password is no longer valid. +5. **Encourages Good Practices**: Promotes the use of strong, unique passwords and discourages password reuse. diff --git a/assets/architecture.png b/assets/architecture.png new file mode 100644 index 0000000..05644ac Binary files /dev/null and b/assets/architecture.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..58198fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ['setuptools==75.6.0'] +build-backend = 'setuptools.build_meta' diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..92379ec --- /dev/null +++ b/setup.cfg @@ -0,0 +1,40 @@ +[metadata] +name = rds-proxy-password-rotatation +version = 1.0.0 +author = Hapag-Lloyd AG +author_email = info@hlag.com +description = A program to rotate the password of an RDS database accessed via a RDS proxy +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/Hapag-Lloyd/rds-proxy-password-rotation +project_urls = + Bug Tracker = https://github.com/Hapag-Lloyd/rds-proxy-password-rotation/issues + repository = https://github.com/Hapag-Lloyd/rds-proxy-password-rotation +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.10 +install_requires = + aws-lambda-powertools==3.4.0 + boto3==1.35.83 + boto3-stubs[secretsmanager]==1.35.83 + dependency-injector==4.44.0 + pydantic==2.10.4 + +[options.extras_require] +test = + pytest==8.3.4 + pytest-cov==6.0.0 + uuid==1.30 + + # updates an outdated dependency of local setup + nose==1.3.7 + +[options.packages.find] +where = src diff --git a/src/rds_proxy_password_rotatation/adapter/aws_lambda_function.py b/src/rds_proxy_password_rotatation/adapter/aws_lambda_function.py new file mode 100644 index 0000000..5cf019b --- /dev/null +++ b/src/rds_proxy_password_rotatation/adapter/aws_lambda_function.py @@ -0,0 +1,27 @@ +from dependency_injector.wiring import inject, Provide + +from aws_lambda_powertools.utilities.parser import event_parser +from aws_lambda_powertools.utilities.typing import LambdaContext + +from rds_proxy_password_rotatation.adapter.aws_lambda_function_model import AwsSecretManagerRotationEvent +from rds_proxy_password_rotatation.adapter.container import Container +from rds_proxy_password_rotatation.password_rotation_application import PasswordRotationApplication + + +container = None + +@event_parser(model=AwsSecretManagerRotationEvent) +def lambda_handler(event: AwsSecretManagerRotationEvent, context: LambdaContext) -> None: + global container + + if container is None: + container = Container() + container.config.api_key.from_env("API_KEY", required=True) + container.config.timeout.from_env("TIMEOUT", as_=int, default=5) + container.wire(modules=[__name__]) + + __call_application(event) + +@inject +def __call_application(event: AwsSecretManagerRotationEvent, application: PasswordRotationApplication = Provide[Container.password_rotation_application]) -> None: + application.rotate_secret(event.step.to_rotation_step(), event.secret_id) diff --git a/src/rds_proxy_password_rotatation/adapter/aws_lambda_function_model.py b/src/rds_proxy_password_rotatation/adapter/aws_lambda_function_model.py new file mode 100644 index 0000000..095ac7a --- /dev/null +++ b/src/rds_proxy_password_rotatation/adapter/aws_lambda_function_model.py @@ -0,0 +1,43 @@ +from enum import Enum + +from pydantic import BaseModel, Field + +from rds_proxy_password_rotatation.model import RotationStep + + +class AwsRotationStep(Enum): + CREATE_SECRET = "create_secret" + """Create a new version of the secret""" + SET_SECRET = "set_secret" + """Change the credentials in the database or service""" + TEST_SECRET = "test_secret" + """Test the new secret version""" + FINISH_SECRET = "finish_secret" + """Finish the rotation""" + + def to_rotation_step(self) -> RotationStep: + match self: + case AwsRotationStep.CREATE_SECRET: + return RotationStep.CREATE_SECRET + case AwsRotationStep.SET_SECRET: + return RotationStep.SET_SECRET + case AwsRotationStep.TEST_SECRET: + return RotationStep.TEST_SECRET + case AwsRotationStep.FINISH_SECRET: + return RotationStep.FINISH_SECRET + case _: + raise ValueError(f"Invalid rotation step: {self.value}") + + +class AwsSecretManagerRotationEvent(BaseModel): + step: AwsRotationStep = Field(alias='Step') + """The rotation step: create_secret, set_secret, test_secret, or finish_secret. For more information, see Four steps in a rotation function.""" + + secret_id: str = Field(alias='SecretId') + """The ARN of the secret to rotate.""" + + client_request_token: str = Field(alias='ClientRequestToken') + """A unique identifier for the new version of the secret. This value helps ensure idempotency. For more information, see PutSecretValue: ClientRequestToken in the AWS Secrets Manager API Reference.""" + + rotation_token: str = Field(alias='RotationToken') + """A unique identifier that indicates the source of the request. Required for secret rotation using an assumed role or cross-account rotation, in which you rotate a secret in one account by using a Lambda rotation function in another account. In both cases, the rotation function assumes an IAM role to call Secrets Manager and then Secrets Manager uses the rotation token to validate the IAM role identity.""" diff --git a/src/rds_proxy_password_rotatation/adapter/aws_secrets_manager.py b/src/rds_proxy_password_rotatation/adapter/aws_secrets_manager.py new file mode 100644 index 0000000..78dd25c --- /dev/null +++ b/src/rds_proxy_password_rotatation/adapter/aws_secrets_manager.py @@ -0,0 +1,31 @@ +from aws_lambda_powertools import Logger +from mypy_boto3_secretsmanager.client import SecretsManagerClient + +from rds_proxy_password_rotatation.services import SecretsManagerService + + +class AwsSecretsManagerService(SecretsManagerService): + def __init__(self, secretsmanager_client: SecretsManagerClient, logger: Logger): + self.client = secretsmanager_client + self.logger = logger + + def is_rotation_enabled(self, secret_id: str) -> bool: + metadata = self.client.describe_secret(SecretId=secret_id) + + return 'RotationEnabled' in metadata and metadata['RotationEnabled'] + + def ensure_valid_secret_state(self, secret_id: str, token: str) -> bool: + metadata = self.client.describe_secret(SecretId=secret_id) + versions = metadata['VersionIdsToStages'] + + if token not in versions: + self.logger.error("Secret version %s has no stage for rotation of secret %s." % (token, secret_id)) + raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, secret_id)) + elif "AWSCURRENT" in versions[token]: + self.logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, secret_id)) + return False + elif "AWSPENDING" not in versions[token]: + self.logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, secret_id)) + raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, secret_id)) + else: + return True diff --git a/src/rds_proxy_password_rotatation/adapter/container.py b/src/rds_proxy_password_rotatation/adapter/container.py new file mode 100644 index 0000000..86b6326 --- /dev/null +++ b/src/rds_proxy_password_rotatation/adapter/container.py @@ -0,0 +1,27 @@ +import boto3 +from aws_lambda_powertools import Logger +from dependency_injector import containers, providers + +from rds_proxy_password_rotatation.adapter.aws_secrets_manager import AwsSecretsManagerService +from rds_proxy_password_rotatation.password_rotation_application import PasswordRotationApplication + + +class Container(containers.DeclarativeContainer): + config = providers.Configuration() + + logger = providers.Singleton( + Logger, + ) + + boto3_secrets_manager = boto3.client(service_name='secretsmanager', region_name='eu-central-1') + + secrets_manager = providers.Singleton( + AwsSecretsManagerService, + boto3_secrets_manager=boto3_secrets_manager, + ) + + password_rotation_application = providers.Singleton( + PasswordRotationApplication, + secrets_manager=secrets_manager, + logger=logger, + ) diff --git a/src/rds_proxy_password_rotatation/model.py b/src/rds_proxy_password_rotatation/model.py new file mode 100644 index 0000000..2e74000 --- /dev/null +++ b/src/rds_proxy_password_rotatation/model.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class RotationStep(Enum): + CREATE_SECRET = "create_secret" + """Create a new version of the secret""" + SET_SECRET = "set_secret" + """Change the credentials in the database or service""" + TEST_SECRET = "test_secret" + """Test the new secret version""" + FINISH_SECRET = "finish_secret" + """Finish the rotation""" diff --git a/src/rds_proxy_password_rotatation/password_rotation_application.py b/src/rds_proxy_password_rotatation/password_rotation_application.py new file mode 100644 index 0000000..1965029 --- /dev/null +++ b/src/rds_proxy_password_rotatation/password_rotation_application.py @@ -0,0 +1,36 @@ +from enum import Enum + +from aws_lambda_powertools import Logger + +from rds_proxy_password_rotatation.model import RotationStep +from rds_proxy_password_rotatation.services import SecretsManagerService + + +class PasswordRotationResult(Enum): + NOTHING_TO_ROTATE = "nothing_to_rotate" + + +class PasswordRotationApplication: + def __init__(self, secrets_manager: SecretsManagerService, logger: Logger): + self.secrets_manager = secrets_manager + self.logger = logger + + def rotate_secret(self, step: RotationStep, secret_id: str, token: str) -> PasswordRotationResult: + if not self.secrets_manager.is_rotation_enabled(secret_id): + self.logger.warning("Rotation is not enabled for the secret %s", secret_id) + return PasswordRotationResult.NOTHING_TO_ROTATE + + if not self.secrets_manager.ensure_valid_secret_state(secret_id, token): + return PasswordRotationResult.NOTHING_TO_ROTATE + + match step: + case RotationStep.CREATE_SECRET: + pass + case RotationStep.SET_SECRET: + pass + case RotationStep.TEST_SECRET: + pass + case RotationStep.FINISH_SECRET: + pass + case _: + raise ValueError(f"Invalid rotation step: {step}") diff --git a/src/rds_proxy_password_rotatation/services.py b/src/rds_proxy_password_rotatation/services.py new file mode 100644 index 0000000..f5cd948 --- /dev/null +++ b/src/rds_proxy_password_rotatation/services.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +class SecretsManagerService(ABC): + @abstractmethod + def is_rotation_enabled(self, secret_id: str) -> bool: + pass + + @abstractmethod + def ensure_valid_secret_state(self, secret_id: str, token: str) -> bool: + pass diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 0000000..9bf8d5b --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,17 @@ +--- +# https://qxf2.com/blog/testing-aws-lambda-locally-using-localstack-and-pytest/ +services: + localstack: + container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}" + image: localstack/localstack:4.0.3 + ports: + - "127.0.0.1:4566:4566" + - "127.0.0.1:4510-4559:4510-4559" + - "0.0.0.0:8543:443" + security_opt: + - "label=disable" + environment: + - DEBUG=${DEBUG:-0} + - PERSISTENCE=${PERSISTENCE:-0} + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" diff --git a/tests/lambda_function.zip b/tests/lambda_function.zip new file mode 100644 index 0000000..1bc401a Binary files /dev/null and b/tests/lambda_function.zip differ diff --git a/tests/rds_proxy_password_rotatation/adapter/test_aws_lambda_function_model_unit.py b/tests/rds_proxy_password_rotatation/adapter/test_aws_lambda_function_model_unit.py new file mode 100644 index 0000000..2528022 --- /dev/null +++ b/tests/rds_proxy_password_rotatation/adapter/test_aws_lambda_function_model_unit.py @@ -0,0 +1,41 @@ +from unittest import TestCase + +from rds_proxy_password_rotatation.adapter.aws_lambda_function_model import AwsRotationStep +from rds_proxy_password_rotatation.model import RotationStep + +class TestAwsRotationStep(TestCase): + def test_should_return_rotation_step_when_to_rotation_step_given_create_secret(self): + # Given + + # When + rotation_step = AwsRotationStep.CREATE_SECRET.to_rotation_step() + + # Then + self.assertEqual(rotation_step, RotationStep.CREATE_SECRET) + + def test_should_return_rotation_step_when_to_rotation_step_given_set_secret(self): + # Given + + # When + rotation_step = AwsRotationStep.SET_SECRET.to_rotation_step() + + # Then + self.assertEqual(rotation_step, RotationStep.SET_SECRET) + + def test_should_return_rotation_step_when_to_rotation_step_given_test_secret(self): + # Given + + # When + rotation_step = AwsRotationStep.TEST_SECRET.to_rotation_step() + + # Then + self.assertEqual(rotation_step, RotationStep.TEST_SECRET) + + def test_should_return_rotation_step_when_to_rotation_step_given_finish_secret(self): + # Given + + # When + rotation_step = AwsRotationStep.FINISH_SECRET.to_rotation_step() + + # Then + self.assertEqual(rotation_step, RotationStep.FINISH_SECRET) diff --git a/tests/rds_proxy_password_rotatation/adapter/test_aws_secrets_manager_infra.py b/tests/rds_proxy_password_rotatation/adapter/test_aws_secrets_manager_infra.py new file mode 100644 index 0000000..8fd6382 --- /dev/null +++ b/tests/rds_proxy_password_rotatation/adapter/test_aws_secrets_manager_infra.py @@ -0,0 +1,114 @@ +import uuid +from unittest import TestCase +from unittest.mock import Mock + +import boto3 +import os + +from aws_lambda_powertools import Logger + +from rds_proxy_password_rotatation.adapter.aws_secrets_manager import AwsSecretsManagerService + +class TestAwsSecretsManagerService(TestCase): + __secret_name_without_rotation = f'secret_without_rotation_enabled_{uuid.uuid4()}' + __secret_name_with_rotation = f'secret_with_rotation_enabled_{uuid.uuid4()}' + + __test_path = os.path.join(os.path.dirname(__file__), '..', '..') + + @classmethod + def setUpClass(cls): + secret_value = { + 'username': 'admin', + 'password': 'admin' + } + + cls.secretsmanager = boto3.client(service_name='secretsmanager', endpoint_url='http://localhost:4566', aws_access_key_id='test', + aws_secret_access_key='test', region_name='eu-central-1') + + # secret without rotation + cls.secretsmanager.create_secret( + Name=cls.__secret_name_without_rotation + ) + cls.secretsmanager.put_secret_value( + SecretId=cls.__secret_name_without_rotation, + SecretString=str(secret_value) + ) + + # secret with rotation + cls.s3_client = boto3.client('s3', endpoint_url='http://localhost:4566', aws_access_key_id='test', + aws_secret_access_key='test', region_name='eu-central-1') + + cls.s3_client.create_bucket(Bucket='s3bucket', CreateBucketConfiguration={'LocationConstraint': 'eu-central-1'}) + cls.s3_client.upload_file(os.path.join(cls.__test_path, 'lambda_function.zip'), 's3bucket', 'function.zip') + + cls.lambda_client = boto3.client('lambda', endpoint_url='http://localhost:4566', aws_access_key_id='test', + aws_secret_access_key='test', region_name='eu-central-1') + + rotation_function = cls.lambda_client.create_function( + Code={ + 'S3Bucket': 's3bucket', + 'S3Key': 'function.zip', + }, + Description='Dummy function', + FunctionName='function_name', + Handler='lambda.handler', + Publish=True, + Role='arn:aws:iam::123456789012:role/lambda-role', + Runtime='python3.10', + ) + cls.lambda_client.add_permission( + FunctionName='function_name', + Action='lambda:InvokeFunction', + StatementId='1', + Principal='secretsmanager.amazonaws.com', + ) + + secret = cls.secretsmanager.create_secret( + Name=cls.__secret_name_with_rotation + ) + cls.secretsmanager.put_secret_value( + SecretId=cls.__secret_name_with_rotation, + SecretString=str(secret_value) + ) + + cls.secretsmanager.rotate_secret( + SecretId=secret['ARN'], + RotationLambdaARN=rotation_function['FunctionArn'], + RotationRules={ + 'AutomaticallyAfterDays': 123, + 'Duration': '3h', + 'ScheduleExpression': 'rate(10 days)' + }, + ) + + @classmethod + def tearDownClass(cls): + cls.secretsmanager.delete_secret( + SecretId=cls.__secret_name_without_rotation, + ) + cls.secretsmanager.delete_secret( + SecretId=cls.__secret_name_with_rotation, + ) + + cls.lambda_client.delete_function(FunctionName='function_name') + + cls.s3_client.delete_object(Bucket='s3bucket', Key='function.zip') + cls.s3_client.delete_bucket(Bucket='s3bucket') + + def test_should_return_false_when_is_rotation_enabled_given_secret_has_rotation_disabled(self): + # Given + + # When + result = AwsSecretsManagerService(self.secretsmanager, Mock(spec=Logger)).is_rotation_enabled(self.__secret_name_without_rotation) + + # Then + self.assertFalse(result) + + def test_should_return_true_when_is_rotation_enabled_given_secret_has_rotation_enabled(self): + # Given + + # When + result = AwsSecretsManagerService(self.secretsmanager, Mock(spec=Logger)).is_rotation_enabled(self.__secret_name_with_rotation) + + # Then + self.assertTrue(result) diff --git a/tests/rds_proxy_password_rotatation/test_password_rotation_application_unit.py b/tests/rds_proxy_password_rotatation/test_password_rotation_application_unit.py new file mode 100644 index 0000000..11b68e2 --- /dev/null +++ b/tests/rds_proxy_password_rotatation/test_password_rotation_application_unit.py @@ -0,0 +1,40 @@ +from unittest import TestCase +from unittest.mock import Mock + +from aws_lambda_powertools import Logger + +from rds_proxy_password_rotatation.model import RotationStep +from rds_proxy_password_rotatation.password_rotation_application import PasswordRotationApplication, PasswordRotationResult +from rds_proxy_password_rotatation.services import SecretsManagerService + + +class TestPasswordRotationApplication(TestCase): + def test_should_do_nothing_when_rotate_secret_given_secret_has_rotation_disabled(self): + # Given + secrets_manager = Mock(spec=SecretsManagerService) + secrets_manager.is_rotation_enabled.return_value = False + + application = PasswordRotationApplication(secrets_manager, Mock(spec=Logger)) + + # When + result = application.rotate_secret(RotationStep.CREATE_SECRET, 'secret_id', 'token') + + # Then + self.assertEqual(result, PasswordRotationResult.NOTHING_TO_ROTATE) + + def test_should_log_warning_when_rotate_secret_given_secret_has_rotation_disabled(self): + # Given + secrets_manager = Mock(spec=SecretsManagerService) + secrets_manager.is_rotation_enabled.return_value = False + + logger = Mock(spec=Logger) + + application = PasswordRotationApplication(secrets_manager, logger) + + # When + application.rotate_secret(RotationStep.CREATE_SECRET, 'secret_id', 'token') + + # Then + logger.warning.assert_called() + +