Skip to content

Commit 7f7e013

Browse files
committed
sqlx-postgres, sqlx-aws: impl dynamic passwords
This commit extends PgConnectOptions to support dynamic passwords, while maintaining the API and memory layout of the previous (static-only) implementation. `PgConnectOptions::password` now takes anything that can be turned into a `PasswordOption`, which has support for providers. Providers have a single `fn password(&self)`. I've chosen to implement the `PasswordProvider` trait as a sync function, because there are usage paths (like `ConnectionOptions::to_url_lossy`) that are not `async`. This decision means that providers that need to do async IO need to do so internally, such as by spawning a task. I've included a provider for AWS Aurora DSQL as part of this commit, to show how a more complex provider can be implemented. I was originally thinking of publishing this crate separately (outside the sqlx tree), but it might make sense to keep it in-tree.
1 parent 91d26ba commit 7f7e013

File tree

13 files changed

+1128
-63
lines changed

13 files changed

+1128
-63
lines changed

Cargo.lock

+710-30
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ members = [
99
# "sqlx-bench",
1010
"sqlx-mysql",
1111
"sqlx-postgres",
12+
"sqlx-aws",
1213
"sqlx-sqlite",
1314
"examples/mysql/todos",
1415
"examples/postgres/axum-social-with-tests",

sqlx-aws/Cargo.toml

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
name = "sqlx-aws"
3+
documentation = "https://docs.rs/sqlx"
4+
description = "AWS specific helpers for using sqlx."
5+
6+
version.workspace = true
7+
license.workspace = true
8+
edition.workspace = true
9+
repository.workspace = true
10+
keywords.workspace = true
11+
categories.workspace = true
12+
authors.workspace = true
13+
14+
[features]
15+
default = ["dsql"]
16+
dsql = ["sqlx-postgres", "aws-sdk-dsql"]
17+
18+
[dependencies]
19+
aws-config = { version = "1.6.2", default-features = false, features = ["behavior-version-latest"] }
20+
aws-sdk-dsql = { version = "1.16.0", default-features = false, optional = true }
21+
sqlx-core = { workspace = true }
22+
sqlx-postgres = { workspace = true, optional = true }
23+
tokio = { workspace = true, features = ["rt", "sync"] }
24+
25+
[dev-dependencies]
26+
sqlx = { workspace = true, features = ["runtime-tokio"] }
27+
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "macros"] }
28+
aws-config = { version = "1.6.2", features = ["behavior-version-latest"] }
29+
aws-sdk-dsql = { version = "1.16.0" }
30+
31+
[lints]
32+
workspace = true
33+
34+
[[example]]
35+
name = "dsql"
36+
features = ["dsql"]

sqlx-aws/examples/dsql.rs

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use aws_sdk_dsql::error::BoxError;
2+
use sqlx_aws::iam::dsql::DsqlIamProvider;
3+
use sqlx_postgres::{PgConnectOptions, PgPoolOptions};
4+
5+
#[tokio::main]
6+
async fn main() -> Result<(), BoxError> {
7+
let hostname = std::env::var("DSQL_CLUSTER_ENDPOINT")
8+
.expect("please set DSQL_CLUSTER_ENDPOINT is your environment");
9+
10+
let provider = DsqlIamProvider::new(hostname).await?;
11+
let opts = PgConnectOptions::new_without_pgpass()
12+
.password(provider)
13+
.database("postgres");
14+
let _pool = PgPoolOptions::new().connect_with(opts).await?;
15+
16+
Ok(())
17+
}

sqlx-aws/src/iam/dsql.rs

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
//! Aurora DSQL requires an IAM token in place of a password. Tokens are
2+
//! generated by the AWS SDK using your AWS credentials.
3+
4+
use std::{
5+
borrow::Cow,
6+
fmt,
7+
sync::{Arc, RwLock},
8+
time::Duration,
9+
};
10+
11+
use aws_config::{BehaviorVersion, SdkConfig};
12+
use aws_sdk_dsql::{
13+
auth_token::{AuthToken, AuthTokenGenerator, Config},
14+
error::BoxError,
15+
};
16+
use sqlx_postgres::PasswordProvider;
17+
use tokio::{task::JoinHandle, time::sleep};
18+
19+
/// A builder type to get you build a customized [`DsqlIamProvider`], in case
20+
/// the AWS SDK defaults aren't what you're looking for.
21+
///
22+
/// If you're happy with the AWS SDK defaults, prefer using
23+
/// [`DsqlIamProvider::new`].
24+
///
25+
///
26+
/// ```ignore
27+
/// use sqlx_aws::iam::dsql::*;
28+
///
29+
/// let b = DsqlIamProviderBuilder::defaults().await;
30+
/// let my_config = Config::builder().hostname("...").build()?;
31+
/// let provider = b.with_generator_config(my_config).await?;
32+
/// ```
33+
pub struct DsqlIamProviderBuilder {
34+
cfg: SdkConfig,
35+
is_admin: bool,
36+
}
37+
38+
impl DsqlIamProviderBuilder {
39+
/// A new builder. The AWS SDK is automatically configured.
40+
pub async fn defaults() -> Self {
41+
let cfg = aws_config::load_defaults(BehaviorVersion::latest()).await;
42+
Self::new_with_sdk_cfg(cfg)
43+
}
44+
45+
/// A new builder with custom SDK config.
46+
pub fn new_with_sdk_cfg(cfg: SdkConfig) -> Self {
47+
Self {
48+
cfg,
49+
is_admin: false,
50+
}
51+
}
52+
53+
/// Build a provider with the given [`auth_token::Config`].
54+
pub async fn with_generator_config(self, config: Config) -> Result<DsqlIamProvider, BoxError> {
55+
let DsqlIamProviderBuilder { cfg, is_admin } = self;
56+
57+
// This default value is hardcoded in the AuthTokenGenerator. There is
58+
// no way to share the value.
59+
let expires_in = config.expires_in().unwrap_or(900);
60+
61+
// Token generation is fast (because it is a local operation). However,
62+
// there is some coordination involved (such as loading AWS credentials,
63+
// or tokio scheduling). We want to avoid ever having stale tokens, and so schedule refreshes slightly ahead of expiry.
64+
let refresh_interval = Duration::from_secs(if expires_in > 60 {
65+
expires_in - 60
66+
} else {
67+
expires_in
68+
});
69+
70+
let generator = AuthTokenGenerator::new(config);
71+
72+
// Boostrap: try once. This allows for failing fast for the case where
73+
// things haven't been correctly configured.
74+
let auth_token = match is_admin {
75+
true => generator.db_connect_admin_auth_token(&cfg).await,
76+
false => generator.db_connect_auth_token(&cfg).await,
77+
}?;
78+
79+
let token = Arc::new(RwLock::new(Ok(auth_token)));
80+
let _token = token.clone();
81+
82+
let task = tokio::spawn(async move {
83+
sleep(refresh_interval).await;
84+
85+
loop {
86+
let res = match is_admin {
87+
true => generator.db_connect_admin_auth_token(&cfg).await,
88+
false => generator.db_connect_auth_token(&cfg).await,
89+
};
90+
match res {
91+
Ok(auth_token) => {
92+
*_token.write().expect("never poisoned") = Ok(auth_token);
93+
sleep(refresh_interval).await;
94+
}
95+
// XXX: In theory, this should almost never happen, because
96+
// we did a boostrap token generation, which should catch
97+
// nearly all errors. However, it is possible that the
98+
// underlying credential provider has failed in some way.
99+
Err(err) => {
100+
// Refreshes are eager, which means it may be possible
101+
// that we're about to replace perfectly good token with
102+
// an error. It doesn't seem worthwhile to guard against
103+
// that, since tokens are short lived and are likely to
104+
// expire shortly anyways.
105+
*_token.write().expect("never poisoned") = Err(err);
106+
107+
// sleep an arbitrary amount of time to prevent busy
108+
// loops, but not so long that we don't try again (if
109+
// the underlying error has been resolved).
110+
sleep(Duration::from_secs(1)).await;
111+
}
112+
}
113+
}
114+
});
115+
116+
Ok(DsqlIamProvider { token, task })
117+
}
118+
}
119+
120+
/// A sqlx [`PasswordProvider`] that automatically manages IAM tokens.
121+
///
122+
/// ```ignore
123+
/// use sqlx_postgres::PgConnectOptions;
124+
/// use sqlx_aws::iam::dsql::*;
125+
///
126+
/// let provider = DsqlIamProvider::new("peccy.dsql.us-east-1.on.aws").await?;
127+
/// let opts = PgConnectOptions::new_without_pgpass()
128+
/// .password(provider);
129+
/// ```
130+
pub struct DsqlIamProvider {
131+
token: Arc<RwLock<Result<AuthToken, BoxError>>>,
132+
task: JoinHandle<()>,
133+
}
134+
135+
impl Drop for DsqlIamProvider {
136+
fn drop(&mut self) {
137+
self.task.abort();
138+
}
139+
}
140+
141+
impl DsqlIamProvider {
142+
pub async fn new(hostname: impl Into<String>) -> Result<Self, BoxError> {
143+
let builder = DsqlIamProviderBuilder::defaults().await;
144+
let config = Config::builder()
145+
.hostname(hostname)
146+
.build()
147+
.expect("hostname was provided");
148+
builder.with_generator_config(config).await
149+
}
150+
}
151+
152+
impl PasswordProvider for DsqlIamProvider {
153+
fn password<'a>(&'a self) -> Result<Cow<'a, str>, sqlx_core::error::BoxDynError> {
154+
match &*self.token.read().expect("never poisoned") {
155+
Ok(auth_token) => Ok(Cow::Owned(auth_token.as_str().to_string())),
156+
Err(err) => Err(Box::new(RefreshError(format!("{err}")))),
157+
}
158+
}
159+
}
160+
161+
#[derive(Debug)]
162+
pub struct RefreshError(String);
163+
164+
impl fmt::Display for RefreshError {
165+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166+
write!(f, "unable to refresh auth token: {}", self.0)
167+
}
168+
}
169+
170+
impl std::error::Error for RefreshError {}

sqlx-aws/src/iam/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#[cfg(feature = "dsql")]
2+
pub mod dsql;

sqlx-aws/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod iam;

sqlx-core/src/error.rs

+4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pub enum Error {
5252
#[error("error occurred while attempting to establish a TLS connection: {0}")]
5353
Tls(#[source] BoxDynError),
5454

55+
/// Password provider failed.
56+
#[error("unable to provide a password: {0}")]
57+
PasswordProvider(#[source] BoxDynError),
58+
5559
/// Unexpected or invalid data encountered while communicating with the database.
5660
///
5761
/// This should indicate there is a programming error in a SQLx driver or there

sqlx-postgres/src/connection/establish.rs

+2-4
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ impl PgConnection {
7676
// password in clear-text form.
7777

7878
stream
79-
.send(Password::Cleartext(
80-
options.password.as_deref().unwrap_or_default(),
81-
))
79+
.send(Password::Cleartext(options.password.get()?.as_ref()))
8280
.await?;
8381
}
8482

@@ -91,7 +89,7 @@ impl PgConnection {
9189
stream
9290
.send(Password::Md5 {
9391
username: &options.username,
94-
password: options.password.as_deref().unwrap_or_default(),
92+
password: options.password.get()?.as_ref(),
9593
salt: body.salt,
9694
})
9795
.await?;

sqlx-postgres/src/connection/sasl.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub(crate) async fn authenticate(
8787

8888
// SaltedPassword := Hi(Normalize(password), salt, i)
8989
let salted_password = hi(
90-
options.password.as_deref().unwrap_or_default(),
90+
options.password.get()?.as_ref(),
9191
&cont.salt,
9292
cont.iterations,
9393
)?;

sqlx-postgres/src/lib.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ pub use database::Postgres;
5454
pub use error::{PgDatabaseError, PgErrorPosition};
5555
pub use listener::{PgListener, PgNotification};
5656
pub use message::PgSeverity;
57-
pub use options::{PgConnectOptions, PgSslMode};
57+
pub use options::{PasswordProvider, PgConnectOptions, PgSslMode};
5858
pub use query_result::PgQueryResult;
5959
pub use row::PgRow;
6060
pub use statement::PgStatement;

0 commit comments

Comments
 (0)