diff --git a/.flake8 b/.flake8 index d4497132a4..070b331964 100644 --- a/.flake8 +++ b/.flake8 @@ -111,19 +111,20 @@ per-file-ignores = tests/**.py: DAR, DCO020, S101, S105, S108, S404, S603, WPS202, WPS210, WPS430, WPS436, WPS441, WPS442, WPS450 # The following ignores must be fixed and the entries removed from this config: - src/awx_plugins/credentials/aim.py: ANN003, ANN201, B950, CCR001, D100, D103, LN001, Q003, WPS210, WPS221, WPS223, WPS231, WPS336, WPS432 - src/awx_plugins/credentials/aws_secretsmanager.py: ANN003, ANN201, D100, D103, WPS111, WPS210, WPS329, WPS529 - src/awx_plugins/credentials/azure_kv.py: ANN003, ANN201, D100, D103, WPS111, WPS361, WPS421 - src/awx_plugins/credentials/centrify_vault.py: ANN003, ANN201, D100, D103, N802, P101, WPS210, WPS229 - src/awx_plugins/credentials/conjur.py: ANN003, ANN201, B950, D100, D103, E800, P101, WPS111, WPS210, WPS229, WPS432, WPS440 - src/awx_plugins/credentials/dsv.py: ANN003, ANN201, D100, D103, P103, WPS210 - src/awx_plugins/credentials/hashivault.py: ANN003, ANN201, B950, C901, CCR001, D100, D103, LN001, N400, WPS202, WPS204, WPS210, WPS221, WPS223, WPS229, WPS231, WPS232, WPS331, WPS336, WPS337, WPS432, WPS454 - src/awx_plugins/credentials/injectors.py: ANN001, ANN201, ANN202, C408, D100, D103, WPS110, WPS111, WPS202, WPS210, WPS347, WPS433, WPS440 + src/awx_plugins/credentials/aim.py: ANN003, ANN201, B950, CCR001, D100, D103, LN001, Q003, WPS210, WPS221, WPS223, WPS226, WPS231, WPS336, WPS432 + src/awx_plugins/credentials/aws_secretsmanager.py: ANN003, ANN201, D100, D103, WPS111, WPS210, WPS226, WPS329, WPS529 + src/awx_plugins/credentials/aws_assumerole.py: WPS226, WPS332 + src/awx_plugins/credentials/azure_kv.py: ANN003, ANN201, D100, D103, WPS111, WPS226, WPS361, WPS421 + src/awx_plugins/credentials/centrify_vault.py: ANN003, ANN201, D100, D103, N802, P101, WPS210, WPS226, WPS229 + src/awx_plugins/credentials/conjur.py: ANN003, ANN201, B950, D100, D103, E800, P101, WPS111, WPS210, WPS226, WPS229, WPS432, WPS440 + src/awx_plugins/credentials/dsv.py: ANN003, ANN201, D100, D103, P103, WPS210, WPS226 + src/awx_plugins/credentials/hashivault.py: ANN003, ANN201, B950, C901, CCR001, D100, D103, LN001, N400, WPS202, WPS204, WPS210, WPS221, WPS223, WPS226, WPS229, WPS231, WPS232, WPS331, WPS336, WPS337, WPS432, WPS454 + src/awx_plugins/credentials/injectors.py: ANN001, ANN201, ANN202, C408, D100, D103, WPS110, WPS111, WPS202, WPS210, WPS226, WPS347, WPS433, WPS440 src/awx_plugins/credentials/plugin.py: ANN001, ANN002, ANN101, ANN201, ANN204, B010, D100, D101, D103, D105, D107, D205, D400, E731, WPS115, WPS432, WPS433, WPS440, WPS442, WPS601 src/awx_plugins/credentials/plugins.py: B950,D100, D101, D103, D105, D107, D205, D400, LN001, WPS204, WPS229, WPS433, WPS440 src/awx_plugins/credentials/tss.py: ANN003, ANN201, D100, D103, E712, WPS433, WPS440, WPS503 src/awx_plugins/inventory/plugins.py: ANN001, ANN002, ANN003, ANN101, ANN102, ANN201, ANN202, ANN206, B950, C812, C819, D100, D101, D102, D205, D209, D400, D401, LN001, LN002, N801, WPS110, WPS111, WPS202, WPS210, WPS214, WPS301, WPS319, WPS324, WPS331, WPS336, WPS337, WPS338, WPS347, WPS421, WPS433, WPS450, WPS510, WPS529 - tests/credential_plugins_test.py: ANN101, B017, C419, D100, D102, D103, D205, D209, D400, DAR, PT011, S105, WPS111, WPS117, WPS118, WPS202, WPS352, WPS421, WPS433, WPS507 + tests/credential_plugins_test.py: ANN101, B017, C419, D100, D102, D103, D205, D209, D400, DAR, PT011, S105, WPS111, WPS117, WPS118, WPS202, WPS352, WPS421, WPS430, WPS433, WPS507 tests/importable_test.py: ANN101, DAR # Count the number of occurrences of each error/warning code and print a report: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd26c0bcad..3e9cbb9f1a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -197,7 +197,8 @@ repos: @ git+https://github.com/ansible/awx_plugins.interfaces.git@ad4a965 - azure-identity # needed by credentials.azure_kv - azure-keyvault # needed by credentials.azure_kv - - boto3-stubs # needed by credentials.awx_secretsmanager + - boto3-stubs[sts] # needed by credentials.awx_secretsmanager + - botocore-stubs # needed by credentials.awx_secretsmanager - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` - msrestazure # needed by credentials.azure_kv - pytest @@ -227,7 +228,8 @@ repos: @ git+https://github.com/ansible/awx_plugins.interfaces.git@ad4a965 - azure-identity # needed by credentials.azure_kv - azure-keyvault # needed by credentials.azure_kv - - boto3-stubs # needed by credentials.awx_secretsmanager + - boto3-stubs[sts] # needed by credentials.awx_secretsmanager + - botocore-stubs # needed by credentials.awx_secretsmanager - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` - msrestazure # needed by credentials.azure_kv - pytest @@ -257,7 +259,8 @@ repos: @ git+https://github.com/ansible/awx_plugins.interfaces.git@ad4a965 - azure-identity # needed by credentials.azure_kv - azure-keyvault # needed by credentials.azure_kv - - boto3-stubs # needed by credentials.awx_secretsmanager + - boto3-stubs[sts] # needed by credentials.awx_secretsmanager + - botocore-stubs # needed by credentials.awx_secretsmanager - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` - msrestazure # needed by credentials.azure_kv - pytest diff --git a/docs/conf.py b/docs/conf.py index d219194bf4..1cfd3a744d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,6 +7,15 @@ from pathlib import Path from tomllib import loads as _parse_toml +from sphinx.addnodes import pending_xref +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment + + +# isort: split + +from docutils.nodes import literal, reference + # -- Path setup -------------------------------------------------------------- @@ -195,3 +204,48 @@ nitpick_ignore = [ # temporarily listed ('role', 'reference') pairs that Sphinx cannot resolve ] + + +def _replace_missing_boto3_reference( + app: Sphinx, + env: BuildEnvironment, + node: pending_xref, + contnode: literal, +) -> reference | None: + if (node.get('refdomain'), node.get('reftype')) != ('py', 'class'): + return None + + boto3_type_uri_map = { + 'AssumeRoleResponseTypeDef': 'type_defs/#assumeroleresponsetypedef', + 'CredentialsTypeDef': 'type_defs/#credentialstypedef', + 'STSClient': 'client/#stsclient', + } + ref_target = node.get('reftarget', '') + + try: + return reference( + ref_target, + ref_target, + internal=False, + refuri='https://youtype.github.io/boto3_stubs_docs/' + f'mypy_boto3_sts/{boto3_type_uri_map[ref_target]}', + ) + except KeyError: + return None + + +def setup(app: Sphinx) -> dict[str, bool | str]: + """Register project-local Sphinx extension-API customizations. + + :param app: Initialized Sphinx app instance. + :type app: Sphinx + :returns: Extension metadata. + :rtype: dict[str, bool | str] + """ + app.connect('missing-reference', _replace_missing_boto3_reference) + + return { + 'parallel_read_safe': True, + 'parallel_write_safe': True, + 'version': release, + } diff --git a/pyproject.toml b/pyproject.toml index bdbdf64e1b..616a34efe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,13 @@ dependencies = [ # runtime deps # https://packaging.python.org/en/latest/guide "PyYAML", # credentials.injectors, inventory.plugins "azure-identity", # credentials.azure_kv "azure-keyvault", # credentials.azure_kv - "boto3", # credentials.awx_secretsmanager + "boto3", # credentials.aws_assume_role, credentials.awx_secretsmanager + "botocore", # credentials.aws_assume_role, credentials.awx_secretsmanager "msrestazure", # credentials.azure_kv "python-dsv-sdk >= 1.0.4", # credentials.thycotic_dsv "python-tss-sdk >= 1.2.1", # credentials.thycotic_tss "requests", # credentials.aim, credentials.centrify_vault, credentials.conjur, credentials.hashivault + "botocore.execeptions", # credentials.aws_assume_role ] classifiers = [ # Allowlist: https://pypi.org/classifiers/ "Development Status :: 1 - Planning", @@ -83,6 +85,7 @@ centrify_vault_kv = "awx_plugins.credentials.centrify_vault:centrify_plugin" thycotic_dsv = "awx_plugins.credentials.dsv:dsv_plugin" thycotic_tss = "awx_plugins.credentials.tss:tss_plugin" aws_secretsmanager_credential = "awx_plugins.credentials.aws_secretsmanager:aws_secretmanager_plugin" +aws_assume_role = "awx_plugins.credentials.aws_assume_role:aws_assume_role_plugin" [project.entry-points."awx_plugins.inventory"] # new entry points group name azure-rm = "awx_plugins.inventory.plugins:azure_rm" diff --git a/src/awx_plugins/credentials/aws_assumerole.py b/src/awx_plugins/credentials/aws_assumerole.py new file mode 100644 index 0000000000..58d216369b --- /dev/null +++ b/src/awx_plugins/credentials/aws_assumerole.py @@ -0,0 +1,182 @@ +"""This module provides integration with AWS AssumeRole functionality.""" + +import hashlib +import typing +from datetime import datetime + +from awx_plugins.interfaces._temporary_private_django_api import ( # noqa: WPS436 + gettext_noop as _, +) + +import boto3 +from botocore.exceptions import ClientError + + +if typing.TYPE_CHECKING: + from mypy_boto3_sts.client import STSClient + from mypy_boto3_sts.type_defs import ( + AssumeRoleResponseTypeDef, + CredentialsTypeDef, + ) + +from .plugin import CredentialPlugin + + +_aws_cred_cache: dict[ + str, + 'CredentialsTypeDef | dict[typing.Never, typing.Never]', +] | dict[typing.Never, typing.Never] = {} + + +assume_role_inputs = { + 'fields': [ + { + 'id': 'access_key', + 'label': _('AWS Access Key'), + 'type': 'string', + 'secret': True, + 'help_text': _( + 'The optional AWS access key for' + 'the user who will assume the role.', + ), + }, + { + 'id': 'secret_key', + 'label': 'AWS Secret Key', + 'type': 'string', + 'secret': True, + 'help_text': _( + 'The optional AWS secret key for the' + 'user who will assume the role.', + ), + }, + { + 'id': 'external_id', + 'label': 'External ID', + 'type': 'string', + 'help_text': _( + 'The optional External ID which will' + 'be provided to the assume role API.', + ), + }, + ], + 'metadata': [ + { + 'id': 'identifier', + 'label': 'Identifier', + 'type': 'string', + 'help_text': _( + 'The name of the key in the assumed AWS role' + 'to fetch [AccessKeyId | SecretAccessKey | SessionToken].', + ), + }, + ], + 'required': [ + 'role_arn', + ], +} + + +def aws_assumerole_getcreds( + access_key: str | None, + secret_key: str | None, + role_arn: str, + external_id: int, +) -> 'CredentialsTypeDef | dict[typing.Never, typing.Never]': + """Return the credentials for use. + + :param access_key: The AWS access key ID. + :type access_key: str + :param secret_key: The AWS secret access key. + :type secret_key: str + :param role_arn: The ARN received from AWS. + :type role_arn: str + :param external_id: The external ID received from AWS. + :type external_id: int + :returns: The credentials received from AWS. + :rtype: dict + :raises ValueError: If the client response is bad. + """ + connection: 'STSClient' = boto3.client( + service_name='sts', + # The following EE creds are read from the env if they are not passed: + aws_access_key_id=access_key, # defaults to `None` in the lib + aws_secret_access_key=secret_key, # defaults to `None` in the lib + ) + try: + response: 'AssumeRoleResponseTypeDef' = connection.assume_role( + RoleArn=role_arn, + RoleSessionName='AAP_AWS_Role_Session1', + ExternalId=external_id, + ) + except ClientError as client_err: + raise ValueError( + f'Got a bad client response from AWS: {client_err.message}.', + ) from client_err + + return response.get('Credentials', {}) + + +def aws_assumerole_backend( + access_key: str | None, + secret_key: str | None, + role_arn: str, + external_id: int, + identifier: str, +) -> dict: + """Contact AWS to assume a given role for the user. + + :param access_key: The AWS access key ID. + :type access_key: str + :param secret_key: The AWS secret access key. + :type secret_key: str + :param role_arn: The ARN received from AWS. + :type role_arn: str + :param external_id: The external ID received from AWS. + :type external_id: int + :param identifier: The identifier to fetch from the assumed role. + :type identifier: str + :raises ValueError: If the identifier is not found. + :returns: The identifier fetched from the assumed role. + :rtype: dict + """ + # Generate a unique SHA256 hash for combo of user access key and ARN + # This should allow two users requesting the same ARN role to have + # separate credentials, and should allow the same user to request + # multiple roles. + credential_key_hash = hashlib.sha256( + (str(access_key or '') + role_arn).encode('utf-8'), + ) + credential_key = credential_key_hash.hexdigest() + + credentials = _aws_cred_cache.get(credential_key, {}) + + # If there are no credentials for this user/ARN *or* the credentials + # we have in the cache have expired, then we need to contact AWS again. + creds_expired = ( + (creds_expire_at := credentials.get('Expiration')) and + creds_expire_at < datetime.now(credentials['Expiration'].tzinfo) + ) + if creds_expired: + + credentials = aws_assumerole_getcreds( + access_key, secret_key, role_arn, external_id, + ) + + _aws_cred_cache[credential_key] = credentials + + credentials = _aws_cred_cache.get(credential_key, {}) + + try: + return credentials[identifier] + except KeyError as key_err: + raise ValueError( + f'Could not find a value for {identifier}.', + ) from key_err + + +aws_assumerole_plugin = CredentialPlugin( + 'AWS Assume Role Plugin', + inputs=assume_role_inputs, + backend=aws_assumerole_backend, +) diff --git a/tests/credential_plugins_test.py b/tests/credential_plugins_test.py index 2c2f3eae67..34c61becf6 100644 --- a/tests/credential_plugins_test.py +++ b/tests/credential_plugins_test.py @@ -1,11 +1,12 @@ # FIXME: the following violations must be addressed gradually and unignored # mypy: disable-error-code="no-untyped-call" +import datetime from unittest import mock import pytest -from awx_plugins.credentials import hashivault +from awx_plugins.credentials import aws_assumerole, hashivault def test_imported_azure_cloud_sdk_vars() -> None: @@ -134,6 +135,68 @@ def test_hashivault_handle_auth_not_enough_args() -> None: hashivault.handle_auth() +@pytest.mark.parametrize( + 'explicit_creds', + ( + { + 'access_key': 'my_access_key', + 'secret_key': 'my_secret_key', + }, + {}, + ), + ids=('with-creds-args', 'with-env-creds'), +) +@pytest.mark.parametrize( + ( + 'identifier_key', + 'expected', + ), + ( + (None, 'the_access_token'), + ('access_key', 'the_access_key'), + ('secret_key', 'the_secret_key'), + ), + ids=( + 'access-token', + 'access-key', + 'secret-key', + ), +) +def test_aws_assumerole_identifier( + monkeypatch: pytest.MonkeyPatch, + explicit_creds: dict[str, str], identifier_key: str | None, expected: str, +) -> None: + """Test that the aws_assumerole_backend function call returns a token given + the access_key and secret_key.""" + + def mock_getcreds( + access_key: str | None, + secret_key: str | None, + role_arn: str, + external_id: int, + ) -> dict: + return { + 'access_key': 'the_access_key', + 'secret_key': 'the_secret_key', + 'access_token': 'the_access_token', + 'Expiration': datetime.datetime.today() + datetime.timedelta(days=1), + } + + monkeypatch.setattr( + aws_assumerole, + 'aws_assumerole_getcreds', + mock_getcreds, + ) + + token = aws_assumerole.aws_assumerole_backend( + external_id=42, + identifier=identifier_key or 'access_token', + role_arn='the_arn', + **explicit_creds, + ) + assert token == expected + + class TestDelineaImports: """These module have a try-except for ImportError which will allow using the older library but we do not want the awx_devel image to have the older diff --git a/tests/importable_test.py b/tests/importable_test.py index 851aef15b6..64e826d5a5 100644 --- a/tests/importable_test.py +++ b/tests/importable_test.py @@ -70,6 +70,11 @@ def __str__(self) -> str: 'aws_secretsmanager_credential', 'awx_plugins.credentials.aws_secretsmanager:aws_secretmanager_plugin', ), + EntryPointParam( + 'awx_plugins.credentials', + 'aws_assumerole', + 'awx_plugins.credentials:aws_assumerole_plugin', + ), )