AWS SDK for Rustを使ってRDS(MySQL)にIAM認証で接続する

## 概要

この記事ではAWS SDK for Rustを使ってRDSにIAM認証で接続する方法を説明します。IAM認証のためにはRDSの認証トークンを生成する必要があるため、まずはその生成方法を説明します。次にRustのMySQLのクライアントライブラリであるmysqlクレートと、sqlxクレートのそれぞれ使ってRDSに接続する方法を説明します。

## RDSのIAM認証

Amazon RDSではIAMを使ってユーザー認証を行うことができます。固定のパスワードを使わずに認証することができるため、セキュリティを向上させることができます。また、IAMによってデータベースへのアクセス権限を制御することができます。

https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html

このIAM認証を行うためには、IAM認証情報から認証トークンを生成してデータベースのパスワードとして使う必要があります。awscliを使って認証トークンを生成する場合は以下のコマンドを利用します。

```bash
aws rds generate-db-auth-token \
   --hostname rdsmysql.123456789012.us-west-2.rds.amazonaws.com \
   --port 3306 \
   --region us-west-2 \
   --username jane_doe
```

https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.AWSCLI.html

このトークンを使ってMySQLデータベースに接続するには、以下のようにいくつかのオプションを指定してmysqlコマンドを実行します。

```bash
mysql --host=hostName --port=portNumber --ssl-ca=full_path_to_ssl_certificate --enable-cleartext-plugin --user=userName --password=authToken

```

- `--ssl-ca`: RDSのSSL証明書を検証するためのCA証明書のパスを指定します。
- `--enable-cleartext-plugin`: パスワードとして指定した認証トークンをハッシュ化や暗号化せずに送信するためのオプションです。RDS側で認証トークンを処理するために必要です。

コマンドラインでは以上の手順で接続できます。以下ではRustプログラムから同様の手順でRDSに接続する方法を説明します。

## RustでのRDS IAM認証トークンの生成

### 必要な依存関係の追加

まず必要なライブラリをCargo.tomlに追加します。以下が今回必要なライブラリを全て追加したCargo.tomlの例です。mysqlクレートとsqlxクレートは役割が重なるのでプロダクションコードではどちらか一方だけで十分です。

AWS SDKクレート群からはaws-config、aws-credential-types、aws-sigv4、aws-smithy-typesを利用します。

```toml
[package]
name = "connect-rds"
version = "0.1.0"
edition = "2021"

[dependencies]
aws-config = "1.1.1"
aws-credential-types = "1.1.1"
aws-sigv4 = "1.1.1"
aws-smithy-types = "1.1.1"
dotenvy = "0.15.7"
http = "1.0.0"
mysql = { version = "24.0.0", default-features = false, features = [
    "default-rustls",
] }
sqlx = { version = "0.7.3", features = [
    "runtime-tokio",
    "tls-rustls",
    "mysql",
] }
tokio = { version = "1", features = ["full"] }
```

### 環境変数の設定

次の準備として、`.env`ファイルを作成して以下の環境変数を設定してください。dotenvyクレートを使ってこのファイルから読み込んだ値を環境変数に設定します。

AWSの認証情報は有効なIAMユーザーのアクセスキーとシークレットアクセスキーを設定します。MySQLのサーバー情報も環境に応じて適切なものを設定してください。

```
AWS_ACCESS_KEY_ID="..."
AWS_SECRET_ACCESS_KEY="..."
AWS_REGION="ap-northeast-1"

MYSQL_HOST="example.rds.amazonaws.com"
MYSQL_PORT="3306"
MYSQL_USER="root"
MYSQL_DATABASE="test"
```

### 認証トークンの生成コード

RDSの認証トークンを生成する関数の全体は以下のようになります。以下ではそれぞれの要素を詳しく見ていきましょう。

```rust
use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials;
use aws_sigv4::http_request::{
    sign, SignableBody, SignableRequest, SignatureLocation, SigningSettings,
};
use aws_sigv4::sign::v4;
use aws_smithy_types::body::SdkBody;
use http::Method;
use std::env;
use std::time::Duration;
use std::time::SystemTime;

pub type OpaqueError = Box<dyn std::error::Error + Send + Sync + 'static>;

pub async fn generate_rds_auth_token() -> Result<String, OpaqueError> {
    let aws_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
    let identity = aws_config
        .credentials_provider()
        .ok_or("No Credentials Provider")?
        .provide_credentials()
        .await?
        .into();
    let mut signing_settings = SigningSettings::default();
    signing_settings.signature_location = SignatureLocation::QueryParams;
    signing_settings.expires_in = Some(Duration::from_secs(900));
    let signing_params = v4::SigningParams::builder()
        .identity(&identity)
        .region(match aws_config.region() {
            Some(region) => region.as_ref(),
            None => "ap-northeast-1",
        })
        .name("rds-db")
        .time(SystemTime::now())
        .settings(signing_settings)
        .build()?
        .into();
    let uri = format!(
        "http://{}:{}/?Action=connect&DBUser={}",
        env::var("MYSQL_HOST")?,
        env::var("MYSQL_PORT")?.parse::<u16>()?,
        env::var("MYSQL_USER")?
    );
    let signable_request = SignableRequest::new(
        Method::GET.as_str(),
        uri.clone(),
        std::iter::empty(),
        SignableBody::Bytes(&[]),
    )?;
    let mut request = http::Request::builder().uri(uri).body(SdkBody::empty())?;
    let (signing_instructions, _signature) = sign(signable_request, &signing_params)?.into_parts();
    signing_instructions.apply_to_request_http1x(&mut request);
    let mut uri = request.uri().to_string();
    let token = uri.split_off("http://".len());
    Ok(token)
}
```

まずaws_configクレートを使ってAWSの認証情報を読み込んで`SdkConfig`構造体を取得します。

```rust
let aws_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
```

後続処理で利用するために`SdkConfig`から`Identity`構造体に変換しておきます。

```rust
let identity = aws_config
    .credentials_provider()
    .ok_or("No Credentials Provider")?
    .provide_credentials()
    .await?
    .into();
```

次にaws_sigv4クレートで署名を作成する準備をしていきます。まずは署名の設定の構造体(`SigningSettings`)を作成します。RDSの認証トークンを生成するためには署名の位置(`signature_location`)をクエリパラメータに指定し、有効期限(`expires_in`)を900秒に設定します。(900秒以外の値を指定すると接続エラーになります。)

```rust
let mut signing_settings = SigningSettings::default();
signing_settings.signature_location = SignatureLocation::QueryParams;
signing_settings.expires_in = Some(Duration::from_secs(900));
```

次にこの`SigningSettings`と先ほど作成した`Identity`、リージョンやサービス名のパラメータを設定して`SigningParams`構造体を作成します。

```rust
let signing_params = v4::SigningParams::builder()
    .identity(&identity)
    .region(match aws_config.region() {
        Some(region) => region.as_ref(),
        None => "ap-northeast-1",
    })
    .name("rds-db")
    .time(SystemTime::now())
    .settings(signing_settings)
    .build()?
    .into();
```

次に署名を行うHTTPリクエストを構成します。まずリクエストURIを作成します。RDSのホストとポートを環境変数から読み込み、また`Action=connect`と`DBUser={MySQLユーザー名}`をクエリパラメータに指定します。次に`SignableRequest`構造体を作成します。メソッドはGET、URIは先ほど作成したURI、ヘッダーおよびリクエストボディは空白にします。

```rust
let uri = format!(
    "http://{}:{}/?Action=connect&DBUser={}",
    env::var("MYSQL_HOST")?,
    env::var("MYSQL_PORT")?.parse::<u16>()?,
    env::var("MYSQL_USER")?
);
let signable_request = SignableRequest::new(
    Method::GET.as_str(),
    uri.clone(),
    std::iter::empty(),
    SignableBody::Bytes(&[]),
)?;
```

この`SignableRequest`と先ほど作成した`SigningParams`を使って署名を行います。`aws_sigv4::http_request::sign`関数から署名の指示と署名自体の2つの値が生成されます(`into_parts()`でそれぞれを別の値にしたタプルにしています)。このうち署名の指示部分をHTTPリクエストに適用して認証トークンを作成します。

```rust
let (signing_instructions, _signature) = sign(signable_request, &signing_params)?.into_parts();
```

署名を適用するためのHTTPリクエストを作成します。

```rust
let mut request = http::Request::builder().uri(uri).body(SdkBody::empty())?;
```

このリクエストに署名の指示を適用します。これによりHTTPリクエストのURIのクエリパラメータに署名情報が追加されます。このURIからプロトコル部分を削除すると認証トークンになります。

```rust
signing_instructions.apply_to_request_http1x(&mut request);
let mut signed_uri = request.uri().to_string();
let token = signed_uri.split_off("http://".len());
```

以下のようなユニットテストでこの関数から出力される認証トークンを確認できます。実行前にdotenvyクレートを使って`.env`ファイルから環境変数を読み込んでおく必要があります。

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use dotenvy::dotenv;

    #[tokio::test]
    async fn test_generate_rds_auth_token() {
        dotenv().ok();
        let rds_auth_token = generate_rds_auth_token().await.unwrap();
        println!("{}", rds_auth_token);
    }
}
```

このユニットテストを実行すると、以下のような認証トークンが生成されたことが確認できます。

```bash
example.rds.amazonaws.com:3306/?Action=connect&DBUser=root&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...%2F20240202%2Fap-northeast-1%2Frds-db%2Faws4_request&X-Amz-Date=20240202T052602Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=...
```

## RDSのSSL証明書の取得

次にこの認証トークンを使ってRDSに接続していきますが、その前にRDSのSSL証明書を検証するためのCA証明書を取得する必要があります。以下のリンクからglobal-bundle.pem(全リージョンの証明書バンドルファイル)またはap-northeast-1-bundle.pemなどのリージョンごとの証明書バンドルファイルをダウンロードしてください。

https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html

## RDSへの接続

### mysqlクレートの利用

mysqlクレートを使ってRDSに接続する関数は以下のようになります。`OptsBuilder`を利用して接続オプションを設定し、`Conn::new()`で接続を行います。

MySQLサーバーの各設定情報は環境変数から読み込みます。パスワードは先ほど作成した認証トークンを使います。コマンドラインでの例と同じように`enable_cleartext_plugin()`で平文でトークンを送るようにします。`ssl_opts()`でSSL証明書をCA証明書で検証するよう指示して、そのCA証明書のパス指定します。このパスには前のセクションでダウンロードした証明書バンドルファイルのパスを指定してください。

```rust
use std::env;

use crate::OpaqueError;

pub fn get_mysql_connection(rds_auth_token: &String) -> Result<mysql::Conn, OpaqueError> {
    let opts = mysql::OptsBuilder::new()
        .ip_or_hostname(env::var("MYSQL_HOST").ok())
        .tcp_port(env::var("MYSQL_PORT")?.parse::<u16>()?)
        .user(env::var("MYSQL_USER").ok())
        .pass(Some(rds_auth_token))
        .db_name(env::var("MYSQL_DATABASE").ok())
        .enable_cleartext_plugin(true)
        .ssl_opts(mysql::SslOpts::default().with_root_cert_path(
            std::path::Path::new("/path/to/certificate/file").into(),
        ));
    let conn = mysql::Conn::new(opts)?;
    Ok(conn)
}
```

### sqlxクレートの利用

sqlxクレートを使ってRDSに接続する関数は以下のようになります。`MySqlConnectOptions`を利用して接続オプションを設定し、`connect()`で接続を行います。

mysqlクレートの場合と同様にパスワードは認証トークンを使います。こちらにも`enable_cleartext_plugin()`オプションがあります。`ssl_mode()`で`VerifyCa`を指定してCA証明書によるSSL証明書の検証を行うよう指示します。また`ssl_ca()`でCA証明書のパスを指定します。こちらも前のセクションでダウンロードした証明書バンドルファイルのパスを指定してください。

```rust
use std::env;

use sqlx::mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode};
use sqlx::ConnectOptions;

use crate::OpaqueError;

pub async fn get_mysql_connection_with_sqlx(
    rds_auth_token: &String,
) -> Result<MySqlConnection, OpaqueError> {
    let options = MySqlConnectOptions::new()
        .host(&env::var("MYSQL_HOST")?)
        .port(env::var("MYSQL_PORT")?.parse::<u16>()?)
        .username(&env::var("MYSQL_USER")?)
        .password(rds_auth_token)
        .database(&env::var("MYSQL_DATABASE")?)
        .ssl_mode(MySqlSslMode::VerifyCa)
        .ssl_ca("/path/to/certificate/file")
        .enable_cleartext_plugin(true);
    let conn = options.connect().await?;
    Ok(conn)
}
```

## 参考文献

認証トークンの生成方法について以下のissueを参考にしました。ただしSDKのバージョンが古いためこのissueで説明されているコードは現在使えません。

https://github.com/awslabs/aws-sdk-rust/issues/147