diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..9a4e3d9 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,73 @@ +name: Python CI + +# Path-filtered so the Python package builds standalone: this workflow only +# runs when files under python/ (or this workflow itself) change. +on: + push: + branches: [main] + paths: + - "python/**" + - ".github/workflows/python.yml" + pull_request: + paths: + - "python/**" + - ".github/workflows/python.yml" + +defaults: + run: + working-directory: python + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package with dev dependencies + run: python -m pip install -e ".[dev]" + + # Lint and format are interpreter-independent, so they run once rather + # than across the whole test matrix. + - name: Ruff lint + run: python -m ruff check . + + - name: Ruff format + run: python -m ruff format --check . + + test: + name: py${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # The floor and the ceiling on each OS, plus Apple Silicon. Every version + # in requires-python is covered on Linux; the other platforms test the + # ends of the range, which is where interpreter differences show up. + os: [ubuntu-latest, windows-latest, macos-14] + python-version: ["3.10", "3.14"] + include: + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.12" + - os: ubuntu-latest + python-version: "3.13" + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev dependencies + run: python -m pip install -e ".[dev]" + + - name: Run tests + run: python -m pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84a6b4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# OS / editor cruft that could appear anywhere in the tree. +.DS_Store +.idea/ +*.iml +.vscode/ + +# Language-specific ignores live in python/.gitignore and java/.gitignore so +# each package stays self-contained. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..e8a6095 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +Developer Toolkit for Amazon ElastiCache +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..6fba1ed --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,25 @@ +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg + +# Test / coverage output +.coverage +.coverage.* +coverage.xml +htmlcov/ + +# Virtual envs +.venv/ +venv/ +env/ + +# OS / editor +.DS_Store diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python/NOTICE b/python/NOTICE new file mode 100644 index 0000000..e8a6095 --- /dev/null +++ b/python/NOTICE @@ -0,0 +1,2 @@ +Developer Toolkit for Amazon ElastiCache +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..aff497f --- /dev/null +++ b/python/README.md @@ -0,0 +1,121 @@ +# Developer Toolkit for Amazon ElastiCache (Python) + +This is a developer toolkit for working with Amazon ElastiCache, as a Python library and +CLI. +It provides a function to generate an [IAM authentication token](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) that Amazon ElastiCache requires as the +connection password for an IAM-enabled user. + +## Installation + +Requires Python 3.10–3.14, `pip`, and `git`. Install from the source repository: + +```bash +git clone https://github.com/aws/developer-toolkit-elasticache.git +cd developer-toolkit-elasticache/python +python3 -m pip install . +``` + +This installs the library and the `developer-toolkit-elasticache` command. + +## Usage + +To generate a usable token you need: + +- An Amazon ElastiCache serverless cache or replication group with the following: + - Valkey 7.2 and above or Redis OSS 7.0 and above + - In-transit Encryption (TLS) enabled + - IAM-enabled user that has access to to the cache. +- AWS credentials on the default credential chain (environment variables, shared + config files, or an instance/container role), for an identity with the + `elasticache:Connect` permission to the cache. + +### Generate a token + +```python +from developer_toolkit_elasticache import generate_iam_auth_token + +token = generate_iam_auth_token( + serverless_cache_name="my-cache", # or replication_group_id="my-group" + user_id="iam-user", + region="us-east-1", +) +``` +Use the returned token as the password when connecting to the cache. + +**Arguments** + +- `serverless_cache_name` or `replication_group_id` (string) [one required] +The serverless cache or node-based replication group to sign for. +- `user_id` (string) [required] +The IAM-enabled user to authenticate as. +- `region` (string) [optional] +The AWS region. Defaults to your AWS configuration (`AWS_REGION`, +`AWS_DEFAULT_REGION`, or the `region` in your profile, in that order). + +**Credentials** + +Credentials are resolved through the standard AWS credential provider chain +and are re-read on every call, so rotated credentials are picked up for every new token generation. +The chain is checked in this order: + +1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and + `AWS_SESSION_TOKEN`). +2. The shared credentials and config files (`~/.aws/credentials`, `~/.aws/config`), + selected by `AWS_PROFILE`, including any assume-role or SSO configuration. +3. Container credentials (Amazon ECS / EKS). +4. EC2 instance profile credentials (IMDS). + +### Reconnecting clients + +Clients such as redis-py and valkey-py request credentials on every reconnection. +Pass them an `ElastiCacheIAMAuthTokenProvider` and call `get_token()` when the +connection opens, so each connection uses a fresh token: + +```python +from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenProvider + +auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name="my-cache", + user_id="iam-user", + region="us-east-1", +) +username, password = auth.user_id, auth.get_token() +``` + +The [`examples/`](examples/) directory has redis-py and valkey-py integrations. + +### Command line + +```bash +developer-toolkit-elasticache generate_iam_auth_token \ + --serverless-cache-name my-cache \ + --user-id iam-user \ + --region us-east-1 +``` + +Add `--region` to sign for a specific region instead of the one resolved from your +AWS configuration + +For the command line, the token is written to stdout, so you can capture it +directly and pass it to a CLI client through an auth environment variable. +`REDISCLI_AUTH` works with both `redis-cli` and `valkey-cli`; `VALKEYCLI_AUTH` +works with `valkey-cli` 9.0.0 and later: + +```bash +export VALKEYCLI_AUTH=$(developer-toolkit-elasticache generate_iam_auth_token \ + --serverless-cache-name my-cache --user-id iam-user --region us-east-1) + +# Example: connecting via valkey-cli +valkey-cli --tls -h --user +``` + +## Security + +The token is a bearer credential. Keep it out of logs and shell history, and +connect over TLS. When using a CLI client, you can use the `REDISCLI_AUTH` / +`VALKEYCLI_AUTH` environment variable to pass the token more safely than the +`-a` / `--pass` flags, which expose it in the process list. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/python/examples/connect_redis.py b/python/examples/connect_redis.py new file mode 100644 index 0000000..cc55f78 --- /dev/null +++ b/python/examples/connect_redis.py @@ -0,0 +1,44 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Example: bridge the generic token engine into redis-py. + +ElastiCacheIAMAuthTokenProvider is client-agnostic — it only vends get_token(). This example +shows the small bridge you can write in your own code to hook it into redis-py's +CredentialProvider interface. +""" + +import redis +from redis.credentials import CredentialProvider + +from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenProvider + + +class ElastiCacheCredentialProvider(CredentialProvider): + """Customer-owned bridge from the generic token engine to redis-py.""" + + def __init__(self, auth: ElastiCacheIAMAuthTokenProvider): + self._auth = auth + + def get_credentials(self): + return self._auth.user_id, self._auth.get_token() + + +# serverless_cache_name is the ElastiCache cache NAME — used as the SigV4 signing +# host. +# For a node-based cluster, use replication_group_id="..." instead. +auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name="my-cache", + user_id="my-iam-user", + region="us-east-1", +) + +client = redis.Redis( + host="my-cache-abc123.serverless.use1.cache.amazonaws.com", # connection endpoint (DNS) + port=6379, + ssl=True, + credential_provider=ElastiCacheCredentialProvider(auth), +) + +client.set("hello", "world") +print(client.get("hello")) diff --git a/python/examples/connect_valkey.py b/python/examples/connect_valkey.py new file mode 100644 index 0000000..6171e4f --- /dev/null +++ b/python/examples/connect_valkey.py @@ -0,0 +1,44 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Example: bridge the generic token engine into valkey-py. + +ElastiCacheIAMAuthTokenProvider is client-agnostic — it only vends get_token(). This example +shows the small bridge you can write in your own code to hook it into valkey-py's +CredentialProvider interface. +""" + +import valkey +from valkey.credentials import CredentialProvider + +from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenProvider + + +class ElastiCacheCredentialProvider(CredentialProvider): + """Customer-owned bridge from the generic token engine to valkey-py.""" + + def __init__(self, auth: ElastiCacheIAMAuthTokenProvider): + self._auth = auth + + def get_credentials(self): + return self._auth.user_id, self._auth.get_token() + + +# serverless_cache_name is the ElastiCache cache NAME — used as the SigV4 signing +# host. +# For a node-based cluster, use replication_group_id="..." instead. +auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name="my-cache", + user_id="my-iam-user", + region="us-east-1", +) + +client = valkey.Valkey( + host="my-cache-abc123.serverless.use1.cache.amazonaws.com", # connection endpoint (DNS) + port=6379, + ssl=True, + credential_provider=ElastiCacheCredentialProvider(auth), +) + +client.set("hello", "world") +print(client.get("hello")) diff --git a/python/examples/generate_token.py b/python/examples/generate_token.py new file mode 100644 index 0000000..76bbd75 --- /dev/null +++ b/python/examples/generate_token.py @@ -0,0 +1,16 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generate a token without connecting to a cache.""" + +from developer_toolkit_elasticache import generate_iam_auth_token + +token = generate_iam_auth_token( + serverless_cache_name="my-cache", + user_id="my-user", + region="us-east-1", +) + +print("Token generated successfully!\n") +print(f"Token length: {len(token)} chars") +print(f"Token preview: {token[:80]}...") diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..033208f --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +# 77 is the first release that understands PEP 639: the bare SPDX expression in +# `license` below, and the default license-files glob that ships LICENSE/NOTICE in +# the wheel. Older setuptools only accepts the pre-PEP-639 table form +# (`license = {text = ...}`) and fails metadata generation outright. Nothing in +# normal development catches this, because pip's isolated build pulls the newest +# setuptools regardless of this floor — it only bites a pinned or vendored build. +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "developer-toolkit-elasticache" +version = "0.1.0.dev0" +description = "Developer toolkit for Amazon ElastiCache" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "botocore>=1.43.0", +] +# NOTE: This project is in active development and is NOT ready to publish. +# The "Private :: Do Not Upload" classifier makes PyPI reject any upload of +# this package. Do not remove it until we have explicitly decided to publish. +classifiers = [ + "Private :: Do Not Upload", + "Development Status :: 3 - Alpha", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.6", +] + +[tool.ruff] +# The floor in requires-python. Keeps ruff from suggesting syntax that would not +# run on the oldest interpreter we claim to support. +target-version = "py310" +line-length = 90 + +[tool.ruff.lint] +extend-select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "I", # isort + "INP", # flake8-no-pep420 (implicit namespace packages) + "PL", # pylint + "RUF", # ruff-specific rules + "S", # flake8-bandit (security) + "T20", # flake8-print + "UP", # pyupgrade + "W", # pycodestyle warnings +] + +# Every rule exempted below must be selected above, or the exemption is inert and +# the config only looks like it is enforcing something. +[tool.ruff.lint.per-file-ignores] +# A CLI's whole job is to write to stdout/stderr, so printing is intended here +# rather than lint debt. +"src/developer_toolkit_elasticache/cli.py" = ["T201"] +# Asserts and magic values are the point of a test, and tests are collected by path +# rather than imported as a package. S105 fires on the known-answer token strings, +# which are expected signed URLs to assert against, not secrets. +"tests/**/*" = ["S101", "PLR2004", "INP001", "S105"] +# The examples are illustrative scripts whose job is to print, and they are not an +# importable package. +"examples/**/*" = ["T201", "INP001"] + +[tool.pytest.ini_options] +# The examples/ scripts are runnable demos that need live AWS credentials and a +# reachable cache, and they do their work at import time, so collecting one would +# fail CI. Two independent guards, because testpaths alone is only a default and is +# silently overridden the moment anyone runs `pytest .` or `pytest `: +# 1. testpaths — bare `pytest` looks only at tests/ +# 2. norecursedirs — examples/ is skipped even when a path IS given +# Keep example filenames clear of test_*.py / *_test.py as well, since a matching +# name is what turned this into a CI failure in the first place. +testpaths = ["tests"] +norecursedirs = ["examples", "build", "dist", ".*"] + +[project.scripts] +developer-toolkit-elasticache = "developer_toolkit_elasticache.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +# PEP 561: py.typed tells a consumer's type checker that the annotations in this +# package are meant to be used. Without the marker they are ignored downstream no +# matter how completely the code is annotated, and it has to be declared here or it +# does not make it into the wheel. +[tool.setuptools.package-data] +developer_toolkit_elasticache = ["py.typed"] diff --git a/python/src/developer_toolkit_elasticache/__init__.py b/python/src/developer_toolkit_elasticache/__init__.py new file mode 100644 index 0000000..0f2b49b --- /dev/null +++ b/python/src/developer_toolkit_elasticache/__init__.py @@ -0,0 +1,22 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Developer Toolkit for Amazon ElastiCache.""" + +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, + ToolkitInputError, +) +from developer_toolkit_elasticache.token_generator import ( + ElastiCacheIAMAuthTokenProvider, + generate_iam_auth_token, +) + +__all__ = [ + "ConfigurationError", + "ElastiCacheIAMAuthTokenProvider", + "InvalidParameterError", + "ToolkitInputError", + "generate_iam_auth_token", +] diff --git a/python/src/developer_toolkit_elasticache/cli.py b/python/src/developer_toolkit_elasticache/cli.py new file mode 100644 index 0000000..146373d --- /dev/null +++ b/python/src/developer_toolkit_elasticache/cli.py @@ -0,0 +1,80 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import sys + +from developer_toolkit_elasticache import generate_iam_auth_token +from developer_toolkit_elasticache.errors import ToolkitInputError + + +def _add_generate_iam_auth_token_args(parser: argparse.ArgumentParser) -> None: + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument( + "--serverless-cache-name", + help="Serverless cache name (the SigV4 signing host)", + ) + target.add_argument( + "--replication-group-id", + help="Replication group id for a node-based cluster (the SigV4 signing host)", + ) + parser.add_argument( + "--user-id", + required=True, + help="ElastiCache user id (used to build the User ARN in the IAM policy)", + ) + parser.add_argument( + "--region", + help="AWS region (default: the region resolved from your AWS configuration)", + ) + + +def _cmd_generate_iam_auth_token(args: argparse.Namespace) -> str | None: + return generate_iam_auth_token( + user_id=args.user_id, + region=args.region, + serverless_cache_name=args.serverless_cache_name, + replication_group_id=args.replication_group_id, + ) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the top-level parser. + + Each tool contributes one subparser and sets ``func`` to a callable taking the + parsed namespace. That callable returns the text to write to stdout, or ``None`` + if the tool has nothing to print. It signals a user-fixable failure by raising + an ``ToolkitInputError`` subclass; ``main`` handles the rest, so adding a tool + means adding a subparser here and nothing else. + """ + parser = argparse.ArgumentParser( + prog="developer-toolkit-elasticache", + description="Developer tools for Amazon ElastiCache", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + gen = subparsers.add_parser( + "generate_iam_auth_token", + help="Generate an IAM auth token for an ElastiCache cache", + ) + _add_generate_iam_auth_token_args(gen) + gen.set_defaults(func=_cmd_generate_iam_auth_token) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + try: + output = args.func(args) + except ToolkitInputError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + if output is not None: + print(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/src/developer_toolkit_elasticache/errors.py b/python/src/developer_toolkit_elasticache/errors.py new file mode 100644 index 0000000..72b507b --- /dev/null +++ b/python/src/developer_toolkit_elasticache/errors.py @@ -0,0 +1,35 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Error types raised by the toolkit. + +``ToolkitInputError`` is the base for every failure the user can correct. It is also +the contract with the CLI: a ``ToolkitInputError`` is reported as a single-line +message instead of a traceback, and anything else is treated as a bug in the +toolkit and keeps its traceback. +""" + + +class ToolkitInputError(Exception): + """Base class for failures the user can correct. + + Catch this in CLI or application code to handle all user-fixable problems + uniformly (display a message, exit non-zero) without coupling to a specific + subclass. + """ + + +class InvalidParameterError(ToolkitInputError, ValueError): + """A parameter value or combination of parameters is not valid. + + Covers both single-value violations (bad format, out of range) and + multi-parameter constraint violations (mutually-exclusive arguments, + missing required combinations). + """ + + +class ConfigurationError(ToolkitInputError): + """The AWS environment is missing required values to sign a token. + + Covers the missing-configuration cases (no region or no credentials). + """ diff --git a/python/src/developer_toolkit_elasticache/py.typed b/python/src/developer_toolkit_elasticache/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/src/developer_toolkit_elasticache/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py new file mode 100644 index 0000000..aa9944a --- /dev/null +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -0,0 +1,240 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import re +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from botocore.auth import SigV4QueryAuth +from botocore.awsrequest import AWSRequest +from botocore.session import Session + +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, +) + +if TYPE_CHECKING: + from botocore.credentials import ReadOnlyCredentials + + +_TOKEN_TTL_SECONDS = 900 # 15 minutes + +_SIGNING_SERVICE = "elasticache" + +# The signed request is a presigned URL; the token is that URL without the scheme. +_URL_SCHEME_PREFIX = "https://" + +# The cache name is the SigV4 signing host, validating it keeps a malformed or +# hostile value from being signed against a host and producing a wrong token. +# Matching documented ElastiCache constraints in cache name. +_CACHE_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$") + +# ElastiCache service UserId pattern, with an optional "default." prefix for +# service-managed users (e.g. default.iam-user). +_USER_ID_PATTERN = re.compile(r"^(?:default\.)?[a-zA-Z][a-zA-Z0-9\-]*$") + +# Honoured on top of botocore's own region resolution — see _resolve_region. +_REGION_ENV_VAR = "AWS_REGION" + + +_NO_REGION_MESSAGE = ( + "No AWS region found. Pass region explicitly, or configure one via " + "AWS_REGION, AWS_DEFAULT_REGION, or the region setting in your AWS config profile." +) +_NO_CREDENTIALS_MESSAGE = ( + "No AWS credentials found. Configure credentials via the environment, " + "shared config/credentials files, or an instance/container role." +) + + +def _validate_and_normalize(parameter: str, value: str, pattern: re.Pattern[str]) -> str: + """Lowercase ``value``, check it against ``pattern``, and return the normalized form. + + ElastiCache stores cache names and user ids lowercase, so a value is normalized + before both matching and signing; the value signed into the token and the value + the client sends must match server-side. + """ + normalized = value.lower() + if not pattern.match(normalized): + # Never echo the caller's value back in the message — name the parameter + # and the accepted pattern only. + raise InvalidParameterError( + f"Invalid value for parameter {parameter!r}: must match {pattern.pattern}" + ) + return normalized + + +def _resolve_target( + serverless_cache_name: str | None, replication_group_id: str | None +) -> tuple[str, bool]: + """Map the mutually-exclusive name arguments to ``(cache_name, serverless)``. + The chosen value is validated here, under its own parameter name, so the + error names the argument the caller passed. + """ + if serverless_cache_name and not replication_group_id: + cache_name = _validate_and_normalize( + "serverless_cache_name", serverless_cache_name, _CACHE_NAME_PATTERN + ) + return cache_name, True + if replication_group_id and not serverless_cache_name: + cache_name = _validate_and_normalize( + "replication_group_id", replication_group_id, _CACHE_NAME_PATTERN + ) + return cache_name, False + raise InvalidParameterError( + "Invalid parameter combination for 'serverless_cache_name' and " + "'replication_group_id': exactly one must be provided." + ) + + +def _resolve_region(region: str | None, session: Session) -> str: + """Fall back to a configured region when one was not passed explicitly. + + The session fallback is read from the same session that vends the credentials + ``AWS_REGION`` is consulted directly because botocore does not: its region only + resolves from ``AWS_DEFAULT_REGION`` or the config profile. + """ + resolved = ( + region or os.environ.get(_REGION_ENV_VAR) or session.get_config_variable("region") + ) + if not resolved: + raise ConfigurationError(_NO_REGION_MESSAGE) + return resolved + + +def _resolve_credentials(session: Session) -> "ReadOnlyCredentials": + """Read a frozen credential set from the session's credential chain. + + Called on every token request rather than cached, so rotated credentials + (SSO, assume-role, container, instance metadata) are picked up automatically. + """ + credentials = session.get_credentials() + if credentials is None: + raise ConfigurationError(_NO_CREDENTIALS_MESSAGE) + return credentials.get_frozen_credentials() + + +def _sign_token( + cache_name: str, + user_id: str, + region: str, + credentials: "ReadOnlyCredentials", + *, + serverless: bool = True, +) -> str: + """Core signing logic. Returns the token string. + + ``cache_name`` is the ElastiCache **cache name** (for a serverless cache) or + the **replication group id** (for a node-based cluster) — the value used as + the SigV4 signing host. The server recomputes the signature using this name as + the host. + Cache names are lowercased at creation time, so the name must be + signed in lowercase to avoid auth errors. + + ``user_id`` is signed as the ``User`` request parameter, lowercased because + the service stores the user id lowercase (see ``_validate_and_normalize``). + (For IAM-enabled users this must equal the Redis/Valkey ACL user name, but + conceptually it is the ElastiCache user id, not the ACL name.) + + ``serverless`` is part of the signature, so it cannot be appended after signing. + """ + user_id = _validate_and_normalize("user_id", user_id, _USER_ID_PATTERN) + + params = {"Action": "connect", "User": user_id} + if serverless: + params["ResourceType"] = "ServerlessCache" + url = f"{_URL_SCHEME_PREFIX}{cache_name.lower()}/?{urlencode(params)}" + + request = AWSRequest(method="GET", url=url) + signer = SigV4QueryAuth( + credentials, _SIGNING_SERVICE, region, expires=_TOKEN_TTL_SECONDS + ) + signer.add_auth(request) + + return request.url.removeprefix(_URL_SCHEME_PREFIX) + + +def generate_iam_auth_token( + *, + user_id: str, + region: str | None = None, + serverless_cache_name: str | None = None, + replication_group_id: str | None = None, + session: Session | None = None, +) -> str: + """Generate an ElastiCache IAM auth token using the default AWS credential chain. + + Provide exactly one of ``serverless_cache_name`` (for a serverless cache) or + ``replication_group_id`` (for a node-based cluster); the choice selects the + resource type baked into the signature. ``user_id`` is the ElastiCache user + id used to build the User ARN in the IAM policy. Returns the token string + (like RDS's generate_db_auth_token). + + ``region`` defaults to the region the session resolves, so it only needs to be + passed to sign for a region other than the configured one. + + Pass ``session`` to sign with a specific botocore session; by default a new + session resolves credentials from the standard chain. + """ + cache_name, serverless = _resolve_target(serverless_cache_name, replication_group_id) + session = session or Session() + resolved_region = _resolve_region(region, session) + credentials = _resolve_credentials(session) + return _sign_token( + cache_name, user_id, resolved_region, credentials, serverless=serverless + ) + + +class ElastiCacheIAMAuthTokenProvider: + """Generic IAM token provider for ElastiCache. + + Signs a fresh token on demand each time ``get_token()`` is called, then + bridges it into whatever client you use (see the ``examples/`` directory). + + Provide exactly one of ``serverless_cache_name`` (for a serverless cache) or + ``replication_group_id`` (for a node-based cluster). + ``user_id`` is the ElastiCache user id. + ``region`` defaults to the region the session resolves, so it only needs to be + passed to sign for a region other than the configured one. + + Credentials come from the default AWS credential chain (env vars, shared + config/credentials, or the instance/container role). They are re-read on + every call, so rotated credentials (SSO, assume-role, container, instance + metadata) are picked up automatically. + """ + + def __init__( + self, + *, + user_id: str, + region: str | None = None, + serverless_cache_name: str | None = None, + replication_group_id: str | None = None, + session: Session | None = None, + ): + self._cache_name, self._serverless = _resolve_target( + serverless_cache_name, replication_group_id + ) + self._user_id = _validate_and_normalize("user_id", user_id, _USER_ID_PATTERN) + self._session = session or Session() + # Resolved once here rather than per call: unlike credentials, the region is + # static configuration, and a missing region should surface at construction + # instead of at the first connection attempt. + self._region = _resolve_region(region, self._session) + + @property + def user_id(self) -> str: + return self._user_id + + def get_token(self) -> str: + """Sign and return a fresh IAM auth token.""" + credentials = _resolve_credentials(self._session) + return _sign_token( + self._cache_name, + self._user_id, + self._region, + credentials, + serverless=self._serverless, + ) diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py new file mode 100644 index 0000000..6e19635 --- /dev/null +++ b/python/tests/test_cli.py @@ -0,0 +1,191 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse + +import pytest + +from developer_toolkit_elasticache import cli +from developer_toolkit_elasticache.errors import InvalidParameterError, ToolkitInputError + +CACHE = "my-cache" +USER = "testuser" +REGION = "us-east-1" + +_BASE_ARGV = [ + "generate_iam_auth_token", + "--serverless-cache-name", + CACHE, + "--user-id", + USER, + "--region", + REGION, +] + + +@pytest.fixture +def fake_token(monkeypatch): + """Stub out signing so the CLI tests never touch the AWS credential chain.""" + calls = {} + + def _generate(**kwargs): + calls.update(kwargs) + return "signed-token" + + monkeypatch.setattr(cli, "generate_iam_auth_token", _generate) + return calls + + +def test_prints_token_to_stdout(capsys, fake_token): + """The bare token must be the only thing on stdout so $(...) can capture it.""" + assert cli.main(_BASE_ARGV) == 0 + captured = capsys.readouterr() + assert captured.out == "signed-token\n" + assert captured.err == "" + + +def test_passes_arguments_through(fake_token): + cli.main(_BASE_ARGV) + assert fake_token == { + "user_id": USER, + "region": REGION, + "serverless_cache_name": CACHE, + "replication_group_id": None, + } + + +def test_region_is_optional_and_passed_as_none(fake_token): + """Omitting --region defers to the library's session fallback. + + The CLI must forward None rather than resolving a region itself, so that both the + CLI and library paths resolve it from the same session that vends the credentials. + """ + assert ( + cli.main( + [ + "generate_iam_auth_token", + "--serverless-cache-name", + CACHE, + "--user-id", + USER, + ] + ) + == 0 + ) + assert fake_token["region"] is None + + +def test_replication_group_mode(fake_token): + cli.main( + [ + "generate_iam_auth_token", + "--replication-group-id", + CACHE, + "--user-id", + USER, + "--region", + REGION, + ] + ) + assert fake_token["replication_group_id"] == CACHE + assert fake_token["serverless_cache_name"] is None + + +def test_expected_error_is_a_message_not_a_traceback(capsys, monkeypatch): + """A user mistake exits 1 with one line on stderr and nothing on stdout.""" + + def _raise(**_kwargs): + raise InvalidParameterError( + "Invalid value for parameter 'serverless_cache_name': must match " + ) + + monkeypatch.setattr(cli, "generate_iam_auth_token", _raise) + + assert cli.main(_BASE_ARGV) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("Error: ") + assert "serverless_cache_name" in captured.err + + +def test_unexpected_error_propagates(monkeypatch): + """An unexpected failure keeps its traceback rather than being flattened.""" + + def _raise(**_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(cli, "generate_iam_auth_token", _raise) + + with pytest.raises(RuntimeError, match="boom"): + cli.main(_BASE_ARGV) + + +def _register_extra_tool(monkeypatch, func): + """Add a second subcommand named ``extra`` the way a real new tool would be. + + These tests cover the contract ``main`` offers to future tools — dispatch via + ``func``, conditional stdout, ``ToolkitInputError`` handling — so that adding a + tool cannot silently inherit wrong behaviour. + """ + + def _build(): + parser = argparse.ArgumentParser(prog="developer-toolkit-elasticache") + subparsers = parser.add_subparsers(dest="command", required=True) + extra = subparsers.add_parser("extra") + extra.set_defaults(func=func) + return parser + + monkeypatch.setattr(cli, "_build_parser", _build) + + +def test_command_returning_none_prints_nothing(capsys, monkeypatch): + """A tool with no output must not print a bare "None" or a blank line.""" + _register_extra_tool(monkeypatch, lambda _args: None) + + assert cli.main(["extra"]) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_new_tools_user_error_is_a_message_not_a_traceback(capsys, monkeypatch): + """Deriving from ToolkitInputError is all a new tool needs for clean reporting.""" + + class ExtraToolError(ToolkitInputError): + pass + + def _raise(_args): + raise ExtraToolError("cannot do that") + + _register_extra_tool(monkeypatch, _raise) + + assert cli.main(["extra"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "Error: cannot do that\n" + + +@pytest.mark.parametrize( + "argv", + [ + [], # no subcommand + ["generate_iam_auth_token", "--user-id", USER, "--region", REGION], # no target + [ # both targets + "generate_iam_auth_token", + "--serverless-cache-name", + CACHE, + "--replication-group-id", + CACHE, + "--user-id", + USER, + "--region", + REGION, + ], + ["generate_iam_auth_token", "--serverless-cache-name", CACHE], # missing required + ], +) +def test_argument_errors_exit_2(argv): + """argparse rejects malformed invocations before any signing happens.""" + with pytest.raises(SystemExit) as excinfo: + cli.main(argv) + assert excinfo.value.code == 2 diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py new file mode 100644 index 0000000..0dd9d04 --- /dev/null +++ b/python/tests/test_token_generator.py @@ -0,0 +1,557 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import datetime +from unittest.mock import MagicMock, patch + +import pytest +from botocore.credentials import Credentials +from botocore.session import Session + +from developer_toolkit_elasticache import ( + ElastiCacheIAMAuthTokenProvider, + generate_iam_auth_token, +) +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, +) + +CACHE = "my-cache" +USER = "testuser" +REGION = "us-east-1" +# Deliberately different from REGION so a test can tell an explicitly-passed region +# apart from one that fell back to the session. +SESSION_REGION = "eu-west-1" + +_DEFAULT_CREDENTIALS = Credentials("FRAIDA1FODNN7EXAMPLE", "secret", "token") +_ROTATED_CREDENTIALS = Credentials("AKIAI44QH8DHBEXAMPLE", "other-secret", "token2") + +# Sentinel so callers can ask for "no credentials at all" (None) and still be +# distinguishable from "just give me the default set". +_UNSET = object() + + +def _fake_session(credentials=_UNSET, region=_UNSET): + """A botocore session stand-in that vends fixed credentials and a region. + + Every test signs with an injected session, so the suite never touches the real + AWS credential chain: no environment, config file, or IMDS lookup can leak in. + Pass ``credentials=None`` to simulate an empty credential chain, or + ``region=None`` to simulate a session with no region configured. + + The region is stubbed explicitly rather than left to MagicMock's auto-attribute, + because an auto-created mock is truthy and would let a broken fallback look like + it resolved a region. + """ + if credentials is _UNSET: + credentials = _DEFAULT_CREDENTIALS + if region is _UNSET: + region = SESSION_REGION + session = MagicMock(spec=Session) + if credentials is None: + session.get_credentials.return_value = None + else: + session.get_credentials.return_value = MagicMock( + get_frozen_credentials=MagicMock(return_value=credentials) + ) + session.get_config_variable.side_effect = lambda name: ( + region if name == "region" else None + ) + return session + + +@pytest.fixture +def mock_session(): + return _fake_session() + + +@pytest.fixture(autouse=True) +def no_region_env(monkeypatch): + """Sever the region environment variables for every test. + + Autouse because AWS_REGION is read from the environment directly, so without this + the suite's result would depend on the developer's shell: a region exported + locally would mask the session fallback and quietly pass a broken build. Tests + that want the env var set it explicitly with monkeypatch.setenv. + """ + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + + +def _assert_valid_token(token, *, serverless=True): + assert token is not None + assert CACHE in token + assert "Action=connect" in token + assert f"User={USER}" in token + assert "X-Amz-Algorithm" in token + assert "X-Amz-Credential" in token + assert "X-Amz-Signature" in token + assert "X-Amz-Expires=900" in token + # The scheme prefix must be stripped: the token is the presigned URL without it. + assert not token.startswith("https://") + # ResourceType is part of the signature and differs per cache kind, so it is + # the one field that must track the serverless/node-based choice. + if serverless: + assert "ResourceType=ServerlessCache" in token + else: + assert "ResourceType" not in token + + +# --- one-shot function ------------------------------------------------------- + + +def test_generate_iam_auth_token(mock_session): + token = generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + _assert_valid_token(token) + + +def test_generate_iam_auth_token_replication_group(mock_session): + """The one-shot function also supports the replication-group mode.""" + token = generate_iam_auth_token( + replication_group_id=CACHE, user_id=USER, region=REGION, session=mock_session + ) + _assert_valid_token(token, serverless=False) + + +def test_generate_iam_auth_token_requires_a_target(mock_session): + with pytest.raises(InvalidParameterError): + generate_iam_auth_token(user_id=USER, region=REGION, session=mock_session) + + +# --- provider class ---------------------------------------------------------- + + +def test_class_generates_token(mock_session): + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + _assert_valid_token(auth.get_token()) + + +def test_class_exposes_user_id(mock_session): + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + assert auth.user_id == USER + + +def test_class_no_client_specific_interface(mock_session): + """The core class must stay client-agnostic — no get_credentials().""" + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + assert not hasattr(auth, "get_credentials") + + +def test_get_token_signs_on_demand(mock_session): + """Each call re-reads credentials, so rotated creds are picked up.""" + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + auth.get_token() + auth.get_token() + assert mock_session.get_credentials.call_count == 2 + + +def test_rotated_credentials_change_the_signature(mock_session): + """A new credential set must produce a differently-signed token. + + Guards the reason credentials are resolved per call rather than cached. + """ + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session + ) + first = auth.get_token() + + mock_session.get_credentials.return_value = MagicMock( + get_frozen_credentials=MagicMock(return_value=_ROTATED_CREDENTIALS) + ) + assert auth.get_token() != first + + +def test_replication_group_omits_resource_type(mock_session): + """Node-based replication groups sign without ResourceType.""" + auth = ElastiCacheIAMAuthTokenProvider( + replication_group_id=CACHE, user_id=USER, region=REGION, session=mock_session + ) + _assert_valid_token(auth.get_token(), serverless=False) + + +# --- credential handling ----------------------------------------------------- + + +def test_empty_credential_chain_raises(): + """An unconfigured credential chain must fail with a clear, actionable error.""" + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, + user_id=USER, + region=REGION, + session=_fake_session(credentials=None), + ) + with pytest.raises(ConfigurationError, match="credentials"): + auth.get_token() + + +# --- target validation ------------------------------------------------------- + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, # neither name provided + {"serverless_cache_name": CACHE, "replication_group_id": CACHE}, # both + ], +) +def test_requires_exactly_one_target(mock_session, kwargs): + """Exactly one of serverless_cache_name / replication_group_id is required.""" + with pytest.raises(InvalidParameterError): + ElastiCacheIAMAuthTokenProvider( + user_id=USER, region=REGION, session=mock_session, **kwargs + ) + + +def test_cache_name_is_lowercased(mock_session): + """Cache names are lowercased server-side, so signing must lowercase too.""" + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name="My-Cache", + user_id=USER, + region=REGION, + session=mock_session, + ) + assert auth.get_token().startswith("my-cache/") + + +def test_user_id_is_lowercased_consistently(mock_session): + """User ids are stored lowercase server-side, so both the signed token and the + username exposed to the client bridge must be lowercase — and must agree, or + AUTH sends a username/token pair for different users and fails as WRONGPASS. + """ + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, + user_id="MixedCaseUser", + region=REGION, + session=mock_session, + ) + assert auth.user_id == "mixedcaseuser" + assert "User=mixedcaseuser" in auth.get_token() + assert "MixedCaseUser" not in auth.get_token() + + +@pytest.mark.parametrize( + "bad_name", + [ + "evil.com/path", # slash / path injection into the signing host + "my-cache@evil.com", # userinfo host confusion + "my-cache/?X=1", # query injection into the host + "my cache", # whitespace + "my_cache", # underscore not allowed + "1-cache", # must start with a letter, not a digit + "-cache", # must start with a letter, not a hyphen + "cache-", # cannot end with a hyphen + "my--cache", # cannot contain two consecutive hyphens + "-", # a bare hyphen is neither + ], +) +def test_invalid_cache_name_rejected(mock_session, bad_name): + """A malformed/hostile cache name must fail loudly, not sign a wrong host. + + The error names the parameter that was bad, since the type no longer does. + """ + with pytest.raises(InvalidParameterError, match="serverless_cache_name"): + ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=bad_name, + user_id=USER, + region=REGION, + session=mock_session, + ) + + +def test_invalid_replication_group_id_names_that_parameter(mock_session): + """A bad replication_group_id is reported under its own parameter name.""" + with pytest.raises(InvalidParameterError, match="replication_group_id"): + ElastiCacheIAMAuthTokenProvider( + replication_group_id="bad_name!", + user_id=USER, + region=REGION, + session=mock_session, + ) + + +def test_error_never_echoes_the_caller_value(mock_session): + hostile = "evil.com/inject\nlog-entry" + with pytest.raises(InvalidParameterError) as excinfo: + ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=hostile, + user_id=USER, + region=REGION, + session=mock_session, + ) + assert hostile not in str(excinfo.value) + assert "serverless_cache_name" in str(excinfo.value) + + +@pytest.mark.parametrize( + "good_name", + [ + "c", # a single letter is the shortest legal name + "my-cache", + "cache1", + "a-1-b-2", # hyphens between alphanumerics, repeatedly + "MyCache", # signed lowercase, but accepted as given + ], +) +def test_valid_cache_name_accepted(mock_session, good_name): + """Tightening the pattern must not reject names the service accepts.""" + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=good_name, user_id=USER, region=REGION, session=mock_session + ) + assert auth.get_token().startswith(f"{good_name.lower()}/") + + +@pytest.mark.parametrize( + "bad_user_id", + [ + "", # empty + "1user", # must start with a letter + "-user", # must start with a letter, not a hyphen + "user name", # whitespace + "user@host", # userinfo-style value + "user.name", # a dot is only allowed via the "default." prefix + "default.", # nothing after the prefix + "default.1x", # label after the prefix must start with a letter + "notdefault.foo", # the prefix must be literally "default." + ], +) +def test_invalid_user_id_rejected(mock_session, bad_user_id): + """A malformed user id fails loudly instead of signing a WRONGPASS token.""" + with pytest.raises(InvalidParameterError, match="user_id"): + ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, + user_id=bad_user_id, + region=REGION, + session=mock_session, + ) + + +@pytest.mark.parametrize( + "good_user_id", + [ + "iam-user", + "myuser", + "default", # service-managed default user + "default.iam-user", # service-managed IAM user + "default.other", # any default.* is accepted + "DEFAULT.IAM-USER", # case-normalized before matching + "a-1-b-2", + ], +) +def test_valid_user_id_accepted(mock_session, good_user_id): + """Validation must not reject user ids the service accepts. + + Case does not matter — the id is lowercased before both matching and signing — + so the signed User value is the lowercased form regardless of input case. + """ + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, + user_id=good_user_id, + region=REGION, + session=mock_session, + ) + assert f"User={good_user_id.lower()}" in auth.get_token() + + +def test_configuration_error_is_not_a_parameter_error(): + """Missing region/credentials is a configuration failure, not a bad parameter.""" + assert not issubclass(ConfigurationError, InvalidParameterError) + + +def test_empty_cache_name_rejected_as_missing_target(mock_session): + """An empty name is indistinguishable from no name, so it fails earlier.""" + with pytest.raises(InvalidParameterError): + ElastiCacheIAMAuthTokenProvider( + serverless_cache_name="", user_id=USER, region=REGION, session=mock_session + ) + + +# --- region resolution ------------------------------------------------------- +# The region is part of the SigV4 credential scope, so these assert on the scope in +# the signed token rather than just on the call succeeding: signing with the wrong +# region still produces a valid-looking token that the server rejects. +# +# Precedence is explicit argument > AWS_REGION > session (AWS_DEFAULT_REGION or the +# config profile). AWS_REGION is handled by this package rather than botocore, so it +# needs its own coverage in both directions. + +ENV_REGION = "us-west-2" + + +def test_region_falls_back_to_the_session(mock_session): + """Omitting region signs with the region the session resolved.""" + token = generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=mock_session + ) + assert f"%2F{SESSION_REGION}%2F" in token + + +def test_explicit_region_overrides_the_session(): + """An explicitly passed region wins over the session's configured one.""" + session = _fake_session(region=SESSION_REGION) + token = generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, region=REGION, session=session + ) + assert f"%2F{REGION}%2F" in token + assert SESSION_REGION not in token + + +def test_aws_region_env_var_is_honoured(mock_session, monkeypatch): + """AWS_REGION resolves a region even though botocore ignores it. + + The aws CLI and the JS/Go/Java SDKs all honour AWS_REGION, so a user whose region + is set only there would otherwise have a working CLI and a failing toolkit. + """ + monkeypatch.setenv("AWS_REGION", ENV_REGION) + session = _fake_session(region=None) + token = generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + assert f"%2F{ENV_REGION}%2F" in token + + +def test_aws_region_takes_priority_over_the_session(monkeypatch): + """Environment beats config file, matching how botocore ranks its own sources.""" + monkeypatch.setenv("AWS_REGION", ENV_REGION) + session = _fake_session(region=SESSION_REGION) + token = generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + assert f"%2F{ENV_REGION}%2F" in token + assert SESSION_REGION not in token + + +def test_explicit_region_overrides_aws_region_env_var(monkeypatch): + """An explicit argument is the most specific source, so it wins outright.""" + monkeypatch.setenv("AWS_REGION", ENV_REGION) + token = generate_iam_auth_token( + serverless_cache_name=CACHE, + user_id=USER, + region=REGION, + session=_fake_session(region=SESSION_REGION), + ) + assert f"%2F{REGION}%2F" in token + assert ENV_REGION not in token + + +def test_missing_region_raises(): + """No region anywhere is a user-fixable configuration problem.""" + session = _fake_session(region=None) + with pytest.raises(ConfigurationError, match="region"): + generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + + +def test_missing_region_error_names_every_source(): + """The message must name each place a region can come from.""" + session = _fake_session(region=None) + with pytest.raises(ConfigurationError, match="AWS_REGION"): + generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + with pytest.raises(ConfigurationError, match="AWS_DEFAULT_REGION"): + generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + + +def test_class_region_falls_back_to_the_session(mock_session): + auth = ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, session=mock_session + ) + assert f"%2F{SESSION_REGION}%2F" in auth.get_token() + + +def test_class_missing_region_fails_at_construction(): + """A missing region surfaces immediately, not at the first connection attempt.""" + session = _fake_session(region=None) + with pytest.raises(ConfigurationError, match="region"): + ElastiCacheIAMAuthTokenProvider( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + + +# --- known-answer signature tests -------------------------------------------- +# +# The tests above check the token's structure. These check its exact value, +# including the SigV4 signature, so a change that silently altered the signing +# inputs (service name, canonical request, credential scope) would fail here +# rather than only at connect time against a real cache. +# +# SigV4 signatures depend on the request timestamp, so the clock botocore reads +# (botocore.auth.get_current_datetime) is frozen to make the signature +# reproducible. These are fake credentials well-known AWS documentation example +# keys. +# https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html + +_KAT_CREDENTIALS = Credentials( + "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +) +_KAT_INSTANT = datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) +_KAT_REGION = "us-east-1" +_KAT_CACHE = "my-cache" +_KAT_USER = "testuser" + + +def _kat_session(): + return _fake_session(credentials=_KAT_CREDENTIALS, region=_KAT_REGION) + + +def _generate_at_fixed_time(**kwargs): + """Generate a token with botocore's signing clock pinned to _KAT_INSTANT.""" + with patch("botocore.auth.get_current_datetime", return_value=_KAT_INSTANT): + return generate_iam_auth_token( + user_id=_KAT_USER, region=_KAT_REGION, session=_kat_session(), **kwargs + ) + + +def test_serverless_token_matches_known_answer(): + """The full serverless token, signature included, matches a pinned value.""" + token = _generate_at_fixed_time(serverless_cache_name=_KAT_CACHE) + assert token == ( + "my-cache/?Action=connect&User=testuser&ResourceType=ServerlessCache" + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" + "%2Felasticache%2Faws4_request" + "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + "&X-Amz-Signature=" + "28f349fd92f74e0192de149a74743ae7d087bb136523c342bdd67632a8360023" + ) + + +def test_replication_group_token_matches_known_answer(): + """The node-based token omits ResourceType and therefore signs differently.""" + token = _generate_at_fixed_time(replication_group_id=_KAT_CACHE) + assert "ResourceType" not in token + assert token == ( + "my-cache/?Action=connect&User=testuser" + "&X-Amz-Algorithm=AWS4-HMAC-SHA256" + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" + "%2Felasticache%2Faws4_request" + "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" + "&X-Amz-Signature=" + "cd78a7de74c1bb1c8c685cac428f783b5433ce5fbf569af867570311d6a85797" + ) + + +def test_known_answer_signatures_differ_by_resource_type(): + """Serverless and node-based must not produce the same signature. + + The only difference in the signed request is ResourceType, so identical + signatures would mean it was dropped from the signature — the exact bug the + serverless/node-based split exists to prevent. + """ + serverless = _generate_at_fixed_time(serverless_cache_name=_KAT_CACHE) + node_based = _generate_at_fixed_time(replication_group_id=_KAT_CACHE) + assert serverless != node_based