From b96b9a6bcca829d627a4af8888a812a1fb037726 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 30 Jul 2026 12:45:29 +0100 Subject: [PATCH 1/5] Add Python toolkit with IAM auth token generator --- .github/workflows/python.yml | 71 ++++ .gitignore | 8 + LICENSE | 202 ++++++++++ NOTICE | 2 + python/.gitignore | 25 ++ python/LICENSE | 202 ++++++++++ python/NOTICE | 2 + python/README.md | 84 +++++ python/examples/connect_redis.py | 45 +++ python/examples/connect_valkey.py | 46 +++ python/examples/generate_token.py | 16 + python/pyproject.toml | 97 +++++ .../developer_toolkit_elasticache/__init__.py | 16 + .../src/developer_toolkit_elasticache/cli.py | 87 +++++ .../developer_toolkit_elasticache/errors.py | 14 + .../developer_toolkit_elasticache/py.typed | 0 .../token_generator.py | 259 +++++++++++++ python/tests/test_cli.py | 190 ++++++++++ python/tests/test_token_generator.py | 351 ++++++++++++++++++ 19 files changed, 1717 insertions(+) create mode 100644 .github/workflows/python.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 python/.gitignore create mode 100644 python/LICENSE create mode 100644 python/NOTICE create mode 100644 python/README.md create mode 100644 python/examples/connect_redis.py create mode 100644 python/examples/connect_valkey.py create mode 100644 python/examples/generate_token.py create mode 100644 python/pyproject.toml create mode 100644 python/src/developer_toolkit_elasticache/__init__.py create mode 100644 python/src/developer_toolkit_elasticache/cli.py create mode 100644 python/src/developer_toolkit_elasticache/errors.py create mode 100644 python/src/developer_toolkit_elasticache/py.typed create mode 100644 python/src/developer_toolkit_elasticache/token_generator.py create mode 100644 python/tests/test_cli.py create mode 100644 python/tests/test_token_generator.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..e75bffb --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,71 @@ +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.13"] + include: + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.12" + + 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..3796aef --- /dev/null +++ b/python/README.md @@ -0,0 +1,84 @@ +# Developer Toolkit for Amazon ElastiCache (Python) + +Developer tools for working with Amazon ElastiCache, as a Python library and +CLI. + +Install from source: + +```bash +python -m pip install -e ".[dev]" +``` + +This also registers the `developer-toolkit-elasticache` CLI. + +## Tools + +### IAM authentication tokens + +IAM authentication for ElastiCache requires presenting a short-lived +SigV4-signed token (valid for 15 minutes) as the connection password. This tool +handles the signing, eliminating the boilerplate and the several non-obvious +details that otherwise surface as an opaque `WRONGPASS` at connect time: + +- The token is signed against the **cache name** (or replication group id), not + the connection endpoint DNS. +- Serverless caches sign `ResourceType=ServerlessCache`; node-based replication + groups omit it. The resource type is part of the signature, so it cannot be + corrected after the fact. +- Cache names are lowercased at creation time, so they must be signed in + lowercase. + +A fresh token is signed on demand each time one is requested, so there is no +background refresh thread to manage and no stale-token window. Credentials are +re-read from the default AWS credential chain on every call, so rotated +credentials are picked up automatically. + +```python +from developer_toolkit_elasticache import generate_iam_auth_token + +token = generate_iam_auth_token( + serverless_cache_name="my-cache", + user_id="iam-user", +) +``` + +For a node-based cluster, pass `replication_group_id` instead of +`serverless_cache_name`. + +`region` is optional: it defaults to the region your AWS configuration resolves — +`AWS_REGION`, `AWS_DEFAULT_REGION`, or the `region` setting in your config profile, +in that order — and only needs to be passed to sign for a different region. + +For clients that re-authenticate on reconnect (redis-py, valkey-py), use +`ElastiCacheIAMAuth` and let the client call `get_token()` when it opens a +connection. See the [`examples/`](examples/) directory. + +From the CLI: + +```bash +developer-toolkit-elasticache generate_iam_auth_token \ + --serverless-cache-name my-cache --user-id iam-user +``` + +The token is a bearer credential. Pass it via the client's password parameter or +`VALKEYCLI_AUTH` / `REDISCLI_AUTH`. + +## Development + +```bash +python -m pip install -e ".[dev]" # install with test + lint dependencies +python -m pytest -q # run the tests +python -m ruff check . # lint +python -m ruff format . # format +``` + +The test suite injects credentials into every signing call, so it never reads the +AWS credential chain and needs no AWS account to run. + +## Status + +Proof of concept + +## 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..6af69db --- /dev/null +++ b/python/examples/connect_redis.py @@ -0,0 +1,45 @@ +# 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. + +ElastiCacheIAMAuth 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 ElastiCacheIAMAuth + + +class ElastiCacheCredentialProvider(CredentialProvider): + """Customer-owned bridge from the generic token engine to redis-py.""" + + def __init__(self, auth: ElastiCacheIAMAuth): + self._auth = auth + + def get_credentials(self): + return self._auth.user_id, self._auth.get_token() + + +# serverless_cache_name is the Serverless ElastiCache cache name used as the +# SigV4 signing host. +# For a node-based cluster, use replication_group_id="..." instead. +auth = ElastiCacheIAMAuth( + 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", + 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..514f2a0 --- /dev/null +++ b/python/examples/connect_valkey.py @@ -0,0 +1,46 @@ +# 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. + +ElastiCacheIAMAuth 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 ElastiCacheIAMAuth + + +class ElastiCacheCredentialProvider(CredentialProvider): + """Customer-owned bridge from the generic token engine to valkey-py.""" + + def __init__(self, auth: ElastiCacheIAMAuth): + self._auth = auth + + def get_credentials(self): + # valkey-py pulls this on every (re)connection, so the token stays fresh + return self._auth.user_id, self._auth.get_token() + + +# serverless_cache_name is the ElastiCache cache NAME — used as the SigV4 signing +# host. It is NOT the connection endpoint DNS below. Signing with the endpoint +# instead of the cache name produces a token the server rejects (WRONGPASS). +# (For a node-based cluster, use replication_group_id="..." instead.) +auth = ElastiCacheIAMAuth( + 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..9c2821c --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,97 @@ +[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.29.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", +] + +[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. +"tests/**/*" = ["S101", "PLR2004", "INP001"] +# 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..e769113 --- /dev/null +++ b/python/src/developer_toolkit_elasticache/__init__.py @@ -0,0 +1,16 @@ +# 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 ToolkitUserError +from developer_toolkit_elasticache.token_generator import ( + ElastiCacheIAMAuth, + generate_iam_auth_token, +) + +__all__ = [ + "ElastiCacheIAMAuth", + "ToolkitUserError", + "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..322e2fb --- /dev/null +++ b/python/src/developer_toolkit_elasticache/cli.py @@ -0,0 +1,87 @@ +# 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 ToolkitUserError + + +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 + a ``ToolkitUserError`` 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 ToolkitUserError as e: + # A user mistake or missing prerequisite, not a bug here: report it as one + # line. Anything else keeps its traceback, which is what you need to debug + # an unexpected failure. + print(f"Error: {e}", file=sys.stderr) + return 1 + + # A tool's result goes to stdout on its own so it can be captured directly, e.g. + # export VALKEYCLI_AUTH=$(developer-toolkit-elasticache generate_iam_auth_token ...) + # A tool with nothing to return prints nothing rather than a blank line. + 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..3379f4a --- /dev/null +++ b/python/src/developer_toolkit_elasticache/errors.py @@ -0,0 +1,14 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Error types shared by every tool in the toolkit. + +``ToolkitUserError`` is the contract between the tools and the CLI indicating user input error +and the CLI reports it as a single-line message instead of a traceback. +Anything else is treated as a bug in the toolkit and keeps its traceback + +""" + + +class ToolkitUserError(Exception): + """Base class for failures the user can correct.""" 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..b7e3a4e --- /dev/null +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -0,0 +1,259 @@ +# 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 ToolkitUserError + +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://" + +# ElastiCache serverless cache names / replication group ids are limited to letters, digits, +# and hyphens. The name is used as the SigV4 signing host, so validating it here +# prevents a malformed or hostile value from being signed against an unintended host +# and producing a silently-wrong token. +_CACHE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") + +# Honoured on top of botocore's own region resolution — see _resolve_region. +_REGION_ENV_VAR = "AWS_REGION" + + +class InvalidCacheNameError(ToolkitUserError, ValueError): + """The cache name is not usable as a SigV4 signing host.""" + + def __init__(self, cache_name: str) -> None: + super().__init__( + f"Invalid cache name {cache_name!r}: expected only letters, digits, and " + "hyphens (the serverless cache name / replication group id, not the " + "endpoint DNS)." + ) + + +class TargetRequiredError(ToolkitUserError, ValueError): + """Neither or both of the mutually-exclusive cache identifiers were given.""" + + def __init__(self) -> None: + super().__init__( + "exactly one of serverless_cache_name or replication_group_id is required" + ) + + +class RegionNotFoundError(ToolkitUserError, ValueError): + """No region was passed and none could be resolved from the environment.""" + + def __init__(self) -> None: + super().__init__( + "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." + ) + + +class CredentialsNotFoundError(ToolkitUserError, RuntimeError): + """The default AWS credential chain did not yield any credentials.""" + + def __init__(self) -> None: + super().__init__( + "No AWS credentials found. Configure credentials via the environment, " + "shared config/credentials files, or an instance/container role." + ) + + +def _validate_cache_name(cache_name: str) -> None: + if not cache_name or not _CACHE_NAME_PATTERN.match(cache_name): + raise InvalidCacheNameError(cache_name) + + +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)``. + + Exactly one of ``serverless_cache_name`` or ``replication_group_id`` must be + provided. Which one is used determines the resource type baked into the + signature + """ + if serverless_cache_name and not replication_group_id: + return serverless_cache_name, True + if replication_group_id and not serverless_cache_name: + return replication_group_id, False + raise TargetRequiredError + + +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, so + the region and the credentials come from the same place — mixing a region from one + profile with credentials from another yields a token the server rejects + (``WRONGPASS``) with nothing to indicate the region was the cause. + + ``AWS_REGION`` is consulted directly because botocore does not: its region only + resolves from ``AWS_DEFAULT_REGION`` or the config profile. The ``aws`` CLI and + the JS, Go, and Java SDKs all honour ``AWS_REGION`` and give it priority, so + without this a user whose region is set only there has a working ``aws`` CLI and + an inexplicably failing toolkit. Checked ahead of the session for the same reason + botocore ranks the environment above the config file. + """ + resolved = ( + region or os.environ.get(_REGION_ENV_VAR) or session.get_config_variable("region") + ) + if not resolved: + raise RegionNotFoundError + 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 CredentialsNotFoundError + 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, so signing with the endpoint DNS instead produces a token the server + rejects (``WRONGPASS``). + Cache names are lowercased at creation time, so the name must be + signed in lowercase to avoid auth errors. + + ``user_id`` is the ElastiCache **user id** — the value used to construct the + User ARN referenced in the IAM policy. It is signed as the ``User`` request + parameter. (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`` selects the one signing difference between the two IAM-auth + modes: serverless caches include ``ResourceType=ServerlessCache`` in the + signed request, node-based replication groups omit ``ResourceType`` entirely. + It is part of the signature, so it cannot be appended after signing. + """ + _validate_cache_name(cache_name) + + 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 ElastiCacheIAMAuth: + """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); 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. + ``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 = user_id + 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..38807df --- /dev/null +++ b/python/tests/test_cli.py @@ -0,0 +1,190 @@ +# 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 ToolkitUserError +from developer_toolkit_elasticache.token_generator import InvalidCacheNameError + +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 InvalidCacheNameError("bad name") + + 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 "bad 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, ``ToolkitUserError`` 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 ToolkitUserError is all a new tool needs for clean reporting.""" + + class ExtraToolError(ToolkitUserError): + 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..f6c0de2 --- /dev/null +++ b/python/tests/test_token_generator.py @@ -0,0 +1,351 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import MagicMock + +import pytest +from botocore.credentials import Credentials +from botocore.session import Session + +from developer_toolkit_elasticache import ElastiCacheIAMAuth, generate_iam_auth_token +from developer_toolkit_elasticache.token_generator import ( + CredentialsNotFoundError, + InvalidCacheNameError, + RegionNotFoundError, + TargetRequiredError, +) + +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(TargetRequiredError): + generate_iam_auth_token(user_id=USER, region=REGION, session=mock_session) + + +# --- provider class ---------------------------------------------------------- + + +def test_class_generates_token(mock_session): + auth = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + serverless_cache_name=CACHE, + user_id=USER, + region=REGION, + session=_fake_session(credentials=None), + ) + with pytest.raises(CredentialsNotFoundError): + 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(TargetRequiredError): + ElastiCacheIAMAuth(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 = ElastiCacheIAMAuth( + serverless_cache_name="My-Cache", + user_id=USER, + region=REGION, + session=mock_session, + ) + assert auth.get_token().startswith("my-cache/") + + +@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 + ], +) +def test_invalid_cache_name_rejected(mock_session, bad_name): + """A malformed/hostile cache name must fail loudly, not sign a wrong host.""" + auth = ElastiCacheIAMAuth( + serverless_cache_name=bad_name, user_id=USER, region=REGION, session=mock_session + ) + with pytest.raises(InvalidCacheNameError): + auth.get_token() + + +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(TargetRequiredError): + ElastiCacheIAMAuth( + 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(RegionNotFoundError): + 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(RegionNotFoundError, match="AWS_REGION"): + generate_iam_auth_token( + serverless_cache_name=CACHE, user_id=USER, session=session + ) + with pytest.raises(RegionNotFoundError, 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 = ElastiCacheIAMAuth( + 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(RegionNotFoundError): + ElastiCacheIAMAuth(serverless_cache_name=CACHE, user_id=USER, session=session) From a4d648d55bec03cec9b6f2bb4d012ea5a0c945ec Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 30 Jul 2026 16:19:10 +0100 Subject: [PATCH 2/5] Match documented ElastiCache constraints in cache name validation + remove redundant newlines --- python/examples/connect_redis.py | 7 +++--- python/examples/connect_valkey.py | 6 ++--- .../developer_toolkit_elasticache/errors.py | 3 +-- .../token_generator.py | 17 +++++++------- python/tests/test_token_generator.py | 23 +++++++++++++++++++ 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/python/examples/connect_redis.py b/python/examples/connect_redis.py index 6af69db..21d6339 100644 --- a/python/examples/connect_redis.py +++ b/python/examples/connect_redis.py @@ -6,7 +6,6 @@ ElastiCacheIAMAuth 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 @@ -25,8 +24,8 @@ def get_credentials(self): return self._auth.user_id, self._auth.get_token() -# serverless_cache_name is the Serverless ElastiCache cache name used as the -# SigV4 signing host. +# 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 = ElastiCacheIAMAuth( serverless_cache_name="my-cache", @@ -35,7 +34,7 @@ def get_credentials(self): ) client = redis.Redis( - host="my-cache-abc123.serverless.use1.cache.amazonaws.com", + host="my-cache-abc123.serverless.use1.cache.amazonaws.com", # connection endpoint (DNS) port=6379, ssl=True, credential_provider=ElastiCacheCredentialProvider(auth), diff --git a/python/examples/connect_valkey.py b/python/examples/connect_valkey.py index 514f2a0..f5f76ef 100644 --- a/python/examples/connect_valkey.py +++ b/python/examples/connect_valkey.py @@ -21,14 +21,12 @@ def __init__(self, auth: ElastiCacheIAMAuth): self._auth = auth def get_credentials(self): - # valkey-py pulls this on every (re)connection, so the token stays fresh return self._auth.user_id, self._auth.get_token() # serverless_cache_name is the ElastiCache cache NAME — used as the SigV4 signing -# host. It is NOT the connection endpoint DNS below. Signing with the endpoint -# instead of the cache name produces a token the server rejects (WRONGPASS). -# (For a node-based cluster, use replication_group_id="..." instead.) +# host. +# For a node-based cluster, use replication_group_id="..." instead. auth = ElastiCacheIAMAuth( serverless_cache_name="my-cache", user_id="my-iam-user", diff --git a/python/src/developer_toolkit_elasticache/errors.py b/python/src/developer_toolkit_elasticache/errors.py index 3379f4a..f95c95b 100644 --- a/python/src/developer_toolkit_elasticache/errors.py +++ b/python/src/developer_toolkit_elasticache/errors.py @@ -5,8 +5,7 @@ ``ToolkitUserError`` is the contract between the tools and the CLI indicating user input error and the CLI reports it as a single-line message instead of a traceback. -Anything else is treated as a bug in the toolkit and keeps its traceback - +Anything else is treated as a bug in the toolkit and keeps its traceback. """ diff --git a/python/src/developer_toolkit_elasticache/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py index b7e3a4e..60da0c0 100644 --- a/python/src/developer_toolkit_elasticache/token_generator.py +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -23,11 +23,10 @@ # The signed request is a presigned URL; the token is that URL without the scheme. _URL_SCHEME_PREFIX = "https://" -# ElastiCache serverless cache names / replication group ids are limited to letters, digits, -# and hyphens. The name is used as the SigV4 signing host, so validating it here -# prevents a malformed or hostile value from being signed against an unintended host -# and producing a silently-wrong token. -_CACHE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") +# 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]+)*$") # Honoured on top of botocore's own region resolution — see _resolve_region. _REGION_ENV_VAR = "AWS_REGION" @@ -38,9 +37,9 @@ class InvalidCacheNameError(ToolkitUserError, ValueError): def __init__(self, cache_name: str) -> None: super().__init__( - f"Invalid cache name {cache_name!r}: expected only letters, digits, and " - "hyphens (the serverless cache name / replication group id, not the " - "endpoint DNS)." + f"Invalid cache name {cache_name!r}: expected letters, digits, and hyphens, " + "starting with a letter, with no trailing hyphen and no two consecutive " + "hyphens (the serverless cache name / replication group id)." ) @@ -49,7 +48,7 @@ class TargetRequiredError(ToolkitUserError, ValueError): def __init__(self) -> None: super().__init__( - "exactly one of serverless_cache_name or replication_group_id is required" + "Exactly one of serverless_cache_name or replication_group_id is required." ) diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py index f6c0de2..bf94b9a 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -228,6 +228,11 @@ def test_cache_name_is_lowercased(mock_session): "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): @@ -239,6 +244,24 @@ def test_invalid_cache_name_rejected(mock_session, bad_name): auth.get_token() +@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 = ElastiCacheIAMAuth( + serverless_cache_name=good_name, user_id=USER, region=REGION, session=mock_session + ) + assert auth.get_token().startswith(f"{good_name.lower()}/") + + 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(TargetRequiredError): From cd835db8130e6bd2bef061c2f12345a56b0f0d60 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Tue, 11 Aug 2026 09:46:03 +0100 Subject: [PATCH 3/5] Improve validation, error types, and test coverage for the Python package - Add Python 3.14 to the supported/tested versions (classifier + CI matrix). - Rename provider class ElastiCacheIAMAuth -> ElastiCacheIAMAuthTokenProvider for clarity and parity with the Java package. - Validate user_id against the ElastiCache user-id format (allowing the service-managed default.iam-user), and lowercase it before signing so a mixed-case id no longer yields a token the server rejects as WRONGPASS. - Consolidate bad-value validation into a single InvalidParameterError that names the offending parameter (botocore-style), keep TargetRequiredError distinct as a missing-required error, and collapse the region/credentials cases into one ConfigurationError. - Add known-answer signature tests asserting the exact signed token for both serverless and node-based caches, with the signing clock frozen; signatures cross-checked against botocore independently. - Raise the botocore floor to >=1.43.0 so the advertised minimum matches the API the test suite relies on. --- .github/workflows/python.yml | 4 +- python/README.md | 4 +- python/examples/connect_redis.py | 8 +- python/examples/connect_valkey.py | 8 +- python/pyproject.toml | 8 +- .../developer_toolkit_elasticache/__init__.py | 14 +- .../developer_toolkit_elasticache/errors.py | 38 ++- .../token_generator.py | 100 ++++---- python/tests/test_cli.py | 5 +- python/tests/test_token_generator.py | 231 +++++++++++++++--- 10 files changed, 318 insertions(+), 102 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e75bffb..9a4e3d9 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -49,12 +49,14 @@ jobs: # 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.13"] + 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 diff --git a/python/README.md b/python/README.md index 3796aef..6564abe 100644 --- a/python/README.md +++ b/python/README.md @@ -6,7 +6,7 @@ CLI. Install from source: ```bash -python -m pip install -e ".[dev]" +python3 -m pip install -e ".[dev]" ``` This also registers the `developer-toolkit-elasticache` CLI. @@ -50,7 +50,7 @@ For a node-based cluster, pass `replication_group_id` instead of in that order — and only needs to be passed to sign for a different region. For clients that re-authenticate on reconnect (redis-py, valkey-py), use -`ElastiCacheIAMAuth` and let the client call `get_token()` when it opens a +`ElastiCacheIAMAuthTokenProvider` and let the client call `get_token()` when it opens a connection. See the [`examples/`](examples/) directory. From the CLI: diff --git a/python/examples/connect_redis.py b/python/examples/connect_redis.py index 21d6339..cc55f78 100644 --- a/python/examples/connect_redis.py +++ b/python/examples/connect_redis.py @@ -3,7 +3,7 @@ """Example: bridge the generic token engine into redis-py. -ElastiCacheIAMAuth is client-agnostic — it only vends get_token(). This example +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. """ @@ -11,13 +11,13 @@ import redis from redis.credentials import CredentialProvider -from developer_toolkit_elasticache import ElastiCacheIAMAuth +from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenProvider class ElastiCacheCredentialProvider(CredentialProvider): """Customer-owned bridge from the generic token engine to redis-py.""" - def __init__(self, auth: ElastiCacheIAMAuth): + def __init__(self, auth: ElastiCacheIAMAuthTokenProvider): self._auth = auth def get_credentials(self): @@ -27,7 +27,7 @@ def get_credentials(self): # 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 = ElastiCacheIAMAuth( +auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name="my-cache", user_id="my-iam-user", region="us-east-1", diff --git a/python/examples/connect_valkey.py b/python/examples/connect_valkey.py index f5f76ef..6171e4f 100644 --- a/python/examples/connect_valkey.py +++ b/python/examples/connect_valkey.py @@ -3,7 +3,7 @@ """Example: bridge the generic token engine into valkey-py. -ElastiCacheIAMAuth is client-agnostic — it only vends get_token(). This example +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. """ @@ -11,13 +11,13 @@ import valkey from valkey.credentials import CredentialProvider -from developer_toolkit_elasticache import ElastiCacheIAMAuth +from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenProvider class ElastiCacheCredentialProvider(CredentialProvider): """Customer-owned bridge from the generic token engine to valkey-py.""" - def __init__(self, auth: ElastiCacheIAMAuth): + def __init__(self, auth: ElastiCacheIAMAuthTokenProvider): self._auth = auth def get_credentials(self): @@ -27,7 +27,7 @@ def get_credentials(self): # 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 = ElastiCacheIAMAuth( +auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name="my-cache", user_id="my-iam-user", region="us-east-1", diff --git a/python/pyproject.toml b/python/pyproject.toml index 9c2821c..033208f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -16,7 +16,7 @@ readme = "README.md" license = "Apache-2.0" requires-python = ">=3.10" dependencies = [ - "botocore>=1.29.0", + "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 @@ -30,6 +30,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.optional-dependencies] @@ -65,8 +66,9 @@ extend-select = [ # 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. -"tests/**/*" = ["S101", "PLR2004", "INP001"] +# 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"] diff --git a/python/src/developer_toolkit_elasticache/__init__.py b/python/src/developer_toolkit_elasticache/__init__.py index e769113..06cf3a3 100644 --- a/python/src/developer_toolkit_elasticache/__init__.py +++ b/python/src/developer_toolkit_elasticache/__init__.py @@ -3,14 +3,22 @@ """Developer Toolkit for Amazon ElastiCache.""" -from developer_toolkit_elasticache.errors import ToolkitUserError +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, + TargetRequiredError, + ToolkitUserError, +) from developer_toolkit_elasticache.token_generator import ( - ElastiCacheIAMAuth, + ElastiCacheIAMAuthTokenProvider, generate_iam_auth_token, ) __all__ = [ - "ElastiCacheIAMAuth", + "ConfigurationError", + "ElastiCacheIAMAuthTokenProvider", + "InvalidParameterError", + "TargetRequiredError", "ToolkitUserError", "generate_iam_auth_token", ] diff --git a/python/src/developer_toolkit_elasticache/errors.py b/python/src/developer_toolkit_elasticache/errors.py index f95c95b..662d2fb 100644 --- a/python/src/developer_toolkit_elasticache/errors.py +++ b/python/src/developer_toolkit_elasticache/errors.py @@ -1,13 +1,43 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -"""Error types shared by every tool in the toolkit. +"""Error types raised by the toolkit. -``ToolkitUserError`` is the contract between the tools and the CLI indicating user input error -and the CLI reports it as a single-line message instead of a traceback. -Anything else is treated as a bug in the toolkit and keeps its traceback. +``ToolkitUserError`` is the base for every failure the user can correct. It is +also the contract with the CLI: a ``ToolkitUserError`` 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 ToolkitUserError(Exception): """Base class for failures the user can correct.""" + + +class InvalidParameterError(ToolkitUserError, ValueError): + """A parameter was given a value that is not valid. + + Error type for every 'bad value' case. The parameter name and the + offending value are carried in the message + """ + + def __init__(self, parameter: str, value: str, reason: str) -> None: + super().__init__( + f"Invalid value ({value!r}) for parameter {parameter!r}: {reason}" + ) + + +class TargetRequiredError(ToolkitUserError, ValueError): + """Neither or both of the mutually-exclusive cache identifiers were given.""" + + def __init__(self) -> None: + super().__init__( + "Exactly one of serverless_cache_name or replication_group_id is required." + ) + + +class ConfigurationError(ToolkitUserError): + """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/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py index 60da0c0..911b3b9 100644 --- a/python/src/developer_toolkit_elasticache/token_generator.py +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -10,7 +10,11 @@ from botocore.awsrequest import AWSRequest from botocore.session import Session -from developer_toolkit_elasticache.errors import ToolkitUserError +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, + TargetRequiredError, +) if TYPE_CHECKING: from botocore.credentials import ReadOnlyCredentials @@ -28,54 +32,51 @@ # Matching documented ElastiCache constraints in cache name. _CACHE_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$") +# The user id is signed as the User query parameter, so it does not +# need the stricter host-safety rules the cache name does. This is the ElastiCache +# service UserId pattern (letter, then letters/digits/hyphens). Explicit exception +# for default service-managed users +_USER_ID_PATTERN = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9\-]*|default\.iam-user)$") + # Honoured on top of botocore's own region resolution — see _resolve_region. _REGION_ENV_VAR = "AWS_REGION" -class InvalidCacheNameError(ToolkitUserError, ValueError): - """The cache name is not usable as a SigV4 signing host.""" - - def __init__(self, cache_name: str) -> None: - super().__init__( - f"Invalid cache name {cache_name!r}: expected letters, digits, and hyphens, " - "starting with a letter, with no trailing hyphen and no two consecutive " - "hyphens (the serverless cache name / replication group id)." - ) - +_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." +) -class TargetRequiredError(ToolkitUserError, ValueError): - """Neither or both of the mutually-exclusive cache identifiers were given.""" - def __init__(self) -> None: - super().__init__( - "Exactly one of serverless_cache_name or replication_group_id is required." - ) +_CACHE_NAME_REASON = ( + "expected letters, digits, and hyphens, starting with a letter, with no " + "trailing hyphen and no two consecutive hyphens" +) +_USER_ID_REASON = "expected a letter followed by letters, digits, and hyphens" -class RegionNotFoundError(ToolkitUserError, ValueError): - """No region was passed and none could be resolved from the environment.""" +def _validate_cache_name(parameter: str, cache_name: str) -> None: + if not _CACHE_NAME_PATTERN.match(cache_name): + raise InvalidParameterError(parameter, cache_name, _CACHE_NAME_REASON) - def __init__(self) -> None: - super().__init__( - "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." - ) +def _validate_user_id(user_id: str) -> str: + """Validate the user id and return it normalized to lowercase. -class CredentialsNotFoundError(ToolkitUserError, RuntimeError): - """The default AWS credential chain did not yield any credentials.""" - - def __init__(self) -> None: - super().__init__( - "No AWS credentials found. Configure credentials via the environment, " - "shared config/credentials files, or an instance/container role." - ) - - -def _validate_cache_name(cache_name: str) -> None: - if not cache_name or not _CACHE_NAME_PATTERN.match(cache_name): - raise InvalidCacheNameError(cache_name) + ElastiCache stores the user id as a lowercase string, so the value signed into + the token (``User=``) AND the username the client sends on AUTH must both be + lowercase to match — otherwise the server rejects the token as ``WRONGPASS``. + Normalizing once here, at the single point the value enters the toolkit, keeps + the signature and the ``user_id`` property from ever disagreeing. + """ + normalized = user_id.lower() + if not _USER_ID_PATTERN.match(normalized): + raise InvalidParameterError("user_id", user_id, _USER_ID_REASON) + return normalized def _resolve_target( @@ -85,11 +86,14 @@ def _resolve_target( Exactly one of ``serverless_cache_name`` or ``replication_group_id`` must be provided. Which one is used determines the resource type baked into the - signature + signature. 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: + _validate_cache_name("serverless_cache_name", serverless_cache_name) return serverless_cache_name, True if replication_group_id and not serverless_cache_name: + _validate_cache_name("replication_group_id", replication_group_id) return replication_group_id, False raise TargetRequiredError @@ -113,7 +117,7 @@ def _resolve_region(region: str | None, session: Session) -> str: region or os.environ.get(_REGION_ENV_VAR) or session.get_config_variable("region") ) if not resolved: - raise RegionNotFoundError + raise ConfigurationError(_NO_REGION_MESSAGE) return resolved @@ -125,7 +129,7 @@ def _resolve_credentials(session: Session) -> "ReadOnlyCredentials": """ credentials = session.get_credentials() if credentials is None: - raise CredentialsNotFoundError + raise ConfigurationError(_NO_CREDENTIALS_MESSAGE) return credentials.get_frozen_credentials() @@ -142,22 +146,22 @@ def _sign_token( ``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, so signing with the endpoint DNS instead produces a token the server - rejects (``WRONGPASS``). + the host. Cache names are lowercased at creation time, so the name must be signed in lowercase to avoid auth errors. ``user_id`` is the ElastiCache **user id** — the value used to construct the User ARN referenced in the IAM policy. It is signed as the ``User`` request - parameter. (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.) + parameter, lowercased, because the service stores the user id lowercase (see + ``_validate_user_id``). (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`` selects the one signing difference between the two IAM-auth modes: serverless caches include ``ResourceType=ServerlessCache`` in the signed request, node-based replication groups omit ``ResourceType`` entirely. It is part of the signature, so it cannot be appended after signing. """ - _validate_cache_name(cache_name) + user_id = _validate_user_id(user_id) params = {"Action": "connect", "User": user_id} if serverless: @@ -204,7 +208,7 @@ def generate_iam_auth_token( ) -class ElastiCacheIAMAuth: +class ElastiCacheIAMAuthTokenProvider: """Generic IAM token provider for ElastiCache. Signs a fresh token on demand each time ``get_token()`` is called, then @@ -235,7 +239,7 @@ def __init__( self._cache_name, self._serverless = _resolve_target( serverless_cache_name, replication_group_id ) - self._user_id = user_id + self._user_id = _validate_user_id(user_id) 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 diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 38807df..ab86712 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -6,8 +6,7 @@ import pytest from developer_toolkit_elasticache import cli -from developer_toolkit_elasticache.errors import ToolkitUserError -from developer_toolkit_elasticache.token_generator import InvalidCacheNameError +from developer_toolkit_elasticache.errors import InvalidParameterError, ToolkitUserError CACHE = "my-cache" USER = "testuser" @@ -96,7 +95,7 @@ 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 InvalidCacheNameError("bad name") + raise InvalidParameterError("serverless_cache_name", "bad name", "nope") monkeypatch.setattr(cli, "generate_iam_auth_token", _raise) diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py index bf94b9a..014a5bf 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -1,17 +1,20 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from unittest.mock import MagicMock +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 ElastiCacheIAMAuth, generate_iam_auth_token -from developer_toolkit_elasticache.token_generator import ( - CredentialsNotFoundError, - InvalidCacheNameError, - RegionNotFoundError, +from developer_toolkit_elasticache import ( + ElastiCacheIAMAuthTokenProvider, + generate_iam_auth_token, +) +from developer_toolkit_elasticache.errors import ( + ConfigurationError, + InvalidParameterError, TargetRequiredError, ) @@ -123,14 +126,14 @@ def test_generate_iam_auth_token_requires_a_target(mock_session): def test_class_generates_token(mock_session): - auth = ElastiCacheIAMAuth( + 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 = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session ) assert auth.user_id == USER @@ -138,7 +141,7 @@ def test_class_exposes_user_id(mock_session): def test_class_no_client_specific_interface(mock_session): """The core class must stay client-agnostic — no get_credentials().""" - auth = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session ) assert not hasattr(auth, "get_credentials") @@ -146,7 +149,7 @@ def test_class_no_client_specific_interface(mock_session): def test_get_token_signs_on_demand(mock_session): """Each call re-reads credentials, so rotated creds are picked up.""" - auth = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session ) auth.get_token() @@ -159,7 +162,7 @@ def test_rotated_credentials_change_the_signature(mock_session): Guards the reason credentials are resolved per call rather than cached. """ - auth = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, region=REGION, session=mock_session ) first = auth.get_token() @@ -172,7 +175,7 @@ def test_rotated_credentials_change_the_signature(mock_session): def test_replication_group_omits_resource_type(mock_session): """Node-based replication groups sign without ResourceType.""" - auth = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( replication_group_id=CACHE, user_id=USER, region=REGION, session=mock_session ) _assert_valid_token(auth.get_token(), serverless=False) @@ -183,13 +186,13 @@ def test_replication_group_omits_resource_type(mock_session): def test_empty_credential_chain_raises(): """An unconfigured credential chain must fail with a clear, actionable error.""" - auth = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, region=REGION, session=_fake_session(credentials=None), ) - with pytest.raises(CredentialsNotFoundError): + with pytest.raises(ConfigurationError, match="credentials"): auth.get_token() @@ -206,12 +209,14 @@ def test_empty_credential_chain_raises(): def test_requires_exactly_one_target(mock_session, kwargs): """Exactly one of serverless_cache_name / replication_group_id is required.""" with pytest.raises(TargetRequiredError): - ElastiCacheIAMAuth(user_id=USER, region=REGION, session=mock_session, **kwargs) + 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 = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name="My-Cache", user_id=USER, region=REGION, @@ -220,6 +225,22 @@ def test_cache_name_is_lowercased(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", [ @@ -236,12 +257,28 @@ def test_cache_name_is_lowercased(mock_session): ], ) def test_invalid_cache_name_rejected(mock_session, bad_name): - """A malformed/hostile cache name must fail loudly, not sign a wrong host.""" - auth = ElastiCacheIAMAuth( - serverless_cache_name=bad_name, user_id=USER, region=REGION, session=mock_session - ) - with pytest.raises(InvalidCacheNameError): - auth.get_token() + """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, + ) @pytest.mark.parametrize( @@ -256,16 +293,73 @@ def test_invalid_cache_name_rejected(mock_session, bad_name): ) def test_valid_cache_name_accepted(mock_session, good_name): """Tightening the pattern must not reject names the service accepts.""" - auth = ElastiCacheIAMAuth( + 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", # dots only allowed in the default.iam-user special case + "default.", # nothing after the dot + "default.foo", # only the exact default.iam-user is allowed + "default.iam-user-2", # not the exact service-managed name + ], +) +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 (the only dotted name allowed) + "DEFAULT.IAM-USER", # same service-managed user, case-normalized before matching + "Default.Iam-User", # mixed case likewise normalizes and is accepted + "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(TargetRequiredError): - ElastiCacheIAMAuth( + ElastiCacheIAMAuthTokenProvider( serverless_cache_name="", user_id=USER, region=REGION, session=mock_session ) @@ -341,7 +435,7 @@ def test_explicit_region_overrides_aws_region_env_var(monkeypatch): def test_missing_region_raises(): """No region anywhere is a user-fixable configuration problem.""" session = _fake_session(region=None) - with pytest.raises(RegionNotFoundError): + with pytest.raises(ConfigurationError, match="region"): generate_iam_auth_token( serverless_cache_name=CACHE, user_id=USER, session=session ) @@ -350,18 +444,18 @@ def test_missing_region_raises(): 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(RegionNotFoundError, match="AWS_REGION"): + with pytest.raises(ConfigurationError, match="AWS_REGION"): generate_iam_auth_token( serverless_cache_name=CACHE, user_id=USER, session=session ) - with pytest.raises(RegionNotFoundError, match="AWS_DEFAULT_REGION"): + 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 = ElastiCacheIAMAuth( + auth = ElastiCacheIAMAuthTokenProvider( serverless_cache_name=CACHE, user_id=USER, session=mock_session ) assert f"%2F{SESSION_REGION}%2F" in auth.get_token() @@ -370,5 +464,82 @@ def test_class_region_falls_back_to_the_session(mock_session): 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(RegionNotFoundError): - ElastiCacheIAMAuth(serverless_cache_name=CACHE, user_id=USER, session=session) + 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 From f6df6e68e595edac2c7b29a3614f42c8916c1acd Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Wed, 12 Aug 2026 15:23:59 +0100 Subject: [PATCH 4/5] Address review feedback on error types and validation - Rename the base exception to ToolkitInputError so the name fits both CLI and programmatic use, and consolidate user errors to two: InvalidParameterError (bad value or parameter combination) and ConfigurationError (missing region or credentials), both inheriting the base. - Unify cache-name and user-id checks into one validate-and-normalize helper so every validator normalizes and returns consistently; derive the failure message from the accepted pattern instead of a hand-maintained description. - Trim comments that restated the code, keeping the ones that explain why. --- .../developer_toolkit_elasticache/__init__.py | 6 +- .../src/developer_toolkit_elasticache/cli.py | 13 +-- .../developer_toolkit_elasticache/errors.py | 40 ++++----- .../token_generator.py | 81 ++++++++----------- python/tests/test_cli.py | 12 +-- python/tests/test_token_generator.py | 7 +- 6 files changed, 65 insertions(+), 94 deletions(-) diff --git a/python/src/developer_toolkit_elasticache/__init__.py b/python/src/developer_toolkit_elasticache/__init__.py index 06cf3a3..0f2b49b 100644 --- a/python/src/developer_toolkit_elasticache/__init__.py +++ b/python/src/developer_toolkit_elasticache/__init__.py @@ -6,8 +6,7 @@ from developer_toolkit_elasticache.errors import ( ConfigurationError, InvalidParameterError, - TargetRequiredError, - ToolkitUserError, + ToolkitInputError, ) from developer_toolkit_elasticache.token_generator import ( ElastiCacheIAMAuthTokenProvider, @@ -18,7 +17,6 @@ "ConfigurationError", "ElastiCacheIAMAuthTokenProvider", "InvalidParameterError", - "TargetRequiredError", - "ToolkitUserError", + "ToolkitInputError", "generate_iam_auth_token", ] diff --git a/python/src/developer_toolkit_elasticache/cli.py b/python/src/developer_toolkit_elasticache/cli.py index 322e2fb..146373d 100644 --- a/python/src/developer_toolkit_elasticache/cli.py +++ b/python/src/developer_toolkit_elasticache/cli.py @@ -5,7 +5,7 @@ import sys from developer_toolkit_elasticache import generate_iam_auth_token -from developer_toolkit_elasticache.errors import ToolkitUserError +from developer_toolkit_elasticache.errors import ToolkitInputError def _add_generate_iam_auth_token_args(parser: argparse.ArgumentParser) -> None: @@ -44,7 +44,7 @@ def _build_parser() -> argparse.ArgumentParser: 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 - a ``ToolkitUserError`` subclass; ``main`` handles the rest, so adding a tool + an ``ToolkitInputError`` subclass; ``main`` handles the rest, so adding a tool means adding a subparser here and nothing else. """ parser = argparse.ArgumentParser( @@ -68,16 +68,9 @@ def main(argv: list[str] | None = None) -> int: try: output = args.func(args) - except ToolkitUserError as e: - # A user mistake or missing prerequisite, not a bug here: report it as one - # line. Anything else keeps its traceback, which is what you need to debug - # an unexpected failure. + except ToolkitInputError as e: print(f"Error: {e}", file=sys.stderr) return 1 - - # A tool's result goes to stdout on its own so it can be captured directly, e.g. - # export VALKEYCLI_AUTH=$(developer-toolkit-elasticache generate_iam_auth_token ...) - # A tool with nothing to return prints nothing rather than a blank line. if output is not None: print(output) return 0 diff --git a/python/src/developer_toolkit_elasticache/errors.py b/python/src/developer_toolkit_elasticache/errors.py index 662d2fb..72b507b 100644 --- a/python/src/developer_toolkit_elasticache/errors.py +++ b/python/src/developer_toolkit_elasticache/errors.py @@ -3,40 +3,32 @@ """Error types raised by the toolkit. -``ToolkitUserError`` is the base for every failure the user can correct. It is -also the contract with the CLI: a ``ToolkitUserError`` 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. +``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 ToolkitUserError(Exception): - """Base class for failures the user can correct.""" +class ToolkitInputError(Exception): + """Base class for failures the user can correct. - -class InvalidParameterError(ToolkitUserError, ValueError): - """A parameter was given a value that is not valid. - - Error type for every 'bad value' case. The parameter name and the - offending value are carried in the message + 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. """ - def __init__(self, parameter: str, value: str, reason: str) -> None: - super().__init__( - f"Invalid value ({value!r}) for parameter {parameter!r}: {reason}" - ) +class InvalidParameterError(ToolkitInputError, ValueError): + """A parameter value or combination of parameters is not valid. -class TargetRequiredError(ToolkitUserError, ValueError): - """Neither or both of the mutually-exclusive cache identifiers were given.""" - - def __init__(self) -> None: - super().__init__( - "Exactly one of serverless_cache_name or replication_group_id is required." - ) + Covers both single-value violations (bad format, out of range) and + multi-parameter constraint violations (mutually-exclusive arguments, + missing required combinations). + """ -class ConfigurationError(ToolkitUserError): +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/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py index 911b3b9..5cc88fd 100644 --- a/python/src/developer_toolkit_elasticache/token_generator.py +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -13,7 +13,6 @@ from developer_toolkit_elasticache.errors import ( ConfigurationError, InvalidParameterError, - TargetRequiredError, ) if TYPE_CHECKING: @@ -52,30 +51,19 @@ ) -_CACHE_NAME_REASON = ( - "expected letters, digits, and hyphens, starting with a letter, with no " - "trailing hyphen and no two consecutive hyphens" -) -_USER_ID_REASON = "expected a letter followed by letters, digits, and hyphens" - - -def _validate_cache_name(parameter: str, cache_name: str) -> None: - if not _CACHE_NAME_PATTERN.match(cache_name): - raise InvalidParameterError(parameter, cache_name, _CACHE_NAME_REASON) +def _validate_and_normalize(parameter: str, value: str, pattern: re.Pattern[str]) -> str: + """Lowercase ``value``, check it against ``pattern``, and return the normalized form. - -def _validate_user_id(user_id: str) -> str: - """Validate the user id and return it normalized to lowercase. - - ElastiCache stores the user id as a lowercase string, so the value signed into - the token (``User=``) AND the username the client sends on AUTH must both be - lowercase to match — otherwise the server rejects the token as ``WRONGPASS``. - Normalizing once here, at the single point the value enters the toolkit, keeps - the signature and the ``user_id`` property from ever disagreeing. + 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 = user_id.lower() - if not _USER_ID_PATTERN.match(normalized): - raise InvalidParameterError("user_id", user_id, _USER_ID_REASON) + normalized = value.lower() + if not pattern.match(normalized): + raise InvalidParameterError( + f"Invalid value ({value!r}) for parameter {parameter!r}: " + f"must match {pattern.pattern}" + ) return normalized @@ -83,19 +71,23 @@ 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)``. - - Exactly one of ``serverless_cache_name`` or ``replication_group_id`` must be - provided. Which one is used determines the resource type baked into the - signature. The chosen value is validated here, under its own parameter name, - so the error names the argument the caller passed. + 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: - _validate_cache_name("serverless_cache_name", serverless_cache_name) - return serverless_cache_name, True + 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: - _validate_cache_name("replication_group_id", replication_group_id) - return replication_group_id, False - raise TargetRequiredError + 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: @@ -150,18 +142,14 @@ def _sign_token( Cache names are lowercased at creation time, so the name must be signed in lowercase to avoid auth errors. - ``user_id`` is the ElastiCache **user id** — the value used to construct the - User ARN referenced in the IAM policy. It is signed as the ``User`` request - parameter, lowercased, because the service stores the user id lowercase (see - ``_validate_user_id``). (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.) + ``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`` selects the one signing difference between the two IAM-auth - modes: serverless caches include ``ResourceType=ServerlessCache`` in the - signed request, node-based replication groups omit ``ResourceType`` entirely. - It is part of the signature, so it cannot be appended after signing. + ``serverless`` is part of the signature, so it cannot be appended after signing. """ - user_id = _validate_user_id(user_id) + user_id = _validate_and_normalize("user_id", user_id, _USER_ID_PATTERN) params = {"Action": "connect", "User": user_id} if serverless: @@ -215,9 +203,8 @@ class ElastiCacheIAMAuthTokenProvider: 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); 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. + ``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. @@ -239,7 +226,7 @@ def __init__( self._cache_name, self._serverless = _resolve_target( serverless_cache_name, replication_group_id ) - self._user_id = _validate_user_id(user_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 diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index ab86712..81b890f 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest from developer_toolkit_elasticache import cli -from developer_toolkit_elasticache.errors import InvalidParameterError, ToolkitUserError +from developer_toolkit_elasticache.errors import InvalidParameterError, ToolkitInputError CACHE = "my-cache" USER = "testuser" @@ -95,7 +95,9 @@ 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("serverless_cache_name", "bad name", "nope") + raise InvalidParameterError( + "Invalid value ('bad name') for parameter 'serverless_cache_name': nope" + ) monkeypatch.setattr(cli, "generate_iam_auth_token", _raise) @@ -122,7 +124,7 @@ 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, ``ToolkitUserError`` handling — so that adding a + ``func``, conditional stdout, ``ToolkitInputError`` handling — so that adding a tool cannot silently inherit wrong behaviour. """ @@ -147,9 +149,9 @@ def test_command_returning_none_prints_nothing(capsys, monkeypatch): def test_new_tools_user_error_is_a_message_not_a_traceback(capsys, monkeypatch): - """Deriving from ToolkitUserError is all a new tool needs for clean reporting.""" + """Deriving from ToolkitInputError is all a new tool needs for clean reporting.""" - class ExtraToolError(ToolkitUserError): + class ExtraToolError(ToolkitInputError): pass def _raise(_args): diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py index 014a5bf..79b4c4f 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -15,7 +15,6 @@ from developer_toolkit_elasticache.errors import ( ConfigurationError, InvalidParameterError, - TargetRequiredError, ) CACHE = "my-cache" @@ -118,7 +117,7 @@ def test_generate_iam_auth_token_replication_group(mock_session): def test_generate_iam_auth_token_requires_a_target(mock_session): - with pytest.raises(TargetRequiredError): + with pytest.raises(InvalidParameterError): generate_iam_auth_token(user_id=USER, region=REGION, session=mock_session) @@ -208,7 +207,7 @@ def test_empty_credential_chain_raises(): ) def test_requires_exactly_one_target(mock_session, kwargs): """Exactly one of serverless_cache_name / replication_group_id is required.""" - with pytest.raises(TargetRequiredError): + with pytest.raises(InvalidParameterError): ElastiCacheIAMAuthTokenProvider( user_id=USER, region=REGION, session=mock_session, **kwargs ) @@ -358,7 +357,7 @@ def test_configuration_error_is_not_a_parameter_error(): 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(TargetRequiredError): + with pytest.raises(InvalidParameterError): ElastiCacheIAMAuthTokenProvider( serverless_cache_name="", user_id=USER, region=REGION, session=mock_session ) From 68de98b0fe21788f9e89948b788ef244f9d37a50 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 13 Aug 2026 14:50:53 +0100 Subject: [PATCH 5/5] Address review feedback: user_id pattern, error messages, and README - Accept an optional "default." prefix on the user_id pattern rather than the single literal "default.iam-user", so a new service-managed default.* user needs no code change. - Stop echoing the caller's value in InvalidParameterError; name the parameter and the accepted pattern only. Add a test that the value is never in the message. - Rewrite the README for a customer audience: separate install requirements from runtime requirements, document the arguments and the credential resolution order, note that VALKEYCLI_AUTH needs valkey-cli 9.0+ while REDISCLI_AUTH works with both clients, and drop implementation detail. --- python/README.md | 129 +++++++++++------- .../token_generator.py | 25 ++-- python/tests/test_cli.py | 4 +- python/tests/test_token_generator.py | 27 +++- 4 files changed, 113 insertions(+), 72 deletions(-) diff --git a/python/README.md b/python/README.md index 6564abe..aff497f 100644 --- a/python/README.md +++ b/python/README.md @@ -1,83 +1,120 @@ # Developer Toolkit for Amazon ElastiCache (Python) -Developer tools for working with Amazon ElastiCache, as a Python library and -CLI. +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. -Install from source: +## Installation + +Requires Python 3.10–3.14, `pip`, and `git`. Install from the source repository: ```bash -python3 -m pip install -e ".[dev]" +git clone https://github.com/aws/developer-toolkit-elasticache.git +cd developer-toolkit-elasticache/python +python3 -m pip install . ``` -This also registers the `developer-toolkit-elasticache` CLI. - -## Tools +This installs the library and the `developer-toolkit-elasticache` command. -### IAM authentication tokens +## Usage -IAM authentication for ElastiCache requires presenting a short-lived -SigV4-signed token (valid for 15 minutes) as the connection password. This tool -handles the signing, eliminating the boilerplate and the several non-obvious -details that otherwise surface as an opaque `WRONGPASS` at connect time: +To generate a usable token you need: -- The token is signed against the **cache name** (or replication group id), not - the connection endpoint DNS. -- Serverless caches sign `ResourceType=ServerlessCache`; node-based replication - groups omit it. The resource type is part of the signature, so it cannot be - corrected after the fact. -- Cache names are lowercased at creation time, so they must be signed in - lowercase. +- 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. -A fresh token is signed on demand each time one is requested, so there is no -background refresh thread to manage and no stale-token window. Credentials are -re-read from the default AWS credential chain on every call, so rotated -credentials are picked up automatically. +### Generate a token ```python from developer_toolkit_elasticache import generate_iam_auth_token token = generate_iam_auth_token( - serverless_cache_name="my-cache", + 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). -For a node-based cluster, pass `replication_group_id` instead of -`serverless_cache_name`. +**Credentials** -`region` is optional: it defaults to the region your AWS configuration resolves — -`AWS_REGION`, `AWS_DEFAULT_REGION`, or the `region` setting in your config profile, -in that order — and only needs to be passed to sign for a different region. +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: -For clients that re-authenticate on reconnect (redis-py, valkey-py), use -`ElastiCacheIAMAuthTokenProvider` and let the client call `get_token()` when it opens a -connection. See the [`examples/`](examples/) directory. +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). -From the CLI: +### 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 + --serverless-cache-name my-cache \ + --user-id iam-user \ + --region us-east-1 ``` -The token is a bearer credential. Pass it via the client's password parameter or -`VALKEYCLI_AUTH` / `REDISCLI_AUTH`. +Add `--region` to sign for a specific region instead of the one resolved from your +AWS configuration -## Development +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 -python -m pip install -e ".[dev]" # install with test + lint dependencies -python -m pytest -q # run the tests -python -m ruff check . # lint -python -m ruff format . # format -``` +export VALKEYCLI_AUTH=$(developer-toolkit-elasticache generate_iam_auth_token \ + --serverless-cache-name my-cache --user-id iam-user --region us-east-1) -The test suite injects credentials into every signing call, so it never reads the -AWS credential chain and needs no AWS account to run. +# Example: connecting via valkey-cli +valkey-cli --tls -h --user +``` -## Status +## Security -Proof of concept +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 diff --git a/python/src/developer_toolkit_elasticache/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py index 5cc88fd..aa9944a 100644 --- a/python/src/developer_toolkit_elasticache/token_generator.py +++ b/python/src/developer_toolkit_elasticache/token_generator.py @@ -31,11 +31,9 @@ # Matching documented ElastiCache constraints in cache name. _CACHE_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$") -# The user id is signed as the User query parameter, so it does not -# need the stricter host-safety rules the cache name does. This is the ElastiCache -# service UserId pattern (letter, then letters/digits/hyphens). Explicit exception -# for default service-managed users -_USER_ID_PATTERN = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9\-]*|default\.iam-user)$") +# 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" @@ -60,9 +58,10 @@ def _validate_and_normalize(parameter: str, value: str, pattern: re.Pattern[str] """ 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 ({value!r}) for parameter {parameter!r}: " - f"must match {pattern.pattern}" + f"Invalid value for parameter {parameter!r}: must match {pattern.pattern}" ) return normalized @@ -93,17 +92,9 @@ def _resolve_target( 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, so - the region and the credentials come from the same place — mixing a region from one - profile with credentials from another yields a token the server rejects - (``WRONGPASS``) with nothing to indicate the region was the cause. - + 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. The ``aws`` CLI and - the JS, Go, and Java SDKs all honour ``AWS_REGION`` and give it priority, so - without this a user whose region is set only there has a working ``aws`` CLI and - an inexplicably failing toolkit. Checked ahead of the session for the same reason - botocore ranks the environment above the config file. + resolves from ``AWS_DEFAULT_REGION`` or the config profile. """ resolved = ( region or os.environ.get(_REGION_ENV_VAR) or session.get_config_variable("region") diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 81b890f..6e19635 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -96,7 +96,7 @@ def test_expected_error_is_a_message_not_a_traceback(capsys, monkeypatch): def _raise(**_kwargs): raise InvalidParameterError( - "Invalid value ('bad name') for parameter 'serverless_cache_name': nope" + "Invalid value for parameter 'serverless_cache_name': must match " ) monkeypatch.setattr(cli, "generate_iam_auth_token", _raise) @@ -105,7 +105,7 @@ def _raise(**_kwargs): captured = capsys.readouterr() assert captured.out == "" assert captured.err.startswith("Error: ") - assert "bad name" in captured.err + assert "serverless_cache_name" in captured.err def test_unexpected_error_propagates(monkeypatch): diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py index 79b4c4f..0dd9d04 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -280,6 +280,19 @@ def test_invalid_replication_group_id_names_that_parameter(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", [ @@ -306,10 +319,10 @@ def test_valid_cache_name_accepted(mock_session, good_name): "-user", # must start with a letter, not a hyphen "user name", # whitespace "user@host", # userinfo-style value - "user.name", # dots only allowed in the default.iam-user special case - "default.", # nothing after the dot - "default.foo", # only the exact default.iam-user is allowed - "default.iam-user-2", # not the exact service-managed name + "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): @@ -329,9 +342,9 @@ def test_invalid_user_id_rejected(mock_session, bad_user_id): "iam-user", "myuser", "default", # service-managed default user - "default.iam-user", # service-managed IAM user (the only dotted name allowed) - "DEFAULT.IAM-USER", # same service-managed user, case-normalized before matching - "Default.Iam-User", # mixed case likewise normalizes and is accepted + "default.iam-user", # service-managed IAM user + "default.other", # any default.* is accepted + "DEFAULT.IAM-USER", # case-normalized before matching "a-1-b-2", ], )