From e9c863f56b849f9321d0d5171f6b3d493a68ee75 Mon Sep 17 00:00:00 2001 From: Tiansu Yu Date: Thu, 11 Sep 2025 12:12:15 +0200 Subject: [PATCH] feat: enable loading aws creds through env vars --- .gitignore | 4 ++ keyrings/codeartifact.py | 36 ++++++++---- tests/test_backend.py | 122 ++++++++++++++++++++++++++++++++++++--- tests/test_config.py | 4 +- tests/test_keyring.py | 6 +- 5 files changed, 146 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 59da84b..1cdd232 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ MANIFEST # Environments .venv venv/ + +# IDEs +.idea/ +.vscode/ diff --git a/keyrings/codeartifact.py b/keyrings/codeartifact.py index 907333d..59bd0ab 100644 --- a/keyrings/codeartifact.py +++ b/keyrings/codeartifact.py @@ -1,20 +1,18 @@ # codeartifact.py -- keyring backend -import re import logging - -import boto3 -import boto3.session - +import os +import re +from configparser import RawConfigParser from datetime import datetime +from typing import NamedTuple from urllib.parse import urlparse +import boto3 +import boto3.session from keyring import backend, credentials from keyring.util.platform_ import config_root -from typing import NamedTuple -from configparser import RawConfigParser - class Qualifier(NamedTuple): domain: str = None @@ -180,11 +178,13 @@ def get_password(self, service, username): # Options for the client callback. options = { # Pass in the region name. - "region_name": region, + "region_name": os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or region, } # Extract any AWS profile name we should use. - profile_name = config.get("profile_name") + profile_name = os.environ.get("AWS_PROFILE") or config.get("profile_name") if profile_name: options.update({"profile_name": profile_name}) @@ -202,8 +202,18 @@ def get_password(self, service, username): options.update({"verify": verify.strip('"')}) # If static access/secret keys were provided, use them. - aws_access_key_id = config.get("aws_access_key_id") - aws_secret_access_key = config.get("aws_secret_access_key") + aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") or config.get( + "aws_access_key_id" + ) + aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") or config.get( + "aws_secret_access_key" + ) + aws_session_token = ( + os.environ.get("AWS_SESSION_TOKEN") + or os.environ.get("AWS_SECURITY_TOKEN") + or config.get("aws_session_token") + ) + if aws_access_key_id and aws_secret_access_key: options.update( { @@ -211,6 +221,8 @@ def get_password(self, service, username): "aws_secret_access_key": aws_secret_access_key, } ) + if aws_session_token: + options["aws_session_token"] = aws_session_token # Generate a CodeArtifact client using the callback. client = self.make_client(options) diff --git a/tests/test_backend.py b/tests/test_backend.py index 549ac1b..0c0f79a 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,19 +1,20 @@ # test_backend.py -- backend tests -import pytest - +from contextlib import contextmanager +from datetime import datetime, timedelta from io import StringIO from pathlib import Path +from tempfile import NamedTemporaryFile from urllib.parse import urlunparse -from datetime import datetime, timedelta +import pytest from botocore.stub import Stubber -from contextlib import contextmanager -from tempfile import NamedTemporaryFile - -from keyrings.codeartifact import make_codeartifact_client -from keyrings.codeartifact import CodeArtifactBackend, CodeArtifactKeyringConfig +from keyrings.codeartifact import ( + CodeArtifactBackend, + CodeArtifactKeyringConfig, + make_codeartifact_client, +) REGION_NAME = "ca-central-1" CONFIG_DIR = Path(__file__).parent / "config" @@ -171,3 +172,108 @@ def make_client(options): backend = CodeArtifactBackend(config=config, make_client=make_client) url = codeartifact_pypi_url("domain", "000000000000", "region", "name") credentials = backend.get_credential(url, None) + + +@pytest.mark.parametrize( + ("env_vars", "config_content", "expected_options"), + [ + # Environment variables override config file + ( + { + "AWS_PROFILE": "env-profile", + "AWS_REGION": "env-region", + "AWS_ACCESS_KEY_ID": "env-key", + "AWS_SECRET_ACCESS_KEY": "env-secret", + "AWS_SESSION_TOKEN": "env-token", + }, + """ + [codeartifact] + profile_name = config-profile + aws_access_key_id = config-key + aws_secret_access_key = config-secret + aws_session_token = config-token + """, + { + "profile_name": "env-profile", + "region_name": "env-region", + "aws_access_key_id": "env-key", + "aws_secret_access_key": "env-secret", + "aws_session_token": "env-token", + }, + ), + # AWS_DEFAULT_REGION fallback + ( + {"AWS_DEFAULT_REGION": "default-region"}, + "", + {"region_name": "default-region"}, + ), + # AWS_SECURITY_TOKEN fallback + ( + { + "AWS_ACCESS_KEY_ID": "key", + "AWS_SECRET_ACCESS_KEY": "secret", + "AWS_SECURITY_TOKEN": "security-token", + }, + "", + { + "aws_access_key_id": "key", + "aws_secret_access_key": "secret", + "aws_session_token": "security-token", + }, + ), + # Config file values when no env vars + ( + {}, + """ + [codeartifact] + profile_name = config-profile + aws_access_key_id = config-key + aws_secret_access_key = config-secret + """, + { + "profile_name": "config-profile", + "aws_access_key_id": "config-key", + "aws_secret_access_key": "config-secret", + }, + ), + # Region precedence: AWS_REGION takes priority over parsed region + ({"AWS_REGION": "env-region"}, "", {"region_name": "env-region"}), + # Only session token without keys should not include token + ({"AWS_SESSION_TOKEN": "token-only"}, "", {}), + ], +) +def test_environment_variable_precedence( + monkeypatch, env_vars: dict, config_content: str, expected_options: str +): + # Clear existing environment variables and set test ones + for env_key in [ + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + ]: + monkeypatch.delenv(env_key, raising=False) + + for env_key, env_value in env_vars.items(): + monkeypatch.setenv(env_key, env_value) + + class DummyClient: + def get_authorization_token(self, *args, **kwargs): + return {} + + def make_client(options): + # Assert that we received specific options. + for key, value in expected_options.items(): + assert value == options.get(key) + + # Ignore the rest. + return DummyClient() + + with config_from_string(config_content) as config_file: + config = CodeArtifactKeyringConfig(config_file=config_file.name) + backend = CodeArtifactBackend(config=config, make_client=make_client) + url = codeartifact_pypi_url("domain", "000000000000", "region", "name") + backend.get_credential(url, None) diff --git a/tests/test_config.py b/tests/test_config.py index 211e2da..bda0314 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,10 @@ # test_config.py -- config parsing tests -import pytest - from io import StringIO from os.path import dirname, join +import pytest + from keyrings.codeartifact import CodeArtifactKeyringConfig diff --git a/tests/test_keyring.py b/tests/test_keyring.py index 5b3efd7..5589c3c 100644 --- a/tests/test_keyring.py +++ b/tests/test_keyring.py @@ -1,11 +1,9 @@ # test_keyring.py -- keyring tests -import pytest +from urllib.parse import urlunparse import keyring - -from urllib.parse import urlunparse -from datetime import datetime, timedelta +import pytest from keyrings.codeartifact import CodeArtifactBackend