From 071187ba3bcb153471f26d44f2665340b0c566d3 Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:06:24 -0400 Subject: [PATCH] smithy-aws-core: Add modular credential chain for AWS identity resolution --- ...ture-b59653bc7c5e42dcb0e64bf9a2d066aa.json | 4 + packages/smithy-aws-core/pyproject.toml | 6 + .../src/smithy_aws_core/config/__init__.py | 21 ++ .../src/smithy_aws_core/identity/__init__.py | 14 ++ .../src/smithy_aws_core/identity/chain.py | 24 -- .../identity/chain/__init__.py | 228 +++++++++++++++++ .../identity/chain/exceptions.py | 63 +++++ .../identity/chain/ordering.py | 73 ++++++ .../identity/chain/provider.py | 133 ++++++++++ .../identity/chain/providers/__init__.py | 2 + .../identity/chain/providers/environment.py | 41 ++++ .../identity/chain/providers/profile.py | 91 +++++++ .../identity/chain/providers/shared_config.py | 41 ++++ .../smithy_aws_core/identity/environment.py | 2 +- .../src/smithy_aws_core/identity/static.py | 12 +- .../tests/unit/identity/chain/__init__.py | 2 + .../unit/identity/chain/providers/__init__.py | 2 + .../unit/identity/chain/providers/conftest.py | 52 ++++ .../chain/providers/test_environment.py | 66 +++++ .../identity/chain/providers/test_profile.py | 118 +++++++++ .../chain/providers/test_shared_config.py | 108 ++++++++ .../tests/unit/identity/chain/test_chain.py | 231 ++++++++++++++++++ .../unit/identity/chain/test_ordering.py | 138 +++++++++++ .../unit/identity/chain/test_provider.py | 98 ++++++++ ...ntials_resolver.py => test_environment.py} | 0 .../tests/unit/identity/test_static.py | 48 ++++ 26 files changed, 1591 insertions(+), 27 deletions(-) create mode 100644 packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-b59653bc7c5e42dcb0e64bf9a2d066aa.json delete mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/__init__.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/exceptions.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/ordering.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/provider.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/__init__.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/environment.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/profile.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/shared_config.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/__init__.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/__init__.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/conftest.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/test_environment.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/test_profile.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/test_shared_config.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/test_chain.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/test_ordering.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/test_provider.py rename packages/smithy-aws-core/tests/unit/identity/{test_environment_credentials_resolver.py => test_environment.py} (100%) create mode 100644 packages/smithy-aws-core/tests/unit/identity/test_static.py diff --git a/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-b59653bc7c5e42dcb0e64bf9a2d066aa.json b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-b59653bc7c5e42dcb0e64bf9a2d066aa.json new file mode 100644 index 000000000..41560ae59 --- /dev/null +++ b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-b59653bc7c5e42dcb0e64bf9a2d066aa.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added a modular credential chain for AWS identity resolution. `IdentityChain.create()` discovers installed chain providers via entry points, orders them by precedence, and assembles a resolver chain. Initially ships with the Environment, SharedConfig, ProfileSessionKeys, and ProfileStaticKeys providers." +} diff --git a/packages/smithy-aws-core/pyproject.toml b/packages/smithy-aws-core/pyproject.toml index c4d481756..75a76c323 100644 --- a/packages/smithy-aws-core/pyproject.toml +++ b/packages/smithy-aws-core/pyproject.toml @@ -37,6 +37,12 @@ dependencies = [ "Code" = "https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-aws-core/" "Issue tracker" = "https://github.com/smithy-lang/smithy-python/issues" +[project.entry-points."smithy_aws_core.identity.chain_providers"] +Environment = "smithy_aws_core.identity.chain.providers.environment:EnvironmentCredentialsProvider" +SharedConfig = "smithy_aws_core.identity.chain.providers.shared_config:SharedConfigProvider" +ProfileSessionKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileSessionCredentialsProvider" +ProfileStaticKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileStaticCredentialsProvider" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py index bbb4bc282..3eccc6b87 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py @@ -74,3 +74,24 @@ async def load_config( std_credentials = standardize(raw_credentials, FileType.CREDENTIALS) return MergedConfig(std_config, std_credentials) + + +def shared_config_files_exist( + config_file_path: Path | None = None, + credentials_file_path: Path | None = None, +) -> bool: + """Return whether either the shared config or credentials file exists. + + A cheap filesystem check (no parsing) used to detect whether the shared + config credential source appears configured. + + :param config_file_path: Override path for config file. + Defaults to AWS_CONFIG_FILE env var or ~/.aws/config. + :param credentials_file_path: Override path for credentials file. + Defaults to AWS_SHARED_CREDENTIALS_FILE env var or ~/.aws/credentials. + :returns: True if either file exists on disk. + """ + config_path, credentials_path = _resolve_config_paths( + config_file_path, credentials_file_path + ) + return config_path.is_file() or credentials_path.is_file() diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index 1b310e5bd..7db4add9c 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -2,6 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 from smithy_core.types import PropertyKey +from .chain import IdentityChain, IdentityChainError, UnclaimedSource +from .chain.providers.environment import EnvironmentCredentialsProvider +from .chain.providers.profile import ( + ProfileSessionCredentialsProvider, + ProfileStaticCredentialsProvider, +) +from .chain.providers.shared_config import SharedConfigProvider from .components import ( AWSCredentialsIdentity, AWSCredentialsResolver, @@ -18,9 +25,16 @@ "AWSCredentialsResolver", "AWSIdentityProperties", "ContainerCredentialsResolver", + "EnvironmentCredentialsProvider", "EnvironmentCredentialsResolver", "IMDSCredentialsResolver", + "IdentityChain", + "IdentityChainError", + "ProfileSessionCredentialsProvider", + "ProfileStaticCredentialsProvider", + "SharedConfigProvider", "StaticCredentialsResolver", + "UnclaimedSource", ) AWS_IDENTITY_CONFIG = PropertyKey(key="config", value_type=AWSIdentityConfig) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain.py deleted file mode 100644 index 68a70eda6..000000000 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 -from smithy_core.aio.identity import ChainedIdentityResolver -from smithy_http.aio.interfaces import HTTPClient - -from .components import ( - AWSCredentialsIdentity, - AWSCredentialsResolver, - AWSIdentityProperties, -) -from .environment import EnvironmentCredentialsResolver -from .imds import IMDSCredentialsResolver -from .static import StaticCredentialsResolver - - -def create_default_chain(http_client: HTTPClient) -> AWSCredentialsResolver: - """Creates the default AWS credential provider chain.""" - return ChainedIdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties]( - resolvers=( - StaticCredentialsResolver(), - EnvironmentCredentialsResolver(), - IMDSCredentialsResolver(http_client=http_client), - ) - ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/__init__.py new file mode 100644 index 000000000..21c557866 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/__init__.py @@ -0,0 +1,228 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import logging +from collections.abc import Callable, Mapping, Sequence +from importlib import metadata +from typing import Any, cast + +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyIdentityError +from smithy_core.interfaces.identity import Identity + +from ...config.merged_config import MergedConfig +from .exceptions import ( + IdentityChainConfigurationError, + IdentityChainError, + IdentityResolverFailure, + UnclaimedSource, +) +from .ordering import ( + After, + Before, + OrderingConstraint, + Standard, + StandardProvider, +) +from .provider import ( + ChainIdentityProvider, + ChainSetup, + NamedResolver, +) + +__all__ = ( + "After", + "Before", + "ChainIdentityProvider", + "ChainSetup", + "IdentityChain", + "IdentityChainConfigurationError", + "IdentityChainError", + "IdentityResolverFailure", + "OrderingConstraint", + "Standard", + "StandardProvider", + "UnclaimedSource", +) + +_CHAIN_PROVIDER_ENTRY_POINT_GROUP = "smithy_aws_core.identity.chain_providers" +logger = logging.getLogger(__name__) + + +def _discover_chain_identity_providers() -> tuple[ChainIdentityProvider, ...]: + discovered: list[ChainIdentityProvider] = [] + for entry_point in metadata.entry_points(group=_CHAIN_PROVIDER_ENTRY_POINT_GROUP): + provider_factory = cast( + Callable[[], ChainIdentityProvider], + entry_point.load(), + ) + discovered.append(provider_factory()) + return tuple(discovered) + + +def _sort_by_ordering( + providers: Sequence[ChainIdentityProvider], +) -> tuple[ChainIdentityProvider, ...]: + if not providers: + return () + slot_indexes = {slot: index for index, slot in enumerate(StandardProvider)} + + def sort_key( + indexed_provider: tuple[int, ChainIdentityProvider], + ) -> tuple[int, int, int]: + discovery_index, provider = indexed_provider + ordering = provider.ordering + + match ordering: + case Before(): + constraint_precedence = 0 + case Standard(): + constraint_precedence = 1 + case After(): + constraint_precedence = 2 + case _: + raise IdentityChainConfigurationError( + f"Provider {type(provider).__name__} returned an unsupported " + f"ordering constraint: {ordering!r}." + ) + + # Slot precedence, constraint precedence, then discovery order + return (slot_indexes[ordering.slot], constraint_precedence, discovery_index) + + indexed_providers = enumerate(providers) + ordered_providers = sorted(indexed_providers, key=sort_key) + return tuple(provider for _, provider in ordered_providers) + + +def _validate_providers(providers: Sequence[ChainIdentityProvider]) -> None: + discovered_names: dict[str, ChainIdentityProvider] = {} + discovered_standard_slots: dict[StandardProvider, ChainIdentityProvider] = {} + + for provider in providers: + if (previous := discovered_names.get(provider.name)) is not None: + raise IdentityChainConfigurationError( + f"Credential providers {type(previous).__name__} and " + f"{type(provider).__name__} use the same name: {provider.name}." + ) + discovered_names[provider.name] = provider + + ordering = provider.ordering + if isinstance(ordering, Standard): + if (previous := discovered_standard_slots.get(ordering.slot)) is not None: + raise IdentityChainConfigurationError( + f"Credential providers {type(previous).__name__} and " + f"{type(provider).__name__} both claim standard slot: " + f"{ordering.slot.name}." + ) + discovered_standard_slots[ordering.slot] = provider + + +def _find_unclaimed_sources( + providers: Sequence[ChainIdentityProvider], +) -> tuple[UnclaimedSource, ...]: + claimed_slots = { + provider.ordering.slot + for provider in providers + if isinstance(provider.ordering, Standard) + } + unclaimed_sources: list[UnclaimedSource] = [] + + for slot in StandardProvider: + if slot in claimed_slots or not slot.is_detected(): + continue + package = slot.module_suggestion + if package: + unclaimed_sources.append( + UnclaimedSource(source_name=slot.canonical_name, package=package) + ) + + return tuple(unclaimed_sources) + + +class IdentityChain[I: Identity](IdentityResolver[I, Mapping[str, Any]]): + """Resolves identities from an assembled sequence of resolvers.""" + + _resolvers: tuple[IdentityResolver[I, Any], ...] + _identity_type: type[I] | None + _unclaimed_sources: tuple[UnclaimedSource, ...] + + def __init__( + self, + resolvers: Sequence[IdentityResolver[I, Any]], + *, + identity_type: type[I] | None = None, + unclaimed_sources: Sequence[UnclaimedSource] = (), + ) -> None: + """Initialize the chain with resolvers in precedence order. + + :param resolvers: Identity resolvers to iterate in precedence order. + :param identity_type: The identity type this chain resolves, or None when + constructing a chain directly without declaring one. + :param unclaimed_sources: Detected-but-unclaimed sources discovered during + chain assembly. This parameter is used by :meth:`create`; omit it when + constructing a chain directly. + """ + self._resolvers = tuple(resolvers) + self._identity_type = identity_type + self._unclaimed_sources = tuple(unclaimed_sources) + + @property + def identity_type(self) -> type[I] | None: + """The identity type this chain resolves, or None if not declared.""" + return self._identity_type + + @staticmethod + async def create[ChainIdentity: Identity]( + identity_type: type[ChainIdentity], + *, + profile_file: MergedConfig | None = None, + profile_name_override: str | None = None, + ) -> "IdentityChain[ChainIdentity]": + """Create an identity chain from discovered providers.""" + discovered_providers = _discover_chain_identity_providers() + _validate_providers(discovered_providers) + providers = _sort_by_ordering(discovered_providers) + setup = ChainSetup( + profile_file=profile_file, + profile_name_override=profile_name_override, + ) + unclaimed_sources = _find_unclaimed_sources(discovered_providers) + + for provider in providers: + setup.set_current_provider(provider) + await provider.setup(identity_type, setup) + if setup.terminal: + break + + for source in unclaimed_sources: + logger.warning(str(source)) + return IdentityChain( + setup.resolvers, + identity_type=identity_type, + unclaimed_sources=unclaimed_sources, + ) + + async def get_identity(self, *, properties: Mapping[str, Any]) -> I: + """Return the first identity resolved by the chain.""" + failures: list[IdentityResolverFailure] = [] + for resolver in self._resolvers: + try: + return await resolver.get_identity(properties=properties) + except SmithyIdentityError as error: + if isinstance(resolver, NamedResolver): + provider_name = resolver.provider_name + failed_resolver = resolver.resolver + else: + provider_name = type(resolver).__name__ + failed_resolver = resolver + failures.append( + IdentityResolverFailure( + provider_name=provider_name, + resolver=failed_resolver, + error=error, + ) + ) + + raise IdentityChainError( + failures=tuple(failures), + unclaimed_sources=self._unclaimed_sources, + ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/exceptions.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/exceptions.py new file mode 100644 index 000000000..5da4a7828 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/exceptions.py @@ -0,0 +1,63 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from dataclasses import dataclass +from typing import Any + +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyError, SmithyIdentityError + + +class IdentityChainConfigurationError(SmithyError): + """Raised when discovered providers violate an assembly invariant.""" + + +@dataclass(frozen=True, kw_only=True) +class UnclaimedSource: + """A detected credential source that no registered provider claims. + + Carries the installable package to suggest so the caller can add the provider + that would claim the source. + """ + + source_name: str + package: str + + def __str__(self) -> str: + return ( + f"{self.source_name} credential source was detected but no provider " + f"claims it; install '{self.package}'." + ) + + +@dataclass(frozen=True, kw_only=True) +class IdentityResolverFailure: + """A failed identity resolution attempt.""" + + provider_name: str + resolver: IdentityResolver[Any, Any] + error: SmithyIdentityError + + +class IdentityChainError(SmithyIdentityError): + """Raised when every resolver in an identity chain misses.""" + + def __init__( + self, + *, + failures: tuple[IdentityResolverFailure, ...], + unclaimed_sources: tuple[UnclaimedSource, ...] = (), + ) -> None: + self.failures = failures + self.unclaimed_sources = unclaimed_sources + if not failures: + message = "No credential providers were discovered." + else: + attempted = "; ".join( + f"{failure.provider_name}: {failure.error}" for failure in failures + ) + message = "Unable to resolve identity from any provider in the chain." + message = f"{message} Providers attempted: {attempted}." + if unclaimed_sources: + suggestions = " ".join(str(source) for source in unclaimed_sources) + message = f"{message} {suggestions}" + super().__init__(message) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/ordering.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/ordering.py new file mode 100644 index 000000000..b02a22d7f --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/ordering.py @@ -0,0 +1,73 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os +from dataclasses import dataclass +from enum import Enum + +from ...config import shared_config_files_exist + + +class StandardProvider(Enum): + """Defines standard AWS credential provider slots in precedence order.""" + + ENVIRONMENT = "Environment", None + WEB_IDENTITY_TOKEN_ENV = "WebIdentityTokenEnv", "aws-credentials-sts" + SHARED_CONFIG = "SharedConfig", None + PROFILE_SESSION_KEYS = "ProfileSessionKeys", None + PROFILE_STATIC_KEYS = "ProfileStaticKeys", None + PROFILE_ASSUME_ROLE = "ProfileAssumeRole", "aws-credentials-sts" + PROFILE_WEB_IDENTITY = "ProfileWebIdentity", "aws-credentials-sts" + PROFILE_SSO_SESSION = "ProfileSsoSession", "aws-credentials-sso" + PROFILE_LOGIN = "Login", "aws-credentials-login" + PROFILE_CREDENTIAL_PROCESS = "ProfileCredentialProcess", None + ECS_CONTAINER = "EcsContainer", "aws-credentials-ecs" + EC2_INSTANCE_METADATA = "Ec2InstanceMetadata", "aws-credentials-imds" + + def __init__( + self, + canonical_name: str, + module_suggestion: str | None, + ) -> None: + self.canonical_name = canonical_name + self.module_suggestion = module_suggestion + + def is_detected(self) -> bool: + """Return whether this slot has a cheap environment detection signal.""" + if self is StandardProvider.ENVIRONMENT: + return bool(os.getenv("AWS_ACCESS_KEY_ID")) + if self is StandardProvider.WEB_IDENTITY_TOKEN_ENV: + return bool(os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")) and bool( + os.getenv("AWS_ROLE_ARN") + ) + if self is StandardProvider.ECS_CONTAINER: + return bool(os.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI")) or bool( + os.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") + ) + if self is StandardProvider.SHARED_CONFIG: + return shared_config_files_exist() + return False + + +@dataclass(frozen=True, kw_only=True) +class Standard: + """Claims a standard provider slot.""" + + slot: StandardProvider + + +@dataclass(frozen=True, kw_only=True) +class Before: + """Positions a provider immediately before a standard provider slot.""" + + slot: StandardProvider + + +@dataclass(frozen=True, kw_only=True) +class After: + """Positions a provider immediately after a standard provider slot.""" + + slot: StandardProvider + + +type OrderingConstraint = Standard | Before | After +"""Positions a provider at, before, or after a standard provider slot.""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/provider.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/provider.py new file mode 100644 index 000000000..8d32425b1 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/provider.py @@ -0,0 +1,133 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping +from dataclasses import dataclass +from typing import Any, Protocol + +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.interfaces.identity import Identity + +from ...config.file_parser import Section +from ...config.merged_config import MergedConfig +from .ordering import OrderingConstraint + + +@dataclass(frozen=True, kw_only=True) +class NamedResolver: + """Associates an identity resolver with its provider name.""" + + provider_name: str + resolver: IdentityResolver[Any, Any] + + async def get_identity(self, *, properties: Mapping[str, Any]) -> Any: + """Resolve an identity using the underlying resolver.""" + return await self.resolver.get_identity(properties=properties) + + +class ChainSetup: + """Tracks shared state and resolvers during chain setup.""" + + def __init__( + self, + *, + profile_file: MergedConfig | None = None, + profile_name_override: str | None = None, + properties: MutableMapping[str, Any] | None = None, + ) -> None: + self._profile_file = profile_file + self._profile_name_override = profile_name_override + self._profile: Section | None = None + self._properties: MutableMapping[str, Any] = ( + {} if properties is None else properties + ) + self._resolvers: list[NamedResolver] = [] + self._current_provider: ChainIdentityProvider | None = None + self._terminal = False + + @property + def profile_file(self) -> MergedConfig | None: + """Return the parsed config and credentials files, if loaded.""" + return self._profile_file + + @property + def profile(self) -> Section | None: + """Return the active profile, if selected.""" + return self._profile + + @property + def profile_name_override(self) -> str | None: + """Return the client-specified profile name, if provided.""" + return self._profile_name_override + + @property + def properties(self) -> MutableMapping[str, Any]: + """Return the property bag shared by custom providers.""" + return self._properties + + @property + def resolvers(self) -> tuple[NamedResolver, ...]: + """Return named resolvers in the order they were added.""" + return tuple(self._resolvers) + + @property + def terminal(self) -> bool: + """Return whether a provider added a terminal resolver.""" + return self._terminal + + def set_current_provider(self, provider: ChainIdentityProvider) -> None: + """Set the provider whose setup method is currently running.""" + if self._terminal: + raise RuntimeError("Cannot change provider after a terminal resolver.") + self._current_provider = provider + + def set_profile_file(self, profile_file: MergedConfig) -> None: + """Set the parsed profile file without overwriting an existing value.""" + if self._profile_file is not None: + raise RuntimeError("Cannot overwrite a profile file already present.") + self._profile_file = profile_file + + def set_profile(self, profile: Section) -> None: + """Set the active profile.""" + self._profile = profile + + def add_resolver(self, resolver: IdentityResolver[Any, Any]) -> None: + """Add a named resolver and continue assembly.""" + if self._terminal: + raise RuntimeError("Cannot add a resolver after a terminal resolver.") + if self._current_provider is None: + raise RuntimeError("Cannot add a resolver without a current provider.") + self._resolvers.append( + NamedResolver( + provider_name=self._current_provider.name, + resolver=resolver, + ) + ) + + def add_terminal_resolver(self, resolver: IdentityResolver[Any, Any]) -> None: + """Add a named resolver and stop assembly.""" + self.add_resolver(resolver) + self._terminal = True + + +class ChainIdentityProvider(Protocol): + """Inspects setup state and conditionally adds identity resolvers.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + ... + + @property + def ordering(self) -> OrderingConstraint: + """Return the provider's chain ordering constraint.""" + ... + + async def setup( + self, + identity_type: type[Identity], + setup: ChainSetup, + ) -> None: + """Add resolvers for the requested identity type.""" + ... diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/environment.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/environment.py new file mode 100644 index 000000000..058e8c265 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/environment.py @@ -0,0 +1,41 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os + +from smithy_core.interfaces.identity import Identity + +from ...components import AWSCredentialsIdentity +from ...environment import EnvironmentCredentialsResolver +from ..ordering import Standard, StandardProvider +from ..provider import ChainSetup + +_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID" +_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" # noqa: S105 + + +class EnvironmentCredentialsProvider: + """Adds an environment resolver when credentials are configured in the environment.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.ENVIRONMENT.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.ENVIRONMENT) + + async def setup( + self, + identity_type: type[Identity], + setup: ChainSetup, + ) -> None: + """Add an environment resolver when env credentials are configured.""" + if identity_type is not AWSCredentialsIdentity: + return + + if not os.getenv(_ACCESS_KEY_ID) or not os.getenv(_SECRET_ACCESS_KEY): + return + + setup.add_terminal_resolver(EnvironmentCredentialsResolver()) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/profile.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/profile.py new file mode 100644 index 000000000..a3ccc133c --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/profile.py @@ -0,0 +1,91 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from smithy_core.interfaces.identity import Identity + +from ....config.file_parser import Section +from ...components import AWSCredentialsIdentity +from ...static import StaticCredentialsResolver +from ..ordering import Standard, StandardProvider +from ..provider import ChainSetup + +_ACCESS_KEY_ID = "aws_access_key_id" +_SECRET_ACCESS_KEY = "aws_secret_access_key" # noqa: S105 +_SESSION_TOKEN = "aws_session_token" # noqa: S105 +_ACCOUNT_ID = "aws_account_id" + + +def _get_string(profile: Section, key: str) -> str | None: + value = profile.properties.get(key) + return value if isinstance(value, str) else None + + +class ProfileSessionCredentialsProvider: + """Adds a resolver for session credentials from the active profile.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.PROFILE_SESSION_KEYS.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.PROFILE_SESSION_KEYS) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + """Add a resolver for complete session credentials from the active profile.""" + if identity_type is not AWSCredentialsIdentity: + return + + profile = setup.profile + if profile is None: + return + + access_key_id = _get_string(profile, _ACCESS_KEY_ID) + secret_access_key = _get_string(profile, _SECRET_ACCESS_KEY) + session_token = _get_string(profile, _SESSION_TOKEN) + if access_key_id is None or secret_access_key is None or session_token is None: + return + + identity = AWSCredentialsIdentity( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + session_token=session_token, + account_id=_get_string(profile, _ACCOUNT_ID), + ) + setup.add_terminal_resolver(StaticCredentialsResolver(identity)) + + +class ProfileStaticCredentialsProvider: + """Adds a resolver for static credentials from the active profile.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.PROFILE_STATIC_KEYS.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.PROFILE_STATIC_KEYS) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + """Add a resolver for complete static credentials from the active profile.""" + if identity_type is not AWSCredentialsIdentity: + return + + profile = setup.profile + if profile is None: + return + + access_key_id = _get_string(profile, _ACCESS_KEY_ID) + secret_access_key = _get_string(profile, _SECRET_ACCESS_KEY) + if access_key_id is None or secret_access_key is None: + return + + identity = AWSCredentialsIdentity( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + account_id=_get_string(profile, _ACCOUNT_ID), + ) + setup.add_terminal_resolver(StaticCredentialsResolver(identity)) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/shared_config.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/shared_config.py new file mode 100644 index 000000000..64dbc211d --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/shared_config.py @@ -0,0 +1,41 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os + +from smithy_core.interfaces.identity import Identity + +from ....config import load_config +from ...components import AWSCredentialsIdentity +from ..ordering import Standard, StandardProvider +from ..provider import ChainSetup + + +class SharedConfigProvider: + """Loads and selects the active AWS profile for downstream providers.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.SHARED_CONFIG.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.SHARED_CONFIG) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + """Load and select the active profile for AWS credentials.""" + if identity_type is not AWSCredentialsIdentity: + return + + profile_file = setup.profile_file + if profile_file is None: + profile_file = await load_config() + setup.set_profile_file(profile_file) + + profile_name = ( + setup.profile_name_override or os.getenv("AWS_PROFILE") or "default" + ) + profile = profile_file.get_profile(profile_name) + if profile is not None: + setup.set_profile(profile) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/environment.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/environment.py index 6db7c72a3..47b09d558 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/environment.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/environment.py @@ -27,7 +27,7 @@ async def get_identity( session_token = os.getenv("AWS_SESSION_TOKEN") account_id = os.getenv("AWS_ACCOUNT_ID") - if access_key_id is None or secret_access_key is None: + if not access_key_id or not secret_access_key: raise SmithyIdentityError( "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required" ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/static.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/static.py index 39f00821c..215e11a03 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/static.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/static.py @@ -9,11 +9,19 @@ class StaticCredentialsResolver( IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] ): - """Resolve Static AWS Credentials.""" + """Resolves static AWS credentials from a fixed identity or request properties.""" + + def __init__(self, identity: AWSCredentialsIdentity | None = None) -> None: + """Initialize the resolver with optional fixed credentials.""" + self._identity = identity async def get_identity( self, *, properties: AWSIdentityProperties ) -> AWSCredentialsIdentity: + """Resolve credentials from the fixed identity or request properties.""" + if self._identity is not None: + return self._identity + access_key_id = properties.get("access_key_id") secret_access_key = properties.get("secret_access_key") if access_key_id is not None and secret_access_key is not None: @@ -23,5 +31,5 @@ async def get_identity( session_token=properties.get("session_token"), ) raise SmithyIdentityError( - "Attempted to resolve AWS crendentials from config, but credentials weren't configured." + "Attempted to resolve AWS credentials from config, but credentials weren't configured." ) diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/__init__.py b/packages/smithy-aws-core/tests/unit/identity/chain/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/__init__.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/conftest.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/conftest.py new file mode 100644 index 000000000..5a9bae4c6 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/conftest.py @@ -0,0 +1,52 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +import pytest +from smithy_aws_core.config.file_parser import Section, StandardizedOutput +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core.interfaces.identity import Identity + + +class OtherIdentity(Identity): + """A non-AWS identity type used to verify providers ignore unknown types.""" + + +@pytest.fixture +def merged_config() -> Callable[..., MergedConfig]: + def _build( + profiles: Mapping[str, Mapping[str, str]] | None = None, + ) -> MergedConfig: + sections = { + name: Section(properties=dict(properties)) + for name, properties in (profiles or {}).items() + } + return MergedConfig(StandardizedOutput(profiles=sections), StandardizedOutput()) + + return _build + + +@pytest.fixture +def setup_provider() -> Callable[..., Awaitable[ChainSetup]]: + async def _setup( + provider: Any, + *, + identity_type: type[Identity] = AWSCredentialsIdentity, + profile: Section | None = None, + profile_file: MergedConfig | None = None, + profile_name_override: str | None = None, + ) -> ChainSetup: + setup = ChainSetup( + profile_file=profile_file, + profile_name_override=profile_name_override, + ) + setup.set_current_provider(provider) + if profile is not None: + setup.set_profile(profile) + await provider.setup(identity_type, setup) + return setup + + return _setup diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_environment.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_environment.py new file mode 100644 index 000000000..c8c412bab --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_environment.py @@ -0,0 +1,66 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Awaitable, Callable + +import pytest +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_aws_core.identity.chain.providers.environment import ( + EnvironmentCredentialsProvider, +) +from smithy_aws_core.identity.environment import EnvironmentCredentialsResolver + +from .conftest import OtherIdentity + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") + + setup = await setup_provider( + EnvironmentCredentialsProvider(), identity_type=OtherIdentity + ) + + assert setup.resolvers == () + assert not setup.terminal + + +@pytest.mark.parametrize( + "environment", + [ + {}, + {"AWS_ACCESS_KEY_ID": "akid"}, + {"AWS_SECRET_ACCESS_KEY": "secret"}, + ], +) +async def test_requires_both_keys( + environment: dict[str, str], + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False) + monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + setup = await setup_provider(EnvironmentCredentialsProvider()) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_registers_terminal_resolver( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") + + setup = await setup_provider(EnvironmentCredentialsProvider()) + + assert setup.terminal + assert len(setup.resolvers) == 1 + assert setup.resolvers[0].provider_name == "Environment" + assert isinstance(setup.resolvers[0].resolver, EnvironmentCredentialsResolver) diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_profile.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_profile.py new file mode 100644 index 000000000..bf3c507f3 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_profile.py @@ -0,0 +1,118 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest +from smithy_aws_core.config.file_parser import Section +from smithy_aws_core.identity import AWSCredentialsIdentity +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_aws_core.identity.chain.providers.profile import ( + ProfileSessionCredentialsProvider, + ProfileStaticCredentialsProvider, +) + +from .conftest import OtherIdentity + + +@pytest.mark.parametrize( + "provider", + [ProfileSessionCredentialsProvider(), ProfileStaticCredentialsProvider()], +) +async def test_ignores_non_aws_identity_type( + provider: Any, + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(provider, identity_type=OtherIdentity) + + assert setup.resolvers == () + assert not setup.terminal + + +@pytest.mark.parametrize( + "provider", + [ProfileSessionCredentialsProvider(), ProfileStaticCredentialsProvider()], +) +async def test_requires_active_profile( + provider: Any, + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(provider) + + assert setup.resolvers == () + assert not setup.terminal + + +@pytest.mark.parametrize( + "provider, profile, expected", + [ + ( + ProfileSessionCredentialsProvider(), + { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + "aws_session_token": "token", + "aws_account_id": "123456789012", + }, + AWSCredentialsIdentity( + access_key_id="akid", + secret_access_key="secret", + session_token="token", + account_id="123456789012", + ), + ), + ( + ProfileStaticCredentialsProvider(), + { + "aws_access_key_id": "akid", + "aws_secret_access_key": "secret", + "aws_account_id": "123456789012", + }, + AWSCredentialsIdentity( + access_key_id="akid", + secret_access_key="secret", + account_id="123456789012", + ), + ), + ], +) +async def test_registers_terminal_resolver_for_complete_profile( + provider: Any, + profile: dict[str, str | dict[str, str]], + expected: AWSCredentialsIdentity, + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(provider, profile=Section(properties=profile)) + + assert setup.terminal + assert len(setup.resolvers) == 1 + identity = await setup.resolvers[0].get_identity(properties={}) + assert identity == expected + + +@pytest.mark.parametrize( + "provider, properties", + [ + ( + ProfileSessionCredentialsProvider(), + {"aws_access_key_id": "akid", "aws_secret_access_key": "secret"}, + ), + (ProfileStaticCredentialsProvider(), {"aws_access_key_id": "akid"}), + ( + ProfileStaticCredentialsProvider(), + { + "aws_access_key_id": {"nested": "value"}, + "aws_secret_access_key": "secret", + }, + ), + ], +) +async def test_rejects_incomplete_or_non_string_keys( + provider: Any, + properties: dict[str, Any], + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(provider, profile=Section(properties=properties)) + + assert setup.resolvers == () + assert not setup.terminal diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_shared_config.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_shared_config.py new file mode 100644 index 000000000..1b85b22f9 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_shared_config.py @@ -0,0 +1,108 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_aws_core.identity.chain.providers import ( + shared_config as shared_config_module, +) +from smithy_aws_core.identity.chain.providers.shared_config import SharedConfigProvider + +from .conftest import OtherIdentity + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + load_config = AsyncMock() + monkeypatch.setattr(shared_config_module, "load_config", load_config) + + setup = await setup_provider(SharedConfigProvider(), identity_type=OtherIdentity) + + assert setup.resolvers == () + assert not setup.terminal + load_config.assert_not_awaited() + + +# profile selection precedence: profile_name_override wins over the AWS_PROFILE +# env var (environment_profile), which wins over the "default" fallback. +@pytest.mark.parametrize( + "profile_name_override, environment_profile, expected", + [ + ("override", "environment", "override"), + (None, "environment", "environment"), + (None, None, "default"), + ], +) +async def test_selects_profile_without_reloading( + profile_name_override: str | None, + environment_profile: str | None, + expected: str, + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile_file = merged_config( + { + "override": {"name": "override"}, + "environment": {"name": "environment"}, + "default": {"name": "default"}, + } + ) + if environment_profile is None: + monkeypatch.delenv("AWS_PROFILE", raising=False) + else: + monkeypatch.setenv("AWS_PROFILE", environment_profile) + load_config = AsyncMock() + monkeypatch.setattr(shared_config_module, "load_config", load_config) + + setup = await setup_provider( + SharedConfigProvider(), + profile_file=profile_file, + profile_name_override=profile_name_override, + ) + + assert setup.profile_file is profile_file + assert setup.profile is profile_file.get_profile(expected) + assert setup.resolvers == () + load_config.assert_not_awaited() + + +async def test_loads_when_not_preloaded( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], + monkeypatch: pytest.MonkeyPatch, +) -> None: + loaded = merged_config({"default": {"name": "loaded"}}) + load_config = AsyncMock(return_value=loaded) + monkeypatch.setattr(shared_config_module, "load_config", load_config) + monkeypatch.delenv("AWS_PROFILE", raising=False) + + setup = await setup_provider(SharedConfigProvider()) + + assert setup.profile_file is loaded + assert setup.profile is loaded.get_profile("default") + assert setup.resolvers == () + load_config.assert_awaited_once_with() + + +async def test_leaves_missing_profile_unset( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], + monkeypatch: pytest.MonkeyPatch, +) -> None: + load_config = AsyncMock() + monkeypatch.setattr(shared_config_module, "load_config", load_config) + + setup = await setup_provider( + SharedConfigProvider(), + profile_file=merged_config(), + profile_name_override="missing", + ) + + assert setup.profile is None + load_config.assert_not_awaited() diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/test_chain.py b/packages/smithy-aws-core/tests/unit/identity/chain/test_chain.py new file mode 100644 index 000000000..9715eea75 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/test_chain.py @@ -0,0 +1,231 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +from collections.abc import Mapping +from typing import Any, assert_type + +import pytest +import smithy_aws_core.identity.chain as chain_module +from smithy_aws_core.identity import ( + AWSCredentialsIdentity, + AWSIdentityProperties, + IdentityChain, +) +from smithy_aws_core.identity.chain import ( + IdentityChainConfigurationError, + IdentityChainError, +) +from smithy_aws_core.identity.chain.ordering import ( + After, + Before, + OrderingConstraint, + Standard, + StandardProvider, +) +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyIdentityError + + +class _StubProvider: + """A minimal ChainIdentityProvider for exercising assembly logic.""" + + def __init__(self, name: str, ordering: OrderingConstraint) -> None: + self.name = name + self.ordering = ordering + + async def setup(self, identity_type: type[Any], setup: Any) -> None: + pass + + +def _credentials(access_key_id: str) -> AWSCredentialsIdentity: + return AWSCredentialsIdentity(access_key_id=access_key_id, secret_access_key="s") + + +class _FakeResolver(IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties]): + def __init__( + self, + *, + identity: AWSCredentialsIdentity | None = None, + error: SmithyIdentityError | None = None, + ) -> None: + self.identity = identity + self.error = error + + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: + if self.error is not None: + raise self.error + assert self.identity is not None + return self.identity + + +async def test_returns_first_successful_resolver() -> None: + miss = _FakeResolver(error=SmithyIdentityError("miss")) + hit = _FakeResolver(identity=_credentials("hit")) + chain = IdentityChain((miss, hit)) + + result = await chain.get_identity(properties={}) + + assert result.access_key_id == "hit" + + +async def test_non_identity_errors_propagate() -> None: + class _BrokenResolver: + async def get_identity( + self, *, properties: Mapping[str, Any] + ) -> AWSCredentialsIdentity: + raise RuntimeError("broken") + + chain = IdentityChain((_BrokenResolver(),)) + + with pytest.raises(RuntimeError, match="broken"): + await chain.get_identity(properties={}) + + +async def test_explicit_chain_preserves_resolvers() -> None: + resolver = _FakeResolver(identity=_credentials("explicit")) + chain = IdentityChain((resolver,)) + + assert chain._resolvers == (resolver,) + assert chain.identity_type is None + assert await chain.get_identity(properties={}) is resolver.identity + + +async def test_create_records_identity_type(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(chain_module, "_discover_chain_identity_providers", tuple) + + chain = await IdentityChain.create(AWSCredentialsIdentity) + + assert chain.identity_type is AWSCredentialsIdentity + assert_type(chain, IdentityChain[AWSCredentialsIdentity]) + + +def test_discovers_and_constructs_entry_point_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _Provider: + pass + + class _EntryPoint: + def load(self) -> Any: + return _Provider + + def _entry_points(*, group: str) -> tuple[_EntryPoint, ...]: + assert group == "smithy_aws_core.identity.chain_providers" + return (_EntryPoint(),) + + monkeypatch.setattr( + "smithy_aws_core.identity.chain.metadata.entry_points", _entry_points + ) + + discovered = chain_module._discover_chain_identity_providers() + + assert len(discovered) == 1 + assert isinstance(discovered[0], _Provider) + + +def test_sort_orders_standards_by_slot_declaration() -> None: + static = _StubProvider( + "static", Standard(slot=StandardProvider.PROFILE_STATIC_KEYS) + ) + env = _StubProvider("env", Standard(slot=StandardProvider.ENVIRONMENT)) + shared = _StubProvider("shared", Standard(slot=StandardProvider.SHARED_CONFIG)) + + ordered = chain_module._sort_by_ordering((static, env, shared)) + + assert [p.name for p in ordered] == ["env", "shared", "static"] + + +def test_sort_places_before_and_after_around_slot() -> None: + before = _StubProvider("before", Before(slot=StandardProvider.SHARED_CONFIG)) + shared = _StubProvider("shared", Standard(slot=StandardProvider.SHARED_CONFIG)) + after = _StubProvider("after", After(slot=StandardProvider.SHARED_CONFIG)) + + ordered = chain_module._sort_by_ordering((after, shared, before)) + + assert [p.name for p in ordered] == ["before", "shared", "after"] + + +def test_sort_resolves_relative_constraints_without_a_claiming_provider() -> None: + # No provider claims SHARED_CONFIG, but Before/After still resolve to the + # slot's declaration position relative to the surrounding standard slots. + env = _StubProvider("env", Standard(slot=StandardProvider.ENVIRONMENT)) + before = _StubProvider("before", Before(slot=StandardProvider.SHARED_CONFIG)) + static = _StubProvider( + "static", Standard(slot=StandardProvider.PROFILE_STATIC_KEYS) + ) + + ordered = chain_module._sort_by_ordering((static, before, env)) + + assert [p.name for p in ordered] == ["env", "before", "static"] + + +def test_sort_keeps_discovery_order_for_same_constraint() -> None: + first = _StubProvider("first", Before(slot=StandardProvider.ENVIRONMENT)) + second = _StubProvider("second", Before(slot=StandardProvider.ENVIRONMENT)) + + ordered = chain_module._sort_by_ordering((first, second)) + + assert [p.name for p in ordered] == ["first", "second"] + + +def test_validate_rejects_duplicate_names() -> None: + first = _StubProvider("dup", Standard(slot=StandardProvider.ENVIRONMENT)) + second = _StubProvider("dup", Standard(slot=StandardProvider.SHARED_CONFIG)) + + with pytest.raises( + IdentityChainConfigurationError, + match="Credential providers _StubProvider and _StubProvider use the " + "same name: dup", + ): + chain_module._validate_providers((first, second)) + + +def test_validate_rejects_duplicate_standard_slots() -> None: + first = _StubProvider("a", Standard(slot=StandardProvider.ENVIRONMENT)) + second = _StubProvider("b", Standard(slot=StandardProvider.ENVIRONMENT)) + + with pytest.raises( + IdentityChainConfigurationError, + match="Credential providers _StubProvider and _StubProvider both claim " + "standard slot: ENVIRONMENT", + ): + chain_module._validate_providers((first, second)) + + +def test_sort_rejects_unsupported_ordering_constraint() -> None: + class _Unsupported: + slot = StandardProvider.ENVIRONMENT + + provider = _StubProvider("bad", _Unsupported()) # type: ignore[arg-type] + + with pytest.raises( + IdentityChainConfigurationError, + match="Provider _StubProvider returned an unsupported ordering constraint", + ): + chain_module._sort_by_ordering((provider,)) + + +async def test_all_miss_raises_with_per_provider_failures() -> None: + first = _FakeResolver(error=SmithyIdentityError("first miss")) + second = _FakeResolver(error=SmithyIdentityError("second miss")) + chain = IdentityChain((first, second)) + + with pytest.raises( + IdentityChainError, + match="Providers attempted: _FakeResolver: first miss; " + "_FakeResolver: second miss", + ) as excinfo: + await chain.get_identity(properties={}) + + assert len(excinfo.value.failures) == 2 + + +async def test_empty_chain_reports_no_providers_discovered() -> None: + chain = IdentityChain((), identity_type=AWSCredentialsIdentity) + + with pytest.raises( + IdentityChainError, match="No credential providers were discovered" + ): + await chain.get_identity(properties={}) diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/test_ordering.py b/packages/smithy-aws-core/tests/unit/identity/chain/test_ordering.py new file mode 100644 index 000000000..f0542f83b --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/test_ordering.py @@ -0,0 +1,138 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from pathlib import Path + +import pytest +from smithy_aws_core.identity.chain.ordering import StandardProvider + +_ENV_DETECTION_VARS = ( + "AWS_ACCESS_KEY_ID", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", +) + + +@pytest.mark.parametrize( + "slot, env, expected", + [ + (StandardProvider.ENVIRONMENT, {"AWS_ACCESS_KEY_ID": "akid"}, True), + ( + StandardProvider.WEB_IDENTITY_TOKEN_ENV, + { + "AWS_WEB_IDENTITY_TOKEN_FILE": "/token", + "AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/test", + }, + True, + ), + ( + StandardProvider.WEB_IDENTITY_TOKEN_ENV, + {"AWS_WEB_IDENTITY_TOKEN_FILE": "/token"}, + False, + ), + ( + StandardProvider.ECS_CONTAINER, + {"AWS_CONTAINER_CREDENTIALS_FULL_URI": "https://example.com"}, + True, + ), + ( + StandardProvider.ECS_CONTAINER, + {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/credentials"}, + True, + ), + (StandardProvider.EC2_INSTANCE_METADATA, {}, False), + (StandardProvider.PROFILE_STATIC_KEYS, {}, False), + ], +) +def test_is_detected_with_env( + slot: StandardProvider, + env: dict[str, str], + expected: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name in _ENV_DETECTION_VARS: + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + + assert slot.is_detected() is expected + + +@pytest.mark.parametrize( + "create_config, create_credentials, expected", + [ + (False, False, False), + (True, False, True), + (False, True, True), + (True, True, True), + ], +) +def test_shared_config_detection( + create_config: bool, + create_credentials: bool, + expected: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config" + credentials_path = tmp_path / "credentials" + if create_config: + config_path.write_text("[default]\n") + if create_credentials: + credentials_path.write_text("[default]\n") + monkeypatch.setenv("AWS_CONFIG_FILE", str(config_path)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(credentials_path)) + + assert StandardProvider.SHARED_CONFIG.is_detected() is expected + + +# Regression test to catch accidental edits. +@pytest.mark.parametrize( + "slot, canonical_name, module_suggestion", + [ + (StandardProvider.ENVIRONMENT, "Environment", None), + ( + StandardProvider.WEB_IDENTITY_TOKEN_ENV, + "WebIdentityTokenEnv", + "aws-credentials-sts", + ), + (StandardProvider.SHARED_CONFIG, "SharedConfig", None), + (StandardProvider.PROFILE_SESSION_KEYS, "ProfileSessionKeys", None), + (StandardProvider.PROFILE_STATIC_KEYS, "ProfileStaticKeys", None), + ( + StandardProvider.PROFILE_ASSUME_ROLE, + "ProfileAssumeRole", + "aws-credentials-sts", + ), + ( + StandardProvider.PROFILE_WEB_IDENTITY, + "ProfileWebIdentity", + "aws-credentials-sts", + ), + ( + StandardProvider.PROFILE_SSO_SESSION, + "ProfileSsoSession", + "aws-credentials-sso", + ), + (StandardProvider.PROFILE_LOGIN, "Login", "aws-credentials-login"), + ( + StandardProvider.PROFILE_CREDENTIAL_PROCESS, + "ProfileCredentialProcess", + None, + ), + (StandardProvider.ECS_CONTAINER, "EcsContainer", "aws-credentials-ecs"), + ( + StandardProvider.EC2_INSTANCE_METADATA, + "Ec2InstanceMetadata", + "aws-credentials-imds", + ), + ], +) +def test_slot_metadata( + slot: StandardProvider, + canonical_name: str, + module_suggestion: str | None, +) -> None: + assert slot.canonical_name == canonical_name + assert slot.module_suggestion == module_suggestion diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/test_provider.py b/packages/smithy-aws-core/tests/unit/identity/chain/test_provider.py new file mode 100644 index 000000000..bade5e675 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/test_provider.py @@ -0,0 +1,98 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from unittest.mock import AsyncMock + +import pytest +from smithy_aws_core.config.file_parser import StandardizedOutput +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity.chain.ordering import Standard, StandardProvider +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_core.interfaces.identity import Identity + + +class _FakeProvider: + def __init__(self, name: str = "Environment") -> None: + self.name = name + self.ordering = Standard(slot=StandardProvider.ENVIRONMENT) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + pass + + +def _empty_config() -> MergedConfig: + return MergedConfig(StandardizedOutput(), StandardizedOutput()) + + +def test_add_resolver_tags_with_current_provider() -> None: + resolver = AsyncMock() + setup = ChainSetup() + setup.set_current_provider(_FakeProvider("Environment")) + + setup.add_resolver(resolver) + + assert len(setup.resolvers) == 1 + assert setup.resolvers[0].resolver is resolver + assert setup.resolvers[0].provider_name == "Environment" + assert not setup.terminal + + +def test_add_resolver_stacks_multiple() -> None: + first, second = AsyncMock(), AsyncMock() + setup = ChainSetup() + setup.set_current_provider(_FakeProvider()) + + setup.add_resolver(first) + setup.add_resolver(second) + + assert [r.resolver for r in setup.resolvers] == [first, second] + assert not setup.terminal + + +def test_add_terminal_resolver_stops_assembly() -> None: + setup = ChainSetup() + setup.set_current_provider(_FakeProvider()) + + setup.add_terminal_resolver(AsyncMock()) + + assert setup.terminal + with pytest.raises( + RuntimeError, match="Cannot add a resolver after a terminal resolver" + ): + setup.add_resolver(AsyncMock()) + + +def test_cannot_change_provider_after_terminal() -> None: + setup = ChainSetup() + setup.set_current_provider(_FakeProvider()) + setup.add_terminal_resolver(AsyncMock()) + + with pytest.raises( + RuntimeError, match="Cannot change provider after a terminal resolver" + ): + setup.set_current_provider(_FakeProvider()) + + +def test_cannot_add_without_current_provider() -> None: + setup = ChainSetup() + + with pytest.raises( + RuntimeError, match="Cannot add a resolver without a current provider" + ): + setup.add_resolver(AsyncMock()) + + +def test_set_profile_file_cannot_overwrite() -> None: + setup = ChainSetup(profile_file=_empty_config()) + + with pytest.raises( + RuntimeError, match="Cannot overwrite a profile file already present" + ): + setup.set_profile_file(_empty_config()) + + +def test_properties_bag_is_shared_and_mutable() -> None: + setup = ChainSetup() + + setup.properties["key"] = "value" + + assert setup.properties["key"] == "value" diff --git a/packages/smithy-aws-core/tests/unit/identity/test_environment_credentials_resolver.py b/packages/smithy-aws-core/tests/unit/identity/test_environment.py similarity index 100% rename from packages/smithy-aws-core/tests/unit/identity/test_environment_credentials_resolver.py rename to packages/smithy-aws-core/tests/unit/identity/test_environment.py diff --git a/packages/smithy-aws-core/tests/unit/identity/test_static.py b/packages/smithy-aws-core/tests/unit/identity/test_static.py new file mode 100644 index 000000000..5e0a9ea66 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/test_static.py @@ -0,0 +1,48 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_aws_core.identity.static import StaticCredentialsResolver +from smithy_core.exceptions import SmithyIdentityError + + +async def test_returns_fixed_identity() -> None: + identity = AWSCredentialsIdentity( + access_key_id="akid", + secret_access_key="secret", + ) + resolver = StaticCredentialsResolver(identity) + + assert await resolver.get_identity(properties={}) is identity + + +async def test_reads_request_properties() -> None: + resolver = StaticCredentialsResolver() + + identity = await resolver.get_identity( + properties={ + "access_key_id": "akid", + "secret_access_key": "secret", + "session_token": "token", + } + ) + + assert identity.access_key_id == "akid" + assert identity.secret_access_key == "secret" + assert identity.session_token == "token" + + +@pytest.mark.parametrize( + "properties", + [ + {}, + {"access_key_id": "akid"}, + {"secret_access_key": "secret"}, + ], +) +async def test_requires_both_request_keys( + properties: AWSIdentityProperties, +) -> None: + with pytest.raises(SmithyIdentityError): + await StaticCredentialsResolver().get_identity(properties=properties)