Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changes/525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": "525a1637-f0f1-4cbd-97dd-5e9c6bcd182e",
"type": "feature",
"description": "Add support for fetching account ID from IMDS credentials on EC2"
}
10 changes: 5 additions & 5 deletions aws-runtime/aws-config/api/aws-config.api
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,11 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/EnvironmentCredential

public final class aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CloseableCredentialsProvider {
public fun <init> ()V
public fun <init> (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;)V
public synthetic fun <init> (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;)V
public synthetic fun <init> (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close ()V
public final fun getClient ()Lkotlin/Lazy;
public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;
public final fun getProfileOverride ()Ljava/lang/String;
public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun toString ()Ljava/lang/String;
}
Expand Down Expand Up @@ -277,6 +276,7 @@ public final class aws/sdk/kotlin/runtime/config/AwsSdkSetting {
public final fun getAwsContainerCredentialsFullUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsContainerCredentialsRelativeUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsDisableRequestCompression ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsEc2InstanceProfileName ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsEc2MetadataDisabled ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsEc2MetadataServiceEndpoint ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
public final fun getAwsEc2MetadataServiceEndpointMode ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting;
Expand Down Expand Up @@ -348,8 +348,8 @@ public final class aws/sdk/kotlin/runtime/config/endpoints/ResolversKt {
}

public final class aws/sdk/kotlin/runtime/config/imds/EC2MetadataError : aws/sdk/kotlin/runtime/AwsServiceException {
public fun <init> (ILjava/lang/String;)V
public final fun getStatusCode ()I
public fun <init> (Laws/smithy/kotlin/runtime/http/HttpStatusCode;Ljava/lang/String;)V
public final fun getStatusCode ()Laws/smithy/kotlin/runtime/http/HttpStatusCode;
}

public abstract class aws/sdk/kotlin/runtime/config/imds/EndpointConfiguration {
Expand Down
2 changes: 2 additions & 0 deletions aws-runtime/aws-config/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.jetbrains.dokka.gradle.DokkaTaskPartial

plugins {
alias(libs.plugins.aws.kotlin.repo.tools.smithybuild)
alias(libs.plugins.kotlinx.serialization)
}

description = "Support for AWS configuration"
Expand Down Expand Up @@ -53,6 +54,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.smithy.kotlin.http.test)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotest.framework.datatest)
}
}
jvmTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import aws.smithy.kotlin.runtime.util.PlatformProvider
* @param region the region to make credentials requests to.
* @return the newly-constructed credentials provider
*/
public class DefaultChainCredentialsProvider constructor(
public class DefaultChainCredentialsProvider(
public val profileName: String? = null,
public val platformProvider: PlatformProvider = PlatformProvider.System,
httpClient: HttpClientEngine? = null,
Expand All @@ -59,11 +59,9 @@ public class DefaultChainCredentialsProvider constructor(
ProfileCredentialsProvider(profileName = profileName, platformProvider = platformProvider, httpClient = engine, region = region),
EcsCredentialsProvider(platformProvider, engine),
ImdsCredentialsProvider(
client = lazy {
ImdsClient {
platformProvider = [email protected]
engine = [email protected]
}
client = ImdsClient {
platformProvider = [email protected]
engine = [email protected]
Copy link
Member

Choose a reason for hiding this comment

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

@ param client a preconfigured IMDS client with which to retrieve instance metadata. If an instance is passed, the caller is responsible for closing it.

Should we be closing this ImdsClient that the default chain creates?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, we should be! Will fix.

},
platformProvider = platformProvider,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package aws.sdk.kotlin.runtime.auth.credentials

import aws.sdk.kotlin.runtime.auth.credentials.internal.credentials
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError
import aws.sdk.kotlin.runtime.config.imds.ImdsClient
Expand All @@ -18,137 +19,208 @@ import aws.smithy.kotlin.runtime.http.HttpStatusCode
import aws.smithy.kotlin.runtime.io.IOException
import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer
import aws.smithy.kotlin.runtime.telemetry.logging.info
import aws.smithy.kotlin.runtime.telemetry.logging.warn
import aws.smithy.kotlin.runtime.time.Clock
import aws.smithy.kotlin.runtime.time.Instant
import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider
import aws.smithy.kotlin.runtime.util.PlatformProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import aws.smithy.kotlin.runtime.util.SingleFlightGroup
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds

private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials/"
private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess"
private const val PROVIDER_NAME = "IMDSv2"

/**
* [CredentialsProvider] that uses EC2 instance metadata service (IMDS) to provide credentials information.
* This provider requires that the EC2 instance has an [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile)
* This provider requires that the EC2 instance has an
* [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile)
* configured.
*
* See [EC2 IAM Roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) for more
* information.
*
* @param profileOverride override the instance profile name. When retrieving credentials, a call must first be made to
* `<IMDS_BASE_URL>/latest/meta-data/iam/security-credentials/`. This returns the instance profile used. If
* [profileOverride] is set, the initial call to retrieve the profile is skipped and the provided value is used instead.
* @param client the IMDS client to use to resolve credentials information with. This provider takes ownership over
* the lifetime of the given [ImdsClient] and will close it when the provider is closed.
* @param platformProvider the [PlatformEnvironProvider] instance
* @param instanceProfileName overrides the instance profile name. When set, this provider skips querying IMDS for the
* name of the active profile.
* @param client a preconfigured IMDS client with which to retrieve instance metadata. If an instance is passed, the
* caller is responsible for closing it. If no instance is passed, a default instance is created and will be closed when
* this credentials provider is closed.
* @param platformProvider a platform provider used for env vars and system properties
*/
public class ImdsCredentialsProvider(
public val profileOverride: String? = null,
public val client: Lazy<InstanceMetadataProvider> = lazy { ImdsClient() },
public val platformProvider: PlatformEnvironProvider = PlatformProvider.System,
private val clock: Clock = Clock.System,
instanceProfileName: String? = null,
client: InstanceMetadataProvider? = null,
private val platformProvider: PlatformProvider = PlatformProvider.System,
) : CloseableCredentialsProvider {

@Deprecated("This constructor supports parameters which are no longer used in the implementation. It will be removed in version 1.5.")
public constructor(
profileOverride: String? = null,
client: Lazy<InstanceMetadataProvider> = lazy { ImdsClient() },
platformProvider: PlatformEnvironProvider = PlatformProvider.System,
@Suppress("UNUSED_PARAMETER") clock: Clock = Clock.System,
) : this(profileOverride, client.value, platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System)
Copy link
Member

Choose a reason for hiding this comment

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

We'll need to merge this deprecation into main, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I originally planned to push this change to main directly but I've since added backwards incompatibilities beyond just deprecation so I'll split this change out now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, I've realized now I can probably just merge this directly into main with a few tweaks. Updating PR...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

...except this PR's branch upstream is already v1.5-main so I'll cherry pick the commits over to a new branch and new PR #1573.

Copy link
Member

Choose a reason for hiding this comment

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

For the future, it's possible to change the destination branch if you edit the PR at the top


private val manageClient: Boolean = client == null

private val client: InstanceMetadataProvider = client ?: ImdsClient {
this.platformProvider = [email protected]
}

// FIXME This only resolves from env vars and sys props but we need to resolve from profiles too
Copy link
Member

Choose a reason for hiding this comment

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

I know you said you're working on a bigger refactor for profile keys, is that in progress now? I think we already have everything available that you'd need to read from profile here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not yet in progress so I'll add profile support now similarly to how we currently resolve other profile settings.

private val instanceProfileName = instanceProfileName
?: AwsSdkSetting.AwsEc2InstanceProfileName.resolve(platformProvider)

// FIXME This only resolves from env vars and sys props but we need to resolve from profiles too
private val providerDisabled = AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true

/**
* Tracks the known-good version of IMDS APIs available in the local environment. This starts as `null` and will be
* updated after the first successful API call.
*/
private var apiVersion: ApiVersion? = null

private val urlBase: String
get() = (apiVersion ?: ApiVersion.EXTENDED).urlBase

private var previousCredentials: Credentials? = null

// the time to refresh the Credentials. If set, it will take precedence over the Credentials' expiration time
private var nextRefresh: Instant? = null
/**
* Tracks the instance profile name resolved from IMDS. This starts as `null` and will be updated after a
* successful API call. Note that if [instanceProfileName] is set, profile name resolution will be skipped.
*/
private var resolvedProfileName: String? = null

/**
* A deduplicator for resolving credentials and tracking mutable state about IMDS
*/
private val sfg = SingleFlightGroup<Credentials>()

// protects previousCredentials and nextRefresh
private val mu = Mutex()
override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight { resolveUnderLock() }

override suspend fun resolve(attributes: Attributes): Credentials {
if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) {
private suspend fun resolveUnderLock(): Credentials {
Copy link
Member

Choose a reason for hiding this comment

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

nit/naming: There's no mutex used here, it seems strange to call it resolveUnderLock

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That was meant to describe the conditions under which the method must be called. 🤔 What's a better name for this?

Copy link
Member

Choose a reason for hiding this comment

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

It's not a strong opinion, I think something like resolveSingleFlight or doResolve might be a bit clearer

println("**** Resolving creds (instanceProfileName=$instanceProfileName; apiVersion=$apiVersion; urlBase=$urlBase)")
Copy link
Member

Choose a reason for hiding this comment

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

nit: remove stray printlns


if (providerDisabled) {
println("**** Explicitly disabled")
throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded")
}

// if we have previously served IMDS credentials and it's not time for a refresh, just return the previous credentials
mu.withLock {
previousCredentials?.run {
nextRefresh?.takeIf { clock.now() < it }?.run {
return previousCredentials!!
val profileName = instanceProfileName ?: resolvedProfileName ?: try {
println("**** Resolving profile")
client.get(urlBase).also {
if (apiVersion == null) {
// Tried EXTENDED and it worked; remember that for the future
apiVersion = ApiVersion.EXTENDED
}
}
}
} catch (ex: EC2MetadataError) {
when {
apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> {
// Tried EXTENDED and that didn't work; fallback to LEGACY
apiVersion = ApiVersion.LEGACY
return resolveUnderLock()
}

ex.statusCode == HttpStatusCode.NotFound -> {
coroutineContext.info<ImdsCredentialsProvider> {
"Received 404 when loading profile name. This instance may not have an associated profile."
}
throw ex
}

val profileName = try {
profileOverride ?: loadProfile()
else -> return usePreviousCredentials()
?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException()
}
} catch (ex: IOException) {
return usePreviousCredentials() ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException()
} catch (ex: Exception) {
return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load instance profile", ex)
throw ImdsProfileException(ex).wrapAsCredentialsProviderException()
}

val payload = try {
client.value.get("$CREDENTIALS_BASE_PATH$profileName")
val credsPayload = try {
client.get("$urlBase$profileName")
} catch (ex: EC2MetadataError) {
when {
apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> {
// Tried EXTENDED and that didn't work; fallback to LEGACY
apiVersion = ApiVersion.LEGACY
return resolveUnderLock()
}

instanceProfileName == null && ex.statusCode == HttpStatusCode.NotFound -> {
// A previously-resolved profile is now invalid; forget the resolved name and re-resolve
resolvedProfileName = null
return resolveUnderLock()
}

else -> return usePreviousCredentials()
?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException()
}
} catch (ex: IOException) {
return usePreviousCredentials()
?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException()
} catch (ex: Exception) {
return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load credentials", ex)
throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException()
}

val deserializer = JsonDeserializer(payload.encodeToByteArray())
if (instanceProfileName == null) {
// No profile name was provided at construction time; cache the resolved name
resolvedProfileName = profileName
}

val deserializer = JsonDeserializer(credsPayload.encodeToByteArray())

return when (val resp = deserializeJsonCredentials(deserializer)) {
is JsonCredentialsResponse.SessionCredentials -> {
nextRefresh = if (resp.expiration != null && resp.expiration < clock.now()) {
coroutineContext.warn<ImdsCredentialsProvider> {
"Attempting credential expiration extension due to a credential service availability issue. " +
"A refresh of these credentials will be attempted again in " +
"${ DEFAULT_CREDENTIALS_REFRESH_SECONDS / 60 } minutes."
}
clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds
Comment on lines -94 to -100
Copy link
Member

Choose a reason for hiding this comment

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

Are we intentionally dropping this HOSM / static stability behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, we still mostly support the requirements of the static stability spec. This change removes some of the messaging around expiry but we'll still attempt to use expired credentials as required. We should update the CachedCredentialsProvider to handle messaging about expiry. I'll leave a TODO.

Copy link
Member

Choose a reason for hiding this comment

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

The static stability SEP says we MUST log a message when using expired credentials

} else {
null
}

val creds = Credentials(
val creds = credentials(
resp.accessKeyId,
resp.secretAccessKey,
resp.sessionToken,
resp.expiration,
PROVIDER_NAME,
resp.accountId,
).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS)

creds.also {
mu.withLock { previousCredentials = it }
}
creds.also { previousCredentials = it }
}
is JsonCredentialsResponse.Error -> {
when (resp.code) {
CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?")
else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}")
}
is JsonCredentialsResponse.Error -> when (resp.code) {
CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?")
else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}")
}
}
}

override fun close() {
if (client.isInitialized()) {
client.value.close()
if (manageClient) {
client.close()
}
}

private suspend fun loadProfile() = try {
client.value.get(CREDENTIALS_BASE_PATH)
} catch (ex: EC2MetadataError) {
if (ex.statusCode == HttpStatusCode.NotFound.value) {
private suspend fun usePreviousCredentials(): Credentials? =
previousCredentials?.apply {
coroutineContext.info<ImdsCredentialsProvider> {
"Received 404 from IMDS when loading profile information. Hint: This instance may not have an " +
"IAM role associated."
"Attempting to reuse previously-fetched credentials (expiration = $expiration)"
}
}
throw ex
}

private suspend fun useCachedCredentials(ex: Exception): Credentials? = when {
ex is IOException || ex is EC2MetadataError && ex.statusCode == HttpStatusCode.InternalServerError.value -> {
mu.withLock {
previousCredentials?.apply { nextRefresh = clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds }
}
}
else -> null
}

override fun toString(): String = this.simpleClassName

/**
* Identifies different versions of IMDS APIs for fetching credentials
*/
private enum class ApiVersion(val urlBase: String) {
/**
* The original, now-deprecated API
*/
LEGACY("/latest/meta-data/iam/security-credentials/"),

/**
* The new API which provides `AccountId` and potentially other fields in the future
*/
EXTENDED("/latest/meta-data/iam/security-credentials-extended/"),
}
}

internal class ImdsCredentialsException(
profileName: String,
cause: Throwable,
) : RuntimeException("Failed to load credentials for EC2 instance profile \"$profileName\"", cause)

internal class ImdsProfileException(cause: Throwable) : RuntimeException("Failed to load instance profile name", cause)

private fun Throwable.wrapAsCredentialsProviderException() =
CredentialsProviderException(message.orEmpty(), this)
Loading
Loading