Skip to content

Commit

Permalink
Add RDS URL signer (#3867)
Browse files Browse the repository at this point in the history
## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here -->
[aws-sdk-rust/951](awslabs/aws-sdk-rust#951)

## Description
<!--- Describe your changes in detail -->
Adds a struct for generating signed URLs for logging in to RDS. See
[this
doc](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.html)
for more info.

## Testing
<!--- Please describe in detail how you tested your changes -->
<!--- Include details of your testing environment, and the tests you ran
to -->
<!--- see how your change affects other areas of the code, etc. -->
I wrote a test.

## Checklist
<!--- If a checkbox below is not applicable, then please DELETE it
rather than leaving it unchecked -->
- [ ] For changes to the smithy-rs codegen or runtime crates, I have
created a changelog entry Markdown file in the `.changelog` directory,
specifying "client," "server," or both in the `applies_to` key.
- [ ] For changes to the AWS SDK, generated SDK code, or SDK runtime
crates, I have created a changelog entry Markdown file in the
`.changelog` directory, specifying "aws-sdk-rust" in the `applies_to`
key.

----

_By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice._
  • Loading branch information
Velfi authored Oct 17, 2024
1 parent eb48261 commit c8c610f
Show file tree
Hide file tree
Showing 12 changed files with 394 additions and 18 deletions.
6 changes: 4 additions & 2 deletions aws/rust-runtime/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion aws/rust-runtime/aws-config/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions aws/rust-runtime/aws-inlineable/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ http_1x = ["dep:http-1x", "dep:http-body-1x", "aws-smithy-runtime-api/http-1x"]
aws-credential-types = { path = "../aws-credential-types" }
aws-runtime = { path = "../aws-runtime", features = ["http-02x"] }
aws-sigv4 = { path = "../aws-sigv4" }
aws-types = { path = "../aws-types" }
aws-smithy-async = { path = "../../../rust-runtime/aws-smithy-async", features = ["rt-tokio"] }
aws-smithy-checksums = { path = "../../../rust-runtime/aws-smithy-checksums" }
aws-smithy-http = { path = "../../../rust-runtime/aws-smithy-http" }
Expand All @@ -37,8 +38,10 @@ ring = "0.17.5"
sha2 = "0.10"
tokio = "1.23.1"
tracing = "0.1"
url = "2.5.2"

[dev-dependencies]
aws-credential-types = { path = "../aws-credential-types", features = ["test-util"] }
aws-smithy-async = { path = "../../../rust-runtime/aws-smithy-async", features = ["test-util"] }
aws-smithy-http = { path = "../../../rust-runtime/aws-smithy-http", features = ["rt-tokio"] }
aws-smithy-runtime-api = { path = "../../../rust-runtime/aws-smithy-runtime-api", features = ["test-util"] }
Expand Down
3 changes: 2 additions & 1 deletion aws/rust-runtime/aws-inlineable/external-types.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
allowed_external_types = [
"aws_credential_types::provider::credentials::ProvideCredentials",
"aws_types::*",
"aws_credential_types::*",
"aws_smithy_http::*",
"aws_smithy_runtime_api::*",

Expand Down
2 changes: 2 additions & 0 deletions aws/rust-runtime/aws-inlineable/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ pub mod s3_expires_interceptor;
/// allow docs to work
#[derive(Debug)]
pub struct Client;

pub mod rds_auth_token;
314 changes: 314 additions & 0 deletions aws/rust-runtime/aws-inlineable/src/rds_auth_token.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

//! Code related to creating signed URLs for logging in to RDS.
//!
//! For more information, see <https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.html>
use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
use aws_sigv4::http_request;
use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings};
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::box_error::BoxError;
use aws_smithy_runtime_api::client::identity::Identity;
use aws_types::region::Region;
use std::fmt;
use std::fmt::Debug;
use std::time::Duration;

const ACTION: &str = "connect";
const SERVICE: &str = "rds-db";

/// A signer that generates an auth token for a database.
///
/// ## Example
///
/// ```ignore
/// use crate::auth_token::{AuthTokenGenerator, Config};
///
/// #[tokio::main]
/// async fn main() {
/// let cfg = aws_config::load_defaults(BehaviorVersion::latest()).await;
/// let generator = AuthTokenGenerator::new(
/// Config::builder()
/// .hostname("zhessler-test-db.cp7a4mblr2ig.us-east-1.rds.amazonaws.com")
/// .port(5432)
/// .username("zhessler")
/// .build()
/// .expect("cfg is valid"),
/// );
/// let token = generator.auth_token(&cfg).await.unwrap();
/// println!("{token}");
/// }
/// ```
#[derive(Debug)]
pub struct AuthTokenGenerator {
config: Config,
}

/// An auth token usable as a password for an RDS database.
///
/// This struct can be converted into a `&str` using the `Deref` trait or by calling `to_string()`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthToken {
inner: String,
}

impl AuthToken {
/// Return the auth token as a `&str`.
#[must_use]
pub fn as_str(&self) -> &str {
&self.inner
}
}

impl fmt::Display for AuthToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.inner)
}
}

impl AuthTokenGenerator {
/// Given a `Config`, create a new RDS database login URL signer.
pub fn new(config: Config) -> Self {
Self { config }
}

/// Return a signed URL usable as an auth token.
pub async fn auth_token(
&self,
config: &aws_types::sdk_config::SdkConfig,
) -> Result<AuthToken, BoxError> {
let credentials = self
.config
.credentials()
.or(config.credentials_provider())
.ok_or("credentials are required to create a signed URL for RDS")?
.provide_credentials()
.await?;
let identity: Identity = credentials.into();
let region = self
.config
.region()
.or(config.region())
.cloned()
.unwrap_or_else(|| Region::new("us-east-1"));
let time = config.time_source().ok_or("a time source is required")?;

let mut signing_settings = SigningSettings::default();
signing_settings.expires_in = Some(Duration::from_secs(
self.config.expires_in().unwrap_or(900).min(900),
));
signing_settings.signature_location = http_request::SignatureLocation::QueryParams;

let signing_params = v4::SigningParams::builder()
.identity(&identity)
.region(region.as_ref())
.name(SERVICE)
.time(time.now())
.settings(signing_settings)
.build()?;

let url = format!(
"https://{}:{}/?Action={}&DBUser={}",
self.config.hostname(),
self.config.port(),
ACTION,
self.config.username()
);
let signable_request =
SignableRequest::new("GET", &url, std::iter::empty(), SignableBody::empty())
.expect("signable request");

let (signing_instructions, _signature) =
http_request::sign(signable_request, &signing_params.into())?.into_parts();

let mut url = url::Url::parse(&url).unwrap();
for (name, value) in signing_instructions.params() {
url.query_pairs_mut().append_pair(name, value);
}
let inner = url.to_string().split_off("https://".len());

Ok(AuthToken { inner })
}
}

/// Configuration for an RDS auth URL signer.
#[derive(Debug, Clone)]
pub struct Config {
/// The AWS credentials to sign requests with.
///
/// Uses the default credential provider chain if not specified.
credentials: Option<SharedCredentialsProvider>,

/// The hostname of the database to connect to.
hostname: String,

/// The port number the database is listening on.
port: u64,

/// The region the database is located in. Uses the region inferred from the runtime if omitted.
region: Option<Region>,

/// The username to login as.
username: String,

/// The number of seconds the signed URL should be valid for.
///
/// Maxes at 900 seconds.
expires_in: Option<u64>,
}

impl Config {
/// Create a new `SignerConfigBuilder`.
pub fn builder() -> ConfigBuilder {
ConfigBuilder::default()
}

/// The AWS credentials to sign requests with.
pub fn credentials(&self) -> Option<SharedCredentialsProvider> {
self.credentials.clone()
}

/// The hostname of the database to connect to.
pub fn hostname(&self) -> &str {
&self.hostname
}

/// The port number the database is listening on.
pub fn port(&self) -> u64 {
self.port
}

/// The region to sign requests with.
pub fn region(&self) -> Option<&Region> {
self.region.as_ref()
}

/// The DB username to login as.
pub fn username(&self) -> &str {
&self.username
}

/// The number of seconds the signed URL should be valid for.
///
/// Maxes out at 900 seconds.
pub fn expires_in(&self) -> Option<u64> {
self.expires_in
}
}

/// A builder for [`Config`]s.
#[derive(Debug, Default)]
pub struct ConfigBuilder {
/// The AWS credentials to create the auth token with.
///
/// Uses the default credential provider chain if not specified.
credentials: Option<SharedCredentialsProvider>,

/// The hostname of the database to connect to.
hostname: Option<String>,

/// The port number the database is listening on.
port: Option<u64>,

/// The region the database is located in. Uses the region inferred from the runtime if omitted.
region: Option<Region>,

/// The database username to login as.
username: Option<String>,

/// The number of seconds the auth token should be valid for.
expires_in: Option<u64>,
}

impl ConfigBuilder {
/// The AWS credentials to create the auth token with.
///
/// Uses the default credential provider chain if not specified.
pub fn credentials(mut self, credentials: impl ProvideCredentials + 'static) -> Self {
self.credentials = Some(SharedCredentialsProvider::new(credentials));
self
}

/// The hostname of the database to connect to.
pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
self.hostname = Some(hostname.into());
self
}

/// The port number the database is listening on.
pub fn port(mut self, port: u64) -> Self {
self.port = Some(port);
self
}

/// The region the database is located in. Uses the region inferred from the runtime if omitted.
pub fn region(mut self, region: Region) -> Self {
self.region = Some(region);
self
}

/// The database username to login as.
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}

/// The number of seconds the signed URL should be valid for.
///
/// Maxes out at 900 seconds.
pub fn expires_in(mut self, expires_in: u64) -> Self {
self.expires_in = Some(expires_in);
self
}

/// Consume this builder, returning an error if required fields are missing.
/// Otherwise, return a new `SignerConfig`.
pub fn build(self) -> Result<Config, BoxError> {
Ok(Config {
credentials: self.credentials,
hostname: self.hostname.ok_or("A hostname is required")?,
port: self.port.ok_or("a port is required")?,
region: self.region,
username: self.username.ok_or("a username is required")?,
expires_in: self.expires_in,
})
}
}

#[cfg(test)]
mod test {
use super::{AuthTokenGenerator, Config};
use aws_credential_types::provider::SharedCredentialsProvider;
use aws_credential_types::Credentials;
use aws_smithy_async::test_util::ManualTimeSource;
use aws_types::region::Region;
use aws_types::SdkConfig;
use std::time::{Duration, UNIX_EPOCH};

#[tokio::test]
async fn signing_works() {
let time_source = ManualTimeSource::new(UNIX_EPOCH + Duration::from_secs(1724709600));
let sdk_config = SdkConfig::builder()
.credentials_provider(SharedCredentialsProvider::new(Credentials::new(
"akid", "secret", None, None, "test",
)))
.time_source(time_source)
.build();
let signer = AuthTokenGenerator::new(
Config::builder()
.hostname("prod-instance.us-east-1.rds.amazonaws.com")
.port(3306)
.region(Region::new("us-east-1"))
.username("peccy")
.build()
.unwrap(),
);

let signed_url = signer.auth_token(&sdk_config).await.unwrap();
assert_eq!(signed_url.as_str(), "prod-instance.us-east-1.rds.amazonaws.com:3306/?Action=connect&DBUser=peccy&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=akid%2F20240826%2Fus-east-1%2Frds-db%2Faws4_request&X-Amz-Date=20240826T220000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=dd0cba843009474347af724090233265628ace491ea17ce3eb3da098b983ad89");
}
}
2 changes: 1 addition & 1 deletion aws/rust-runtime/aws-sigv4/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aws-sigv4"
version = "1.2.4"
version = "1.2.5"
authors = ["AWS Rust SDK Team <[email protected]>", "David Barsky <[email protected]>"]
description = "SigV4 signer for HTTP requests and Event Stream messages."
edition = "2021"
Expand Down
Loading

0 comments on commit c8c610f

Please sign in to comment.