From 587604eb2b8761a4255b76a08a41c86b7377ac2b Mon Sep 17 00:00:00 2001 From: userhas404d <29389186+userhas404d@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:23:58 -0400 Subject: [PATCH] Adds teleport support --- .gitignore | 1 + README.md | 30 +++- keyrings/clients/__init__.py | 0 keyrings/clients/boto3.py | 89 ++++++++++++ keyrings/clients/tsh.py | 132 +++++++++++++++++ keyrings/codeartifact.py | 87 +++++------ keyrings/observability.py | 8 ++ .../config/multiple_sections_with_default.cfg | 7 + ...e_section.cfg => single_section_boto3.cfg} | 0 tests/config/single_section_tsh.cfg | 8 ++ tests/conftest.py | 14 ++ ...{test_backend.py => test_backend_boto3.py} | 4 +- tests/test_backend_tsh.py | 136 ++++++++++++++++++ tests/test_config.py | 36 ++++- 14 files changed, 493 insertions(+), 59 deletions(-) create mode 100644 keyrings/clients/__init__.py create mode 100644 keyrings/clients/boto3.py create mode 100644 keyrings/clients/tsh.py create mode 100644 keyrings/observability.py rename tests/config/{single_section.cfg => single_section_boto3.cfg} (100%) create mode 100644 tests/config/single_section_tsh.cfg create mode 100644 tests/conftest.py rename tests/{test_backend.py => test_backend_boto3.py} (97%) create mode 100644 tests/test_backend_tsh.py diff --git a/.gitignore b/.gitignore index 59da84b..65b7740 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.py[cod] *$py.class +.pytest_cache/ # Distribution / packaging .Python diff --git a/README.md b/README.md index 033367a..8c0335a 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,15 @@ Run `keyring diagnose` to find its as the location; it varies between different Available options: - `profile_name`: Use a specific AWS profile to authenticate with AWS. - - `token_duration`: Validity period (in seconds) for retieved authorization tokens. + - `token_duration`: Validity period (in seconds) for retrieved authorization tokens. - `aws_access_key_id`: Use a specific AWS access key to authenticate with AWS. - `aws_secret_access_key`: Use a specific AWS secret access key to authenticate with AWS. For more explanation of these options see the [AWS CLI documentation](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). -### Single Section Configuration +### Single Section Configuration - boto3 -A trivial example `keyringrc.cfg` section for a single account: +A trivial example `keyringrc.cfg` section for a single account that uses the `boto3` client: ```ini [codeartifact] @@ -57,6 +57,30 @@ aws_access_key_id=xxxxxxxxx aws_secret_access_key=xxxxxxxxx ``` +### Single Section Configuration - teleport + +A trivial example `keyringrc.cfg` section for a single account that uses the `tsh` client. +Requires the [Teleport](https://goteleport.com/) client to be installed and configured. + +```ini +[codeartifact] +# Use the tsh binary to create the ca token. +# Can be overridden by the CA_KEYRING_CLIENT environment variable. +default_client = tsh + +# Tokens should only be valid for 30 minutes. +token_duration=1800 + +# teleport proxy to use for authentication. +teleport_proxy = foo.teleport.sh + +# name of the teleport application to use for authentication. +tsh_app_name = test_app_name + +# name of the teleport role to use for authentication. +tsh_aws_role_name = test_aws_role_name +``` + ### Multiple Section Configuration (EXPERIMENTAL) This backend can use multiple sections to select different configuration values. diff --git a/keyrings/clients/__init__.py b/keyrings/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keyrings/clients/boto3.py b/keyrings/clients/boto3.py new file mode 100644 index 0000000..07150db --- /dev/null +++ b/keyrings/clients/boto3.py @@ -0,0 +1,89 @@ +import boto3 +import boto3.session + +import os +from datetime import datetime + +import logging + +logging.getLogger("keyrings.codeartifact") + + +class Boto3CAClient: + def __init__( + self, + region: str = None, + domain: str = None, + account: str = None, + profile_name: str = None, + aws_access_key_id: str = None, + aws_secret_access_key: str = None, + session=None, + token_duration: int = 3600, + ): + """ + Initialize the boto3 codeartifact client. + + :param region: AWS region to use. + :param domain: CodeArtifact domain name. + :param account: AWS account ID. + :param profile_name: AWS profile name (optional). + """ + if region: + self.region = region + else: + self.region = os.getenv("AWS_REGION") + self.domain = domain + self.account = account + self.profile_name = profile_name + self.aws_access_key_id = aws_access_key_id + self.aws_secret_access_key = aws_secret_access_key + self.token_duration = token_duration + if session: + self.session = session + else: + self.session = boto3.session.Session() + + def _get_codeartifact_client(self): + # CodeArtifact requires a region. + kwargs = {"region_name": self.region} + + # If a profile name was provided, use it. + if self.profile_name: + kwargs.update({"profile_name": self.profile_name}) + + # If static access/secret keys were provided, use them. + if self.aws_access_key_id and self.aws_secret_access_key: + kwargs.update( + { + "aws_access_key_id": self.aws_access_key_id, + "aws_secret_access_key": self.aws_secret_access_key, + } + ) + + # Build a CodeArtifact client from the session. + return self.session.client("codeartifact", **kwargs) + + def get_authorization_token(self): + """ + Get the CodeArtifact authorization token. + + :return: Authorization token as a string. + """ + client = self._get_codeartifact_client() + response = client.get_authorization_token( + domain=self.domain, + domainOwner=self.account, + durationSeconds=self.token_duration, + ) + + # Figure out our local timezone from the current time. + tzinfo = datetime.now().astimezone().tzinfo + now = datetime.now(tz=tzinfo) + + # Give up if the token has already expired. + if response.get("expiration", now) <= now: + logging.warning("Received an expired CodeArtifact token!") + return + + return response.get("authorizationToken") diff --git a/keyrings/clients/tsh.py b/keyrings/clients/tsh.py new file mode 100644 index 0000000..d0d3801 --- /dev/null +++ b/keyrings/clients/tsh.py @@ -0,0 +1,132 @@ +import subprocess +import re +import os + +import logging + +logging.getLogger("keyrings.codeartifact") + + +class TeleportCAClient: + def __init__( + self, + account: str, + domain: str, + region: str, + teleport_proxy: str = None, + tsh_app_name: str = None, + tsh_aws_role_name: str = None, + **kwargs, + ): + """ + Initialize the teleport codeartifact client. + This class is responsible for managing the Teleport login and app authentication + to retrieve the CodeArtifact authorization token. + """ + self.region = region + self.domain = domain + self.account = account + self.tsh_aws_role_name = tsh_aws_role_name + self.tsh_app_name = tsh_app_name + self.teleport_proxy = teleport_proxy + + def _get_teleport_path_status(self): + """Check if the tsh command is available in the system path.""" + try: + subprocess.run( + ["tsh", "version"], + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + raise RuntimeError( + "Error checking tsh command. Confirm it is installed and available via PATH: %s" + % e.stderr.strip() + ) from e + + def _get_teleport_login_status(self): + """Check if the user is logged into Teleport.""" + try: + subprocess.run( + ["tsh", "status"], + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + logging.debug("Error checking Teleport login status: %s", e.stderr.strip()) + return False + + def _teleport_login(self): + """Login to Teleport using the tsh command.""" + try: + login_command = f"tsh login --proxy={self.teleport_proxy}" + subprocess.run( + login_command.split(" "), + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + logging.debug("Error logging into Teleport: %s", e.stderr.strip()) + return False + + def _get_teleport_app_auth_status(self): + """Check if the user is authenticated for the Teleport app.""" + try: + subprocess.run( + ["tsh", "app", "config", f"{self.tsh_app_name}"], + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + logging.debug( + "Error checking Teleport app auth status: %s", e.stderr.strip() + ) + return False + + def _teleport_app_login(self): + """Login to the Teleport app using the tsh command.""" + try: + login_command = ( + f"tsh app login {self.tsh_app_name} --aws-role {self.tsh_aws_role_name}" + ) + subprocess.run( + login_command.split(" "), + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + logging.debug("Error logging into Teleport app: %s", e.stderr.strip()) + return False + + def _get_ca_token(self): + """use tsh prefixed aws cli command to generate codeartifact token""" + try: + get_ca_token_command = f"tsh aws codeartifact get-authorization-token --domain {self.domain} --domain-owner {self.account} --query authorizationToken --region {self.region} --output text" + get_ca_token_command_list = get_ca_token_command.split(" ") + tsh_output = subprocess.run( + get_ca_token_command_list, + capture_output=True, + text=True, + check=True, + ) + return tsh_output.stdout.strip() + except subprocess.CalledProcessError as e: + logging.debug("Error getting CA token: %s", e.stderr.strip()) + + def get_authorization_token(self): + """Get the CodeArtifact authorization token.""" + if not self._get_teleport_login_status(): + self._teleport_login() + if not self._get_teleport_app_auth_status(): + self._teleport_app_login() + return self._get_ca_token() diff --git a/keyrings/codeartifact.py b/keyrings/codeartifact.py index 3ce201e..5c71b9e 100644 --- a/keyrings/codeartifact.py +++ b/keyrings/codeartifact.py @@ -1,11 +1,9 @@ # codeartifact.py -- keyring backend +import os import re import logging -import boto3 -import boto3.session - from datetime import datetime from urllib.parse import urlparse @@ -15,6 +13,12 @@ from typing import NamedTuple from configparser import RawConfigParser +from .clients.boto3 import Boto3CAClient +from .clients.tsh import TeleportCAClient + + +logging.getLogger("keyrings.codeartifact") + class Qualifier(NamedTuple): domain: str = None @@ -80,7 +84,13 @@ def codeartifact_sections(sections): # Expand the generator into a dictionary. self.config = dict(sections) - def lookup(self, domain=None, account=None, region=None, name=None): + def lookup( + self, + domain=None, + account=None, + region=None, + name=None, + ): key = Qualifier(domain, account, region, name) # Return the defaults if we didn't have anything to look up. @@ -106,7 +116,7 @@ def score(candidate): return self.config.get(found_key) -class CodeArtifactBackend(backend.KeyringBackend): +class key(backend.KeyringBackend): HOST_REGEX = r"^(.+)-(\d{12})\.d\.codeartifact\.([^\.]+)\.amazonaws\.com$" PATH_REGEX = r"^/pypi/([^/]+)/?" @@ -123,11 +133,7 @@ def __init__(self, /, config=None, session=None): config_file = config_root() / "keyringrc.cfg" self.config = CodeArtifactKeyringConfig(config_file) - # Use the boto3 session implementation by default. - if session: - self.session = session - else: - self.session = boto3.session.Session() + self.session = session def get_credential(self, service, username): authorization_token = self.get_password(service, username) @@ -162,33 +168,31 @@ def get_password(self, service, username): # Load our configuration file. config = self.config.lookup( - domain=domain, - account=account, - region=region, - name=repository_name, + domain=domain, account=account, region=region, name=repository_name ) - # Create a CodeArtifact client for this repository's region. - client = self._get_codeartifact_client(config, region) - # Authorization tokens should be good for an hour by default. token_duration = int(config.get("token_duration", 3600)) + config["token_duration"] = token_duration + config["domain"] = domain + config["account"] = account + config["region"] = region + + if self.session: + # If a session was provided, use it. + config["session"] = self.session + + # allow boto3 client override from environment or config + if ( + config.get("default_client") == "tsh" + or os.getenv("CA_KEYRING_CLIENT") == "tsh" + ): + client = TeleportCAClient(**config) + else: + client = Boto3CAClient(**config) # Ask for an authorization token using the current AWS credentials. - response = client.get_authorization_token( - domain=domain, domainOwner=account, durationSeconds=token_duration - ) - - # Figure out our local timezone from the current time. - tzinfo = datetime.now().astimezone().tzinfo - now = datetime.now(tz=tzinfo) - - # Give up if the token has already expired. - if response.get("expiration", now) <= now: - logging.warning("Received an expired CodeArtifact token!") - return - - return response.get("authorizationToken") + return client.get_authorization_token() def set_password(self, service, username, password): # Defer setting a password to the next backend @@ -197,24 +201,3 @@ def set_password(self, service, username, password): def delete_password(self, service, username): # Defer deleting a password to the next backend raise NotImplementedError() - - def _get_codeartifact_client(self, /, config, region): - # CodeArtifact requires a region. - kwargs = {"region_name": region} - - # If a profile name was provided, use it. - profile_name = config.get("profile_name") - if profile_name: - kwargs.update({"profile_name": profile_name}) - - # 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") - if aws_access_key_id and aws_secret_access_key: - kwargs.update({ - "aws_access_key_id": aws_access_key_id, - "aws_secret_access_key": aws_secret_access_key, - }) - - # Build a CodeArtifact client from the session. - return self.session.client("codeartifact", **kwargs) diff --git a/keyrings/observability.py b/keyrings/observability.py new file mode 100644 index 0000000..f6788ad --- /dev/null +++ b/keyrings/observability.py @@ -0,0 +1,8 @@ +import os +import logging + +# create logger with 'spam_application' +logger = logging.getLogger("keyrings.codeartifact") + +log_level = os.getenv("LOG_LEVEL", "INFO").upper() +logging.basicConfig(level=log_level) diff --git a/tests/config/multiple_sections_with_default.cfg b/tests/config/multiple_sections_with_default.cfg index de25f26..d9f9b85 100644 --- a/tests/config/multiple_sections_with_default.cfg +++ b/tests/config/multiple_sections_with_default.cfg @@ -23,3 +23,10 @@ profile_name = testing_profile [codeartifact account="000000000000" name="production"] profile_name = production_profile + +# teleport +[codeartifact account="000000000000" name="teleport"] +default_client = tsh +teleport_proxy = foo.teleport.sh +tsh_app_name = bar +tsh_aws_role_name = baz diff --git a/tests/config/single_section.cfg b/tests/config/single_section_boto3.cfg similarity index 100% rename from tests/config/single_section.cfg rename to tests/config/single_section_boto3.cfg diff --git a/tests/config/single_section_tsh.cfg b/tests/config/single_section_tsh.cfg new file mode 100644 index 0000000..457484b --- /dev/null +++ b/tests/config/single_section_tsh.cfg @@ -0,0 +1,8 @@ +[codeartifact] +account = 000000000000 +default_client = tsh +region = us-west-2 +teleport_proxy = foo.teleport.sh +token_duration = 1800 +tsh_app_name = test_app_name +tsh_aws_role_name = test_aws_role_name diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..105825b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from keyrings.clients.tsh import TeleportCAClient + + +@pytest.fixture +def teleport_client(): + return TeleportCAClient( + account="123456789012", + domain="my-domain", + region="us-west-2", + teleport_proxy="teleport.example.com:443", + tsh_app_name="my-app", + tsh_aws_role_name="my-role", + ) diff --git a/tests/test_backend.py b/tests/test_backend_boto3.py similarity index 97% rename from tests/test_backend.py rename to tests/test_backend_boto3.py index 661034a..b5e8e24 100644 --- a/tests/test_backend.py +++ b/tests/test_backend_boto3.py @@ -111,7 +111,7 @@ def test_get_credential_invalid_path(default_backend, service): assert not keyring.get_credential(service, None) -def test_get_credential_supported_host(): +def test_get_credential_supported_host_boto3(): session = StubbingSession(region_name=REGION_NAME) client = session.client("codeartifact", region_name=REGION_NAME) @@ -140,4 +140,4 @@ def test_get_credential_supported_host(): assert credentials.username == "aws" assert credentials.password == "TOKEN" - client.stub.assert_no_pending_responses() \ No newline at end of file + client.stub.assert_no_pending_responses() diff --git a/tests/test_backend_tsh.py b/tests/test_backend_tsh.py new file mode 100644 index 0000000..d720019 --- /dev/null +++ b/tests/test_backend_tsh.py @@ -0,0 +1,136 @@ +import subprocess + +from unittest.mock import patch, MagicMock +import pytest + +from keyrings.clients.tsh import TeleportCAClient + + +@patch("subprocess.run") +def test_get_teleport_path_status_success(mock_run, teleport_client): + # Mock subprocess.run to return successfully + mock_process = MagicMock() + mock_process.returncode = 0 + mock_run.return_value = mock_process + + # Call the method + result = teleport_client._get_teleport_path_status() + + # Verify the result + assert result is True + mock_run.assert_called_once_with( + ["tsh", "version"], capture_output=True, text=True, check=True + ) + + +@patch("subprocess.run") +def test_get_teleport_path_status_failure(mock_run, teleport_client): + # Mock subprocess.run to raise CalledProcessError + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, cmd=["tsh", "version"], stderr="tsh: command not found" + ) + + # Call the method and verify it raises the expected exception + with pytest.raises(RuntimeError) as excinfo: + teleport_client._get_teleport_path_status() + + assert "Error checking tsh command" in str(excinfo.value) + + +@patch("subprocess.run") +def test_get_teleport_login_status_success(mock_run, teleport_client): + # Mock subprocess.run to return successfully + mock_process = MagicMock() + mock_process.returncode = 0 + mock_run.return_value = mock_process + + # Call the method + result = teleport_client._get_teleport_login_status() + + # Verify the result + assert result is True + mock_run.assert_called_once_with( + ["tsh", "status"], capture_output=True, text=True, check=True + ) + + +@patch("subprocess.run") +def test_get_teleport_login_status_failure(mock_run, teleport_client): + # Mock subprocess.run to raise CalledProcessError + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, cmd=["tsh", "status"], stderr="Not logged in" + ) + + # Call the method + result = teleport_client._get_teleport_login_status() + + # Verify the result + assert result is False + + +@patch("subprocess.run") +def test_get_ca_token_success(mock_run, teleport_client): + # Mock subprocess.run to return successfully with a token + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.stdout = "mock-token-value" + mock_run.return_value = mock_process + + # Call the method + result = teleport_client._get_ca_token() + + # Verify the result + assert result == "mock-token-value" + mock_run.assert_called_once_with( + [ + "tsh", + "aws", + "codeartifact", + "get-authorization-token", + "--domain", + "my-domain", + "--domain-owner", + "123456789012", + "--query", + "authorizationToken", + "--region", + "us-west-2", + "--output", + "text", + ], + capture_output=True, + text=True, + check=True, + ) + + +@patch.object(TeleportCAClient, "_get_teleport_login_status") +@patch.object(TeleportCAClient, "_teleport_login") +@patch.object(TeleportCAClient, "_get_teleport_app_auth_status") +@patch.object(TeleportCAClient, "_teleport_app_login") +@patch.object(TeleportCAClient, "_get_ca_token") +def test_get_authorization_token_flow( + mock_get_ca_token, + mock_teleport_app_login, + mock_get_teleport_app_auth_status, + mock_teleport_login, + mock_get_teleport_login_status, + teleport_client, +): + # Configure mocks for full flow + mock_get_teleport_login_status.return_value = False + mock_teleport_login.return_value = True + mock_get_teleport_app_auth_status.return_value = False + mock_teleport_app_login.return_value = True + mock_get_ca_token.return_value = "mock-token-value" + + # Call the method + result = teleport_client.get_authorization_token() + + # Verify the result and that all methods were called + assert result == "mock-token-value" + mock_get_teleport_login_status.assert_called_once() + mock_teleport_login.assert_called_once() + mock_get_teleport_app_auth_status.assert_called_once() + mock_teleport_app_login.assert_called_once() + mock_get_ca_token.assert_called_once() diff --git a/tests/test_config.py b/tests/test_config.py index 211e2da..db3fd82 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,8 +26,8 @@ def _config_file(path): ("domain", "00000000", "ca-central-1", "repository"), ], ) -def test_parse_single_section_only(config_file, parameters): - config = CodeArtifactKeyringConfig(config_file("single_section.cfg")) +def test_parse_single_section_only_boto3(config_file, parameters): + config = CodeArtifactKeyringConfig(config_file("single_section_boto3.cfg")) # A single section has only one configuration. values = config.lookup(*parameters) @@ -38,6 +38,28 @@ def test_parse_single_section_only(config_file, parameters): assert values.get("aws_secret_access_key") == "default_access_secret_key" +@pytest.mark.parametrize( + "parameters", + [ + (None, None, None, None), + ("domain", "owner", "region", "name"), + ("domain", "00000000", "ca-central-1", "repository"), + ], +) +def test_parse_single_section_only_tsh(config_file, parameters): + config = CodeArtifactKeyringConfig(config_file("single_section_tsh.cfg")) + + # A single section has only one configuration. + values = config.lookup(*parameters) + + assert values.get("token_duration") == "1800" + assert values.get("account") == "000000000000" + assert values.get("default_client") == "tsh" + assert values.get("teleport_proxy") == "foo.teleport.sh" + assert values.get("tsh_app_name") == "test_app_name" + assert values.get("tsh_aws_role_name") == "test_aws_role_name" + + @pytest.mark.parametrize( "config_data", [ @@ -87,6 +109,16 @@ def test_bogus_config_returns_empty_configuration(config_data): {"account": "000000000000", "name": "production"}, {"token_duration": "1800", "profile_name": "production_profile"}, ), + ( + {"account": "000000000000", "name": "teleport"}, + { + "token_duration": "1800", + "default_client": "tsh", + "teleport_proxy": "foo.teleport.sh", + "tsh_app_name": "bar", + "tsh_aws_role_name": "baz", + }, + ), ], ) def test_multiple_sections_with_defaults(config_file, query, expected):