DataGrip などの JetBrains 製 IDE から IAM データベース認証を使って RDS インスタンスに接続する

今までデータベースにアクセスする際には GUI ツールとして Sequel Ace を使っていたんですが、諸事情から IAM データベース認証に対応したツールを利用する必要が出てきました。残念ながら Sequel Ace は IAM データベース認証に対応していません。
cf. Cannot connect to AWS RDS using IAM Authentication · Issue #202 · Sequel-Ace/Sequel-Ace

そこで、代替手段として RubyMine、DataGrip 等 JetBrains 製の IDE を使おうとしたんですが、~/.aws/credentials にクレデンシャルを保存していない自分の使い方だと一苦労したので、接続方法についてまとめてみました。

IAM データベース認証とは?

詳細は IAM database authentication for MariaDB, MySQL, and PostgreSQL に譲るとして、物凄くざっくり説明すると、パスワードとして AWS SDK で発行した token を使ってデータベースに接続する方法です。
データベースに作成するユーザは認証方式以外は通常のユーザと同じですが、パスワードとして利用する token は IAM ユーザに紐付いているので、接続可否を IAM に任せることができます。

なお、token の有効期限は 15 分ですが、ドキュメントで次のように述べられているとおり、一度コネクションを確立すれば token の期限が切れてもクエリを投げることができるようです。

The token is only used for authentication and doesn’t affect the session after it is established.

Recommendations for IAM database authentication でも言及されていますが、アプリケーションで利用する際には次の点を特に気を付けた方が良さそうです。

  • 秒間 200 コネクションが確立される場合は利用すべきじゃない
    • ドキュメントからは token を生成するための API の rate limit の都合と読めるので、コネクションの確立数が多い場合は rate limit が IAM ユーザ単位なのかリージョン単位なのか AWS アカウント単位なのか検証した方が良さそう(Quotas and constraints for Amazon RDS には言及がない)
  • アプリケーション起動時に token を生成するだけだと、15 分経った後に何かしらの理由で新しいコネクションを作成しようとしても失敗する
    • 認証に失敗した場合に再度 token を生成して接続を試みるような処理が必要

検証環境

次のような環境を用意して検証しました

  • データベースエンジンは Amazon Aurora MySQL 3.04.0
  • IAM データベース認証用のユーザ名は iam_db_auth_test
  • データベースクラスタの識別子は iam-db-auth-example-rds
  • データベースには VPC 内からしかアクセスできない
  • VPC 内に踏み台用の EC2 インスタンスがある

「おまけ」に上記の環境を構築するのに使った tf ファイルを載せているので、検証環境を構築したい方はそちらを参照してください。

IAM データベース認証を実現する 3 つの方法

調べた限り、次の 3 つの方法がありました。

  • AWS Toolkit Plugin を使う方法
  • AWS 製の JDBC Driver を使う方法
  • MysqlClearPasswordPlugin を使う方法

以下、それぞれについて説明していきます。

AWS Toolkit Plugin を使う方法

~/.aws/credentials でクレデンシャルを管理している人にとって最も簡単な方法です。

基本的には Connecting to an Amazon RDS database に従って設定すれば接続できると思います。JetBrains DataGrip でのみ利用できるような書きっぷりですが、Database Tools and SQL plugin がプリインストールされている IntelliJ IDEA Ultimate, RubyMine 等でも同様に設定できたので、おそらく IntelliJ IDEA Community Edition を除く全ての JetBrains 製 IDE で同様に接続できます。

もし IAM ユーザがデータベースに接続するための権限しかない場合、ドキュメントの手順では設定できないので、手動で設定する必要あります。
まず、Database ツールウィンドウから Driver and Data Source を選択します。

AWS Toolkit plugin をインストールすると、下図のように Data Sources and Drivers で Data Source を追加する際に Authentication で AWS IAM を選択できるようになります。

User にはデータベースユーザの名前を入力するので、今回は iam_db_auth_test になります。
また、AWS IAM を選択すると Credentials に profile を指定する必要があるので、IAM データベース認証で使う IAM ユーザのクレデンシャルを管理している profile を指定すれば良いです。ここでは iam-db-auth-test という profile を指定しています。

なお、~/.aws/credentials が存在しなければ、たとえ環境変数に AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY がセットされていても登録できません。

IAM データベース認証を利用する場合は TLS を有効にする必要があるのと、今回はデータベースにアクセスするには踏み台を経由しなければいけないので、次のように SSH/SSL の設定も必要です。

SSH configuration に指定している設定は次のとおりで、Host には踏み台サーバの public IP を指定しています。

SSH tunnel の設定をすると、IDE の方でよしなに接続先をローカルホストに変更してくれるようで、General の項目は一切変更する必要がありません。

CA file を指定しなければいけないケースがあるようですが、自分の環境では不要でした。必要な場合は Using SSL/TLS to encrypt a connection to a DB instance の手順に従って CA file をダウンロードした上で指定する必要があります。

AWS 製の JDBC Driver を使う方法

MySQL に使える IAM データベース認証に対応した JDBC driver として、AWS は次の 2 つの driver を提供しています。

データベースに直接アクセスできる場合はどちらも使えると思われますが、踏み台を経由する場合のように、JDBC driver の実際の接続先と RDS ホストが異なる場合はおそらく後者しか使えないので、ここでは後者を利用する方法について説明します。

aws-advanced-jdbc-wrapper は様々な機能を plugin として提供しており、 IAM データベース認証機能も plugin の 1 つです。plugin の使い方については AWS IAM Authentication Plugin に書いてあるので、IDE で利用する際もこれに従って設定します。

JetBrains 製の IDE でカスタム driver を利用するには、Data Sources and Drivers の Drivers タブで + ボタンを押して driver を追加します。Driver Files には custom driver の含まれた jar や、その jar が依存している jar を登録します。

これが少し厄介で、aws-advanced-jdbc-wrapper を含めた一般的な JDBC driver は DriverManagerServiceLoader を使ってクラスパスに存在する driver を全てロードするようになっているようなんですが(この辺あまり詳しくない)、JetBrains 製の IDE ではロードするクラスを 1 つだけ明示的に指定する必要があるようで、aws-advanced-jdbc-wrapper が提供している driver である software.amazon.jdbc.Driver を指定すると、MySQL driver である com.mysql.cj.jdbc.Driver 等、他の driver がロードされません。また、IAM データベース認証を利用するためには token を発行するために AWS Java SDK RDS v2.x や、それが依存しているパッケージまで必要ですが、それらの jar をダウンロードして適当な位置に配置するのは少し手間です。

そこで、software.amazon.jdbc.Driver がロードされると com.mysql.cj.jdbc.Driver もロードされるように改造した上で、依存するパッケージが全て含まれた fat jar を作成するようにしました。

cf. https://github.com/abicky/aws-advanced-jdbc-wrapper/commit/f7f34fdd0b494025d2e59c10d9938d98993a0de5

fat jar は aws-advanced-jdbc-wrapper-2.2.5-SNAPSHOT-all.jar からダウンロードできるようにしてあります。

URL templates には他の driver を参考に次の値を入れました。

jdbc:aws-wrapper:mysql://{host::localhost}?[:{port::3306}][/{database}?][\?<&,user={user},password={password},{:identifier}={:param}>]

また、Options タブで Dialect に MySQL を指定しておくと、MySQL driver を使った他の data source でも利用可能になります。

最後に、プロパティの wrapperPlugins を iam に、iamDefaultPort をデータベースサーバのポート番号に設定してカスタム driver の登録は完了です。

iamDefaultPort はデータベースサーバのポート番号で、踏み台を利用する場合は設定が必要です。というのも、踏み台を利用すると IDE が裏側で data source の URL を書き換えるため、aws-advanced-jdbc-wrapper がその URL をパースして得たポート番号を使って token を生成しても認証に失敗するからです。iamDefaultPort を設定することで、このポート番号を使って token を生成するようになります。

新しく登録したカスタム driver を使うには、data source の Driver を変更します。IAM データベース認証はパスワード認証と同じように利用可能なので、Authentication は User & Password にします。ここで入力した Password は利用されないので、空文字列を入力して Save: Forever にすると良いです。

SSH/SSL の設定は「AWS Toolkit Plugin を使う方法」と同様です。

Advanced タブでは iamHost を設定します。理由は iamDefaultPort と同様で、指定しないと localhost 用の token を発行しようとしてエラーになります。

最後に、IAM ユーザのクレデンシャルを渡す方法ですが、ローカル開発環境で ~/.aws/credentials を利用していない場合は環境変数で渡すことになります。
Advanced タブの VM environment で渡すこともできますが、僕の場合は DataGrip を起動する際に渡しています。普段 envchain を使って環境変数を管理しているんですが、例えば aws という namespace に AWS 関係のクレデンシャルが保存されている場合、macOS では次のコマンドで起動すると IAM ユーザのクレデンシャルが環境変数として引き継がれます。

envchain aws open -a DataGrip

上記のやり方の注意点として、もしアプリケーションの起動時に利用した IAM ユーザが AWS の様々なリソースを操作できる強力な権限を持っている場合、IDE 上で AWS にアクセスするスクリプトなどを実行した時に意図せず本番環境に影響を与える可能性があります。データベースにアクセスするだけの権限を持った IAM ユーザを使うか、データベースへのアクセス以外の用途で IDE を使わないようにした方が良いでしょう。

MysqlClearPasswordPlugin を使う方法

DataGrip で クリアテキスト認証 を使う #DataGrip - ジムには乗りたいに書いてあるとおりです。
ただ、今回の検証環境だと Advanced タブの項目を修正する必要はなく、パスワードの欄に aws rds generate-db-auth-token で生成した token を入力するだけで大丈夫でした。

初めて IAM データベース認証を利用して接続した時には必要な設定だった気がするので、Aurora のバージョンや driver のバージョンの組み合わせによっては不要なのかもしれません。

設定は「AWS 製の JDBC Driver を使う方法」に比べて簡単であり、誤って AWS リソースを操作してしまうリスクもないですが、頻繁に複数のデータベースにアクセスしては接続を切る必要がある場合は割りと手間に感じるかもしれません。

おまけ 〜検証環境の構築〜

今回は次の tf ファイルを使って検証環境を構築しました。

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  default = "ap-northeast-1"
}

variable "db_username" {
  default = "iam_db_auth_test"
}


locals {
  name_prefix = "iam-db-auth-example-"
}

resource "random_string" "password" {
  length           = 16
  override_special = "!#$%&*()-_=+[]{}<>:?"
}

resource "aws_rds_cluster" "default" {
  cluster_identifier     = "${local.name_prefix}rds"
  engine                 = "aurora-mysql"
  engine_version         = "8.0.mysql_aurora.3.04.0"
  master_username        = "admin"
  master_password        = random_string.password.result
  skip_final_snapshot    = true
  vpc_security_group_ids = [aws_security_group.rds.id]

  iam_database_authentication_enabled = true
}

resource "aws_rds_cluster_instance" "default" {
  count = 2

  identifier         = "${local.name_prefix}rds-${count.index}"
  cluster_identifier = aws_rds_cluster.default.id
  instance_class     = "db.t4g.medium"
  engine             = aws_rds_cluster.default.engine
  engine_version     = aws_rds_cluster.default.engine_version
}

resource "aws_security_group" "rds" {
  name = "${local.name_prefix}rds"
}

variable "key_name" {}

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_subnet" "default" {
  default_for_az    = true
  availability_zone = data.aws_availability_zones.available.names[0]
}

data "aws_ami" "most_recent_amazon_linux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-arm64"]
  }
}


resource "aws_security_group" "ec2" {
  name = "${local.name_prefix}ec2"
}

resource "aws_security_group_rule" "ec2_ingress" {
  security_group_id = aws_security_group.ec2.id
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "ec2_egress" {
  security_group_id = aws_security_group.ec2.id
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "rds_ingress" {
  security_group_id = aws_security_group.rds.id
  type              = "ingress"
  from_port         = 3306
  to_port           = 3306
  protocol          = "tcp"

  source_security_group_id = aws_security_group.ec2.id
}

resource "aws_security_group_rule" "rds_egress" {
  security_group_id = aws_security_group.rds.id
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"

  source_security_group_id = aws_security_group.ec2.id
}

resource "aws_instance" "bastion" {
  ami           = data.aws_ami.most_recent_amazon_linux_2023.image_id
  instance_type = "t4g.nano"
  key_name      = var.key_name

  #subnet_id              = aws_subnet.subnet_az1.id
  vpc_security_group_ids = [aws_security_group.ec2.id]

  associate_public_ip_address = true
}

resource "aws_iam_user" "test" {
  name = "${local.name_prefix}test"
}

resource "aws_iam_access_key" "test" {
  user = aws_iam_user.test.name
}

data "aws_caller_identity" "current" {}

resource "aws_iam_user_policy" "test" {
  name = "iam-db-auth"
  user = aws_iam_user.test.name

  policy = <<-JSON
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Action": [
          "rds-db:connect"
        ],
        "Resource": [
          "arn:aws:rds-db:${var.aws_region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.default.cluster_resource_id}/${var.db_username}"
        ]
      }
   ]
  }
  JSON
}

output "ami_id" {
  value = aws_instance.bastion.ami
}

output "bastion_public_ip" {
  value = aws_instance.bastion.public_ip
}

output "rds_endpoint" {
  value = aws_rds_cluster.default.endpoint
}

output "rds_reader_endpoint" {
  value = aws_rds_cluster.default.reader_endpoint
}

output "rds_password" {
  value     = aws_rds_cluster.default.master_password
  sensitive = true
}

output "aws_access_key_id" {
  value = aws_iam_access_key.test.id
}

output "aws_secret_access_key" {
  value     = aws_iam_access_key.test.secret
  sensitive = true
}

aws_secret_access_key, rds_password の値は terraform applyterraform output だと <sensitive> という表示になりますが、terraform output --json だと実際の値を確認することができます。

値を確認したら、踏み台にポートフォワーディングをします。

ssh -fNL \
  13306:iam-db-auth-example-rds.cluster-xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306 \
  <bastion_public_ip> \
  -l ec2-user \
  -i /path/to/key

ポートフォワーディングしたら、そのポートに対して admin ユーザでアクセスします。パスワードは rds_password の値を入力します。

mysql -u admin -h 127.0.0.1 -P 13306 -p

データベースに接続できたら、次のクエリを実行することで IAM データベース認証で接続できるユーザが作成されます。

CREATE USER iam_db_auth_test IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS' REQUIRE SSL;

もし作成したユーザに強力な権限を付与したい場合は次のようにして権限を付与します (どういうわけか *.* だと Access denied になります)

GRANT ALL ON `%`.* TO iam_db_auth_test;

IAM ユーザのプロファイル名が iam-db-auth-test の場合、次のコマンドで接続可否を確認できます。

token=$(aws rds generate-db-auth-token \
  --profile iam-db-auth-test \
  --hostname iam-db-auth-example-rds.cluster-ro-cv0e2etijwco.ap-northeast-1.rds.amazonaws.com \
  --port 3306 \
  --username iam_db_auth_test)

mysql \
  --protocol tcp \
  -u iam_db_auth_test \
  -h 127.0.0.1 \
  -P 13306 \
  -p${token} \
  --enable-cleartext-plugin