Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,18 @@ def identity_type(self) -> type[I] | None:
async def create[ChainIdentity: Identity](
identity_type: type[ChainIdentity],
*,
profile_file: MergedConfig | None = None,
profile_name_override: str | None = None,
config_file: MergedConfig | None = None,
profile_name: str | None = None,
region_override: str | None = None,
http_client: HTTPClient | None = None,
) -> "IdentityChain[ChainIdentity]":
"""Create an identity chain from discovered providers.

:param identity_type: The identity type to resolve.
:param profile_file: Parsed config/credentials file. Loaded from disk
:param config_file: Parsed config/credentials file. Loaded from disk
when not set.
:param profile_name_override: Profile name to use, taking precedence over
``AWS_PROFILE``.
:param profile_name: Profile name to use. If omitted, the shared config
provider uses ``AWS_PROFILE`` when set, otherwise ``default``.
:param region_override: Region to use for providers whose resolvers
fetch credentials through a service call.
:param http_client: HTTP client to use for providers whose resolvers make
Expand All @@ -196,8 +196,8 @@ async def create[ChainIdentity: Identity](
_validate_providers(discovered_providers)
providers = _sort_by_ordering(discovered_providers)
setup = ChainSetup(
profile_file=profile_file,
profile_name_override=profile_name_override,
config_file=config_file,
profile_name=profile_name,
region_override=region_override,
http_client=http_client,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class StandardProvider(Enum):
PROFILE_SSO_SESSION = "ProfileSsoSession", "aws-credentials-sso"
PROFILE_LOGIN = "Login", "aws-credentials-login"
PROFILE_CREDENTIAL_PROCESS = "ProfileCredentialProcess", None
ECS_CONTAINER = "EcsContainer", "aws-credentials-ecs"
ECS_CONTAINER = "EcsContainer", "aws-credentials-http"
EC2_INSTANCE_METADATA = "Ec2InstanceMetadata", "aws-credentials-imds"

def __init__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from smithy_core.interfaces.identity import Identity
from smithy_http.aio.interfaces import HTTPClient

from ...config.file_parser import Section
from ...config.merged_config import MergedConfig
from .ordering import OrderingConstraint

Expand All @@ -37,17 +36,16 @@ class ChainSetup:
def __init__(
self,
*,
profile_file: MergedConfig | None = None,
profile_name_override: str | None = None,
config_file: MergedConfig | None = None,
profile_name: str | None = None,
region_override: str | None = None,
http_client: HTTPClient | None = None,
properties: MutableMapping[str, Any] | None = None,
) -> None:
self._profile_file = profile_file
self._profile_name_override = profile_name_override
self._config_file = config_file
self._profile_name = profile_name
self._region_override = region_override
self._http_client = http_client
self._profile: Section | None = None
self._properties: MutableMapping[str, Any] = (
{} if properties is None else properties
)
Expand All @@ -56,19 +54,14 @@ def __init__(
self._terminal = False

@property
def profile_file(self) -> MergedConfig | None:
"""Return the parsed config and credentials files, if loaded."""
return self._profile_file
def config_file(self) -> MergedConfig | None:
"""Return the parsed config/credentials file, if loaded."""
return self._config_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
def profile_name(self) -> str | None:
"""Return the profile name, if selected."""
return self._profile_name

@property
def region_override(self) -> str | None:
Expand Down Expand Up @@ -101,15 +94,15 @@ def set_current_provider(self, provider: ChainIdentityProvider) -> None:
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_config_file(self, config_file: MergedConfig) -> None:
"""Set the parsed config file without overwriting an existing value."""
if self._config_file is not None:
raise RuntimeError("Cannot overwrite a config file already present.")
self._config_file = config_file

def set_profile(self, profile: Section) -> None:
"""Set the active profile."""
self._profile = profile
def set_profile_name(self, profile_name: str) -> None:
"""Set the resolved name of the active profile."""
self._profile_name = profile_name

def add_resolver(self, resolver: IdentityResolver[Any, Any]) -> None:
"""Add a named resolver and continue assembly."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# 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
Expand All @@ -14,11 +13,6 @@
_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."""

Expand All @@ -37,21 +31,22 @@ async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None:
if identity_type is not AWSCredentialsIdentity:
return

profile = setup.profile
if profile is None:
config_file = setup.config_file
profile_name = setup.profile_name
if config_file is None or profile_name 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)
access_key_id = config_file.get(profile_name, _ACCESS_KEY_ID)
secret_access_key = config_file.get(profile_name, _SECRET_ACCESS_KEY)
session_token = config_file.get(profile_name, _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),
account_id=config_file.get(profile_name, _ACCOUNT_ID),
)
setup.add_terminal_resolver(StaticCredentialsResolver(identity))

Expand All @@ -74,18 +69,19 @@ async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None:
if identity_type is not AWSCredentialsIdentity:
return

profile = setup.profile
if profile is None:
config_file = setup.config_file
profile_name = setup.profile_name
if config_file is None or profile_name is None:
return

access_key_id = _get_string(profile, _ACCESS_KEY_ID)
secret_access_key = _get_string(profile, _SECRET_ACCESS_KEY)
access_key_id = config_file.get(profile_name, _ACCESS_KEY_ID)
secret_access_key = config_file.get(profile_name, _SECRET_ACCESS_KEY)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs before ProfileAssumeRole and terminates assembly, so a profile with both static keys and role_arn resolves to the raw user keys and the role is never assumed. This breaks the standard self-referencing pattern, where the keys are only the source credentials for the STS call:

[profile deploy]
role_arn = arn:aws:iam::999999999999:role/Deployer
source_profile = deploy
aws_access_key_id = AKIA...          # near-zero-permission user, only sts:AssumeRole
aws_secret_access_key = ...          # real permissions live on the role

botocore gives role_arn precedence: botocore's assume-role provider runs before the static-keys providers and claims any profile containing role_arn, regardless of static keys (_has_assume_role_config_vars).

Let me know what you think.

@arandito arandito Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boto3's credential chain order is legacy and places assume role credentials directly after environment (docs).

However, modern SDKs must follow the order defined in the StandardProvider enum. This means that self-referencing profiles will not be respected if used in a chain.

They are still respected if you use the assume role resolver on its own.

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),
account_id=config_file.get(profile_name, _ACCOUNT_ID),
)
setup.add_terminal_resolver(StaticCredentialsResolver(identity))
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,10 @@ async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None:
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)
config_file = setup.config_file
if config_file is None:
config_file = await load_config()
setup.set_config_file(config_file)

profile_name = setup.profile_name or os.getenv("AWS_PROFILE") or "default"
setup.set_profile_name(profile_name)
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ class OtherIdentity(Identity):

@pytest.fixture
def merged_config() -> Callable[..., MergedConfig]:
"""Build merged config for provider tests."""

def _build(
profiles: Mapping[str, Mapping[str, str]] | None = None,
profiles: Mapping[str, Mapping[str, str | dict[str, str]]] | None = None,
) -> MergedConfig:
sections = {
name: Section(properties=dict(properties))
Expand All @@ -31,21 +33,20 @@ def _build(

@pytest.fixture
def setup_provider() -> Callable[..., Awaitable[ChainSetup]]:
"""Setup a provider with a configured 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,
config_file: MergedConfig | None = None,
profile_name: str | None = None,
) -> ChainSetup:
setup = ChainSetup(
profile_file=profile_file,
profile_name_override=profile_name_override,
config_file=config_file,
profile_name=profile_name,
)
setup.set_current_provider(provider)
if profile is not None:
setup.set_profile(profile)
await provider.setup(identity_type, setup)
return setup

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Any

import pytest
from smithy_aws_core.config.file_parser import Section
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_aws_core.identity.chain.providers.profile import (
Expand Down Expand Up @@ -43,6 +43,25 @@ async def test_requires_active_profile(
assert not setup.terminal


@pytest.mark.parametrize(
"provider",
[ProfileSessionCredentialsProvider(), ProfileStaticCredentialsProvider()],
)
async def test_missing_profile_does_not_register(
provider: Any,
setup_provider: Callable[..., Awaitable[ChainSetup]],
merged_config: Callable[..., MergedConfig],
) -> None:
setup = await setup_provider(
provider,
config_file=merged_config({"default": {"aws_access_key_id": "akid"}}),
profile_name="missing",
)

assert setup.resolvers == ()
assert not setup.terminal


@pytest.mark.parametrize(
"provider, profile, expected",
[
Expand Down Expand Up @@ -81,8 +100,13 @@ async def test_registers_terminal_resolver_for_complete_profile(
profile: dict[str, str | dict[str, str]],
expected: AWSCredentialsIdentity,
setup_provider: Callable[..., Awaitable[ChainSetup]],
merged_config: Callable[..., MergedConfig],
) -> None:
setup = await setup_provider(provider, profile=Section(properties=profile))
setup = await setup_provider(
provider,
config_file=merged_config({"default": profile}),
profile_name="default",
)

assert setup.terminal
assert len(setup.resolvers) == 1
Expand Down Expand Up @@ -111,8 +135,13 @@ async def test_rejects_incomplete_or_non_string_keys(
provider: Any,
properties: dict[str, Any],
setup_provider: Callable[..., Awaitable[ChainSetup]],
merged_config: Callable[..., MergedConfig],
) -> None:
setup = await setup_provider(provider, profile=Section(properties=properties))
setup = await setup_provider(
provider,
config_file=merged_config({"default": properties}),
profile_name="default",
)

assert setup.resolvers == ()
assert not setup.terminal
Loading
Loading