-
Notifications
You must be signed in to change notification settings - Fork 55
feat(rt)!: implement kmp profile credentials provider #478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
A new generated diff is ready to view: __generated-main...__generated-feat-profile-provider |
kggilmer
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite clean and easy to follow. i think every log emission needs the first word capitalized otherwise no concerns found.
| public constructor() : this(Platform, CrtHttpEngine()) | ||
| public constructor() : this(Platform) | ||
|
|
||
| private val manageEngine = httpClientEngine == null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style
IMO owned is more descriptive than manage as to the intent of this property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah true, I made it manageEngine to match the corresponding parameter that sdkHttpClient uses.
I kind of expect this is going to change when we decide on our ownership direction
| override suspend fun getCredentials(): Credentials { | ||
| val logger = Logger.getLogger<ProfileCredentialsProvider>() | ||
| val source = resolveConfigSource(platform, profileName) | ||
| logger.debug { "loading credentials from profile `${source.profile}`" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit
title case for log entry, for all log emissions in this file
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
did we document this convention somewhere. I remember discussing it and promptly forgot and I will probably continue to forget until we get it written down in a style guide
| * A standalone member of the profile chain. Leaf providers do not require | ||
| * input credentials to provide their own credentials (e.g. IMDS, ECS, Environment, etc) | ||
| */ | ||
| internal sealed class LeafProvider { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question
it's not connecting for me, what meaning does 'leaf' mean for this type? Is it that it's terminal, as in a leaf in a tree?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah the provider that doesn't depend on any other providers. In the Rust implementation they called it BaseProvider. I didn't find that descriptive enough for my tastes.
|
|
||
| if (visited.contains(sourceProfileName)) { | ||
| // we're in a loop, break out | ||
| throw ProviderConfigurationException("profile formed an infinite loop: ${visited.joinToString(separator = " -> ")} -> $sourceProfileName") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question
assuming a customer would see this error message, is there some additional text we could add to explain what the customer should do to resolve it?
| val accessKeyId = get(AWS_ACCESS_KEY_ID) | ||
| val secretKey = get(AWS_SECRET_ACCESS_KEY) | ||
| return when { | ||
| accessKeyId == null && secretKey == null -> throw ProviderConfigurationException("profile ($name) did not contain credential information") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit
"profile ($name) did not contained neither an access key nor secret key" may be more actionable to a user
| } | ||
| // loop back into this profile and pick up the credential source | ||
| sourceProfile == null && credSource != null -> NextProfile.SelfReference | ||
| else -> error("shouldn't be able to get here, this is a bug please file a ticket") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion
link to gh issues so the customer knows where to file the ticket
| import kotlin.test.assertEquals | ||
| import kotlin.test.fail | ||
|
|
||
| class ProfileChainTest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wow nice 🤩
| val secretAccessKey: String, | ||
| val sessionToken: String? = null, | ||
| val expiration: Instant? = null, | ||
| val providerName: String? = null, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Where do we make use of this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't really but it seems very useful for debugging purposes. I could be convinced to remove it though
| private const val PROVIDER_NAME = "EcsContainer" | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Would using the class name be simpler than custom names for each provider?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Provider names are actually called out in an SEP
| ) { | ||
| companion object { | ||
| internal fun resolve(profiles: ProfileMap, profileName: String): ProfileChain { | ||
| val visited = mutableListOf<String>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Could be a set for clearer semantics. Bonus, you can simplify contains/add to:
if (!visited.add(sourceProfileName)) {
throw ...
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can't be a set because the order matters for the error message we form on L53. If we don't use that formatting we I think we could make it a set. Although I didn't go look and see if there is an ordered set available...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mutableSetOf returns a set with order-insertion guaranteed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perfect. Will update.
| visited.add(sourceProfileName) | ||
|
|
||
| // when chaining assume role profiles, SDKs MUST terminate the chain as soon as they hit a profile with static credentials | ||
| if (visited.size > 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why do we not use static credentials and break if there's only 1 element in visited?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I follow. As the comment suggests the chain terminates as soon as it hits a profile with static creds. In the case of a single profile with static creds it would break out at L71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The if condition is visited.size > 1. Why shouldn't we run the code inside the if-block if we've only visited one element so far?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh so as the comment on L64 indicates the only indication that multiple profiles are chained is the existince of role_arn. This check exists only to terminate a chain at the first profile with credentials, e.g.
[profile A]
role_arn = arn::test::foo::blah
source_profile = B
[profile B]
access_key_id = AKID
secret_access_key= secret
source_profile = C
# profile A chain must terminate here and not look at C for creds
[profile C]
access_key_id = incorrect_key
secret_access_key= incorrect_secretThe chain must terminate at the first profile that has static creds when multiple are involved. The singular profile with static creds that has no chain:
[profile A]
access_key_id = AKID
secret_access_key= secretis handled by the else branch starting on L68 (just below).
As I type all this I'm now realizing your actual question. I'll leave all the above as context but the answer is in this test case
| else -> { | ||
| LeafProvider.WebIdentityTokenRole(roleArn, tokenFile, sessionName) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Unnecessary braces
| private inline fun ProfileMap.getOrThrow(name: String, lazyException: () -> Throwable): AwsProfile { | ||
| val props = get(name) ?: throw lazyException() | ||
| return AwsProfile(name, props) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment: This seems slightly strange to me. The function accepts a () -> Throwable whereas other similar functions (e.g., require, check, etc.) accept a () -> Any and handle the exception initialization themselves. Making this change could also simplify the call site.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah true but the exception type would be wrong. I want a ProviderConfigurationException. What I really want is check(condition, MyErrorType::class) { "lazy message" } I guess but not sure that works
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right sorry, I wasn't suggesting using require or check...I was suggesting keeping this function but constructing the ProviderConfigurationException inside of it rather than in calling code.
| accessKeyId == null -> throw throw ProviderConfigurationException("profile ($name) missing `aws_access_key_id`") | ||
| secretKey == null -> throw throw ProviderConfigurationException("profile ($name) missing `aws_secret_access_key`") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: throw throw? Does this compile?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oof yes but I will fix it, must be copy pasta, good eye
| /** | ||
| * Load [LeafProvider.AccessKey] from the current profile or throw an exception if the profile does not contain | ||
| * credentials | ||
| */ | ||
| private fun AwsProfile.staticCreds(): LeafProvider { | ||
| val accessKeyId = get(AWS_ACCESS_KEY_ID) | ||
| val secretKey = get(AWS_SECRET_ACCESS_KEY) | ||
| return when { | ||
| accessKeyId == null && secretKey == null -> throw ProviderConfigurationException("profile ($name) did not contain credential information") | ||
| accessKeyId == null -> throw throw ProviderConfigurationException("profile ($name) missing `aws_access_key_id`") | ||
| secretKey == null -> throw throw ProviderConfigurationException("profile ($name) missing `aws_secret_access_key`") | ||
| else -> { | ||
| val sessionToken = get(AWS_SESSION_TOKEN) | ||
| LeafProvider.AccessKey(Credentials(accessKeyId, secretKey, sessionToken)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Attempt to load [LeafProvider.AccessKey] from the current profile or `null` if the current profile does not contain | ||
| * credentials | ||
| */ | ||
| private fun AwsProfile.staticCredsOrNull(): LeafProvider? = try { | ||
| staticCreds() | ||
| } catch (_: ProviderConfigurationException) { | ||
| null | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment: At least in JVM, exceptions are slow and thus generally avoided for flow control. This may be more performant by using some other type to encapsulate the result of getting static credentials (e.g., an ADT) and then throwing or returning null as appropriate at a higher level:
sealed class LeafProviderResult {
data class Success(provider: LeafProvider) : LeafProviderResult()
data class Failure(errorMessage: String) : LeafProviderResult()
}
private fun AwsProfile.staticCredsResult(): LeafProviderResult {
val accessKeyId = get(AWS_ACCESS_KEY_ID)
val secretKey = get(AWS_SECRET_ACCESS_KEY)
return when {
accessKeyId == null && secretKey == null -> LeafProviderResult.Failure("profile ($name) did not contain credential information")
accessKeyId == null -> LeafProviderResult.Failure("profile ($name) missing `aws_access_key_id`")
secretKey == null -> LeafProviderResult.Failure("profile ($name) missing `aws_secret_access_key`")
else -> {
val sessionToken = get(AWS_SESSION_TOKEN)
LeafProviderResult.Success(LeafProvider.AccessKey(Credentials(accessKeyId, secretKey, sessionToken)))
}
}
}
private fun AwsProfile.staticCredsOrNull(): LeafProvider? = when (val result = staticCredsResult()) {
is LeafProviderResult.Success -> result.provider
else -> null
}
private fun AwsProfile.staticCredsOrThrow(): LeafProvider = when (val result = staticCredsResult()) {
is LeafProviderResult.Success -> result.provider
else -> throw ProviderConfigurationException(result.errorMessage)
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ya I can look into this.
| return when { | ||
| sourceProfile != null && credSource != null -> throw ProviderConfigurationException("profile ($name) contained both `source_profile` and `credential_source`. Only one or the other can be defined.") | ||
| sourceProfile == null && credSource == null -> throw ProviderConfigurationException("profile ($name) must contain `source_profile` or `credential_source` but neither were defined") | ||
| sourceProfile != null && credSource == null -> if (sourceProfile == name) { | ||
| NextProfile.SelfReference | ||
| } else { | ||
| NextProfile.Named(sourceProfile) | ||
| } | ||
| // loop back into this profile and pick up the credential source | ||
| sourceProfile == null && credSource != null -> NextProfile.SelfReference | ||
| else -> error("shouldn't be able to get here, this is a bug please file a ticket") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment: I'm surprised the compiler doesn't detect else as dead code. Should be able to avoid the else -> error case by rewriting this as nested if/else statements:
return
if (sourceProfile == null) {
if (credSource == null) {
throw ProviderConfigurationException(...)
} else { // credSource != null
NextProfile.SelfReference
}
} else { // sourceProfile != null
if (credSource == null) {
if (sourceProfile == name) NextProfile.SelfReference else NextProfile.Named(sourceProfile)
} else { // credSource != null
throw ProviderConfigurationException(...)
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
true but I find the when cleaner personally, I was surprised as well
|
SonarCloud Quality Gate failed.
|
|
A new generated diff is ready to view: __generated-main...__generated-feat-profile-provider |
…ible (#469) Refactor credential providers to remove CRT dependency and make them KMP compatible. Added SSO provider to default chain. Lots of misc cleanup and improvements. * feat(rt): standalone sso credentials provider (#462) * refactor(rt)!: generated sts and sts web identity credential providers (#470) * refactor(rt)!: implement kmp ecs provider (#475) * feat(rt)!: implement kmp profile credentials provider (#478) * feat(rt)!: kmp default credentials provider chain (#491) * fix: work around machine-specific Gradle bug with aws-config variants (#496) * fix: credentials provider ownership (#498) Co-authored-by: Ian Botsford <83236726+ianbotsf@users.noreply.github.com>








Issue #
N/A
Description of changes
ProfileChain) and tests are mostly ported from Rust (thanks Russell)Credentialsfor easier debuggingTesting
Tested with assuming a role:
Profile:
NOTE: Additional test cases that cover more involved scenarios will be added when we replace the default chain.
Questions
regionis provided to theStsClientbut not the profile provider. There is no good way to currently plumb this information through. The profile provider requires region to be set still in either the profile or env/system property. I think this is correct but wanted to bring it up for discussion.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.