RFC: SDK Credential Cache Type Safety
Status: Implemented in smithy-rs#2122
Applies to: AWS SDK for Rust
At time of writing (2022-10-11), the SDK's credentials provider can be customized by providing:
- A profile credentials file to modify the default provider chain
- An instance of one of the credentials providers implemented in
aws-config
, such as theAssumeRoleCredentialsProvider
,ImdsCredentialsProvider
, and so on. - A custom struct that implements the
ProvideCredentials
The problem this RFC examines is that when options 2 and 3 above are exercised, the customer
needs to be aware of credentials caching and put additional effort to ensure caching is set up
correctly (and that double caching doesn't occur). This is especially difficult to get right
since some built-in credentials providers (such as AssumeRoleCredentialsProvider
) already
have caching, while most others do not and need to be wrapped in LazyCachingCredentialsProvider
.
The goal of this RFC is to create an API where Rust's type system ensures caching is set up correctly, or explicitly opted out of.
CredentialsCache
and ConfigLoader::credentials_cache
A new config method named credentials_cache()
will be added to ConfigLoader
and the
generated service Config
builders that takes a CredentialsCache
instance. This CredentialsCache
will be a struct with several functions on it to create and configure the cache.
Client creation will ultimately be responsible for taking this CredentialsCache
instance
and wrapping the given (or default) credentials provider.
The CredentialsCache
would look as follows:
enum Inner {
Lazy(LazyConfig),
// Eager doesn't exist today, so this is purely for illustration
Eager(EagerConfig),
// Custom may not be implemented right away
// Not naming or specifying the custom cache trait for now since its out of scope
Custom(Box<dyn SomeCacheTrait>),
NoCaching,
}
pub struct CredentialsCache {
inner: Inner,
}
impl CredentialsCache {
// These methods use default cache settings
pub fn lazy() -> Self { /* ... */ }
pub fn eager() -> Self { /* ... */ }
// Unprefixed methods return a builder that can take customizations
pub fn lazy_builder() -> LazyBuilder { /* ... */ }
pub fn eager_builder() -> EagerBuilder { /* ... */ }
// Later, when custom implementations are supported
pub fn custom(cache_impl: Box<dyn SomeCacheTrait>) -> Self { /* ... */ }
pub(crate) fn create_cache(
self,
provider: Box<dyn ProvideCredentials>,
sleep_impl: Arc<dyn AsyncSleep>
) -> SharedCredentialsProvider {
// Note: SharedCredentialsProvider would get renamed to SharedCredentialsCache.
// This code is using the old name to make it clearer that it already exists,
// and the rename is called out in the change checklist.
SharedCredentialsProvider::new(
match self {
Self::Lazy(inner) => LazyCachingCredentialsProvider::new(provider, settings.time, /* ... */),
Self::Eager(_inner) => unimplemented!(),
Self::Custom(_custom) => unimplemented!(),
Self::NoCaching => unimplemented!(),
}
)
}
}
Using a struct over a trait prevents custom caching implementations, but if customization is desired,
a Custom
variant could be added to the inner enum that has its own trait that customers implement.
The SharedCredentialsProvider
needs to be updated to take a cache implementation in addition to
the impl ProvideCredentials + 'static
. A sealed trait could be added to facilitate this.
Customers that don't care about credential caching can configure credential providers without needing to think about it:
let sdk_config = aws_config::from_env()
.credentials_provider(ImdsCredentialsProvider::builder().build())
.load()
.await;
However, if they want to customize the caching, they can do so without modifying the credentials provider at all (in case they want to use the default):
let sdk_config = aws_config::from_env()
.credentials_cache(CredentialsCache::default_eager())
.load()
.await;
The credentials_cache
will default to CredentialsCache::default_lazy()
if not provided.
Changes Checklist
-
Remove cache from
AssumeRoleProvider
-
Implement
CredentialsCache
with itsLazy
variant and builder -
Add
credentials_cache
method toConfigLoader
-
Refactor
ConfigLoader
to takeCredentialsCache
instead ofimpl ProvideCredentials + 'static
-
Refactor
SharedCredentialsProvider
to take a cache implementation in addition to animpl ProvideCredentials + 'static
-
Remove
ProvideCredentials
impl fromLazyCachingCredentialsProvider
-
Rename
LazyCachingCredentialsProvider
->LazyCredentialsCache
-
Refactor the SDK
Config
code generator to be consistent withConfigLoader
- Write changelog upgrade instructions
- Fix examples (if there are any for configuring caching)
Appendix: Alternatives Considered
Alternative A: ProvideCachedCredentials
trait
In this alternative, aws-types
has a ProvideCachedCredentials
in addition to ProvideCredentials
.
All individual credential providers (such as ImdsCredentialsProvider
) implement ProvideCredentials
,
while credential caches (such as LazyCachingCredentialsProvider
) implement the ProvideCachedCredentials
.
The ConfigLoader
would only take impl ProvideCachedCredentials
.
This allows customers to provide their own caching solution by implementing ProvideCachedCredentials
,
while requiring that caching be done correctly through the type system since ProvideCredentials
is
only useful inside the implementation of ProvideCachedCredentials
.
Caching can be opted out by creating a NoCacheCredentialsProvider
that implements ProvideCachedCredentials
without any caching logic, although this wouldn't be recommended and this provider wouldn't be vended
in aws-config
.
Example configuration:
// Compiles
let sdk_config = aws_config::from_env()
.credentials(
LazyCachingCredentialsProvider::builder()
.load(ImdsCredentialsProvider::new())
.build()
)
.load()
.await;
// Doesn't compile
let sdk_config = aws_config::from_env()
// Wrong type: doesn't implement `ProvideCachedCredentials`
.credentials(ImdsCredentialsProvider::new())
.load()
.await;
Another method could be added to ConfigLoader
that makes it easier to use the default cache:
let sdk_config = aws_config::from_env()
.credentials_with_default_cache(ImdsCredentialsProvider::new())
.load()
.await;
Pros/cons
- :+1: It's flexible, and somewhat enforces correct cache setup through types.
- :+1: Removes the possibility of double caching since the cache implementations won't
implement
ProvideCredentials
. - :-1: Customers may unintentionally implement
ProvideCachedCredentials
instead ofProvideCredentials
for a custom provider, and then not realize they're not benefiting from caching. - :-1: The documentation needs to make it very clear what the differences are between
ProvideCredentials
andProvideCachedCredentials
since they will look identical. - :-1: It's possible to implement both
ProvideCachedCredentials
andProvideCredentials
, which breaks the type safety goals.
Alternative B: CacheCredentials
trait
This alternative is similar to alternative A, except that the cache trait is distinct from ProvideCredentials
so
that it's more apparent when mistakenly implementing the wrong trait for a custom credentials provider.
A CacheCredentials
trait would be added that looks as follows:
pub trait CacheCredentials: Send + Sync + Debug {
async fn cached(&self, now: SystemTime) -> Result<Credentials, CredentialsError>;
}
Instances implementing CacheCredentials
need to own the ProvideCredentials
implementation
to make both lazy and eager credentials caching possible.
The configuration examples look identical to Option A.
Pros/cons
- :+1: It's flexible, and enforces correct cache setup through types slightly better than Option A.
- :+1: Removes the possibility of double caching since the cache implementations won't
implement
ProvideCredentials
. - :-1: Customers can still unintentionally implement the wrong trait and miss out on caching when creating custom credentials providers, but it will be more apparent than in Option A.
- :-1: It's possible to implement both
CacheCredentials
andProvideCredentials
, which breaks the type safety goals.
Alternative C: CredentialsCache
struct with composition
The struct approach posits that customers don't need or want to implement custom credential caching, but at the same time, doesn't make it impossible to add custom caching later.
The idea is that there would be a struct called CredentialsCache
that specifies the desired
caching approach for a given credentials provider:
pub struct LazyCache {
credentials_provider: Arc<dyn ProvideCredentials>,
// ...
}
pub struct EagerCache {
credentials_provider: Arc<dyn ProvideCredentials>,
// ...
}
pub struct CustomCache {
credentials_provider: Arc<dyn ProvideCredentials>,
// Not naming or specifying the custom cache trait for now since its out of scope
cache: Arc<dyn SomeCacheTrait>
}
enum CredentialsCacheInner {
Lazy(LazyCache),
// Eager doesn't exist today, so this is purely for illustration
Eager(EagerCache),
// Custom may not be implemented right away
Custom(CustomCache),
}
pub struct CredentialsCache {
inner: CredentialsCacheInner,
}
impl CredentialsCache {
// Methods prefixed with `default_` just use the default cache settings
pub fn default_lazy(provider: impl ProvideCredentials + 'static) -> Self { /* ... */ }
pub fn default_eager(provider: impl ProvideCredentials + 'static) -> Self { /* ... */ }
// Unprefixed methods return a builder that can take customizations
pub fn lazy(provider: impl ProvideCredentials + 'static) -> LazyBuilder { /* ... */ }
pub fn eager(provider: impl ProvideCredentials + 'static) -> EagerBuilder { /* ... */ }
pub(crate) fn create_cache(
self,
sleep_impl: Arc<dyn AsyncSleep>
) -> SharedCredentialsProvider {
// ^ Note: SharedCredentialsProvider would get renamed to SharedCredentialsCache.
// This code is using the old name to make it clearer that it already exists,
// and the rename is called out in the change checklist.
SharedCredentialsProvider::new(
match self {
Self::Lazy(inner) => LazyCachingCredentialsProvider::new(inner.credentials_provider, settings.time, /* ... */),
Self::Eager(_inner) => unimplemented!(),
Self::Custom(_custom) => unimplemented!(),
}
)
}
}
Using a struct over a trait prevents custom caching implementations, but if customization is desired,
a Custom
variant could be added to the inner enum that has its own trait that customers implement.
The SharedCredentialsProvider
needs to be updated to take a cache implementation rather
than impl ProvideCredentials + 'static
. A sealed trait could be added to facilitate this.
Configuration would look as follows:
let sdk_config = aws_config::from_env()
.credentials(CredentialsCache::default_lazy(ImdsCredentialsProvider::builder().build()))
.load()
.await;
The credentials_provider
method on ConfigLoader
would only take CredentialsCache
as an argument
so that the SDK could not be configured without credentials caching, or if opting out of caching becomes
a use case, then a CredentialsCache::NoCache
variant could be made.
Like alternative A, a convenience method can be added to make using the default cache easier:
let sdk_config = aws_config::from_env()
.credentials_with_default_cache(ImdsCredentialsProvider::builder().build())
.load()
.await;
In the future if custom caching is added, it would look as follows:
let sdk_config = aws_config::from_env()
.credentials(
CredentialsCache::custom(ImdsCredentialsProvider::builder().build(), MyCache::new())
)
.load()
.await;
The ConfigLoader
wouldn't be able to immediately set its credentials provider since other values
from the config are needed to construct the cache (such as sleep_impl
). Thus, the credentials
setter would merely save off the CredentialsCache
instance, and then when load
is called,
the complete SharedCredentialsProvider
would be constructed:
pub async fn load(self) -> SdkConfig {
// ...
let credentials_provider = self.credentials_cache.create_cache(sleep_impl);
// ...
}
Pros/cons
- :+1: Removes the possibility of missing out on caching when implementing a custom provider.
- :+1: Removes the possibility of double caching since the cache implementations won't
implement
ProvideCredentials
. - :-1: Requires thinking about caching when only wanting to customize the credentials provider
- :-1: Requires a lot of boilerplate in
aws-config
for the builders, enum variant structs, etc.