Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

データベースの固定パスワードをなくす

プラットフォームチームの菅原です。

カンムのサービスで使われている各種アプリケーション(Goアプリ・管理アプリ・Redash等)では、データベースに接続する場合に一般的なパスワード認証を使っていることが多いです。

しかし、パスワード認証はパスワード漏洩のリスクやパスワード管理の手間があり、また要件によっては定期的なパスワードの変更も必要になってきます。 単純な方法で安全にパスワードをローテーションしようとすると、新しいDBユーザーを作成し、アプリケーションの接続ユーザーを変更し、さらに必要であれば元のDBユーザーのパスワードを変更して、接続ユーザーを元に戻す…などのオペレーションが必要になります。

そこで、AWS RDS(PostgreSQL)の「Secrets Managerによるマスターユーザーパスワードのパスワード管理」と「IAMデータベース認証」を利用してシステムから固定パスワードをなくすようにしてみました。

Secrets Managerによるマスターユーザーパスワードの管理

docs.aws.amazon.com

「Secrets Managerによるマスターユーザーパスワード管理」はRDSのマスターユーザーパスワードをSecrets Managerに管理させて定期的にパスワードをローテーションさせる機能です。 パスワードを管理するSecretは自動的に作成されるので、RDS側の設定を変更するだけで機能は有効になります。

terraformでの設定は以下のようになります。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  manage_master_user_password = true

  # aws secretsmanager get-secret-value \
  #   --secret-id $(
  #    aws rds describe-db-clusters \
  #      --db-cluster-identifier my-db \
  #      --query 'DBClusters[0].MasterUserSecret.SecretArn' \
  #      --output text
  #   ) \
  #   --query SecretString --output text
}

Secret自体は自動作成されるのですがローテーション間隔やポリシーは管理したいので、作成されたSecretをterraformにインポートします。

resource "aws_secretsmanager_secret_rotation" "my_db" {
  secret_id = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  rotation_rules {
    automatically_after_days = 7
  }
}

resource "aws_secretsmanager_secret_policy" "my_db" {
  secret_arn = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "secretsmanager:GetSecretValue"
        Condition = {
          ArnNotEquals = {
            "aws:PrincipalArn" = "(アクセスを許可するIAMロール)"
          }
        }
        Effect    = "Deny"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

オンラインの設定変更でも特にダウンタイムが発生するようなこともなく、簡単にマスターユーザーパスワードのローテーションを自動化することができました。

IAMデータベース認証

docs.aws.amazon.com

「IAMデータベース認証」はパスワードの代わりに一時的な認証トークンを生成して許可されたIAMロールからデータベースに接続できるようにする機能です。認証トークンの有効期限は15分で、データベースに接続できれば基本的にコネクションは維持されます。

マスターユーザーパスワードと違ってアプリケーションで使われているので、アプリケーション側の修正も必要になります。

RDS・IAMの設定

「IAMデータベース認証」を有効にするには、まずRDSの設定を変更します。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  iam_database_authentication_enabled = true
}

PostgreSQLの場合、接続するDBユーザー(ロール)にrds_iamロールを付与します。

GRANT rds_iam TO app_db_user;

rds_iamを付与するとIAM認証以外では接続できなくなるので注意が必要です。 IAM認証を有効化する際には、IAM認証用のユーザーを新しく作成して、アプリケーションの接続ユーザーをそちらに変更するようにしました。

DBに接続するEC2インスタンスやECSタスクのIAMロールにはrds-db:connectの権限を付与します。

resource "aws_iam_role_policy" "app_db_connect" {
  role = aws_iam_role.app.name
  name = "db-connect"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "rds-db:connect"
        Resource = "arn:aws:rds-db:ap-northeast-1:123456789012:dbuser:${aws_rds_cluster.my_db.cluster_resource_id}/app_db_user"
      }
    ]
  })
}

アプリケーション側の変更

Goアプリ

database/sqlsql.Open()はコネクションプールを返すので、sql.Open()を呼び出すタイミングと実際にデータベースに接続するタイミングは異なります。IAM認証を使う場合、実際にデータベースに接続するタイミングで認証トークンを生成するように設定する必要があります。

PostgreSQL用のドライバjackc/pgxではstdlib.GetConnector()にオプションを渡すことでデータベース接続のコールバックを設定することができます。

実際のコードは以下のような感じになりました。

func ConnectDB(dsn *url.URL, iamAuth bool) (*sql.DB, error) {
    opts := []stdlib.OptionOpenDB{}

    if iamAuth {
        host := dsn.Hostname()
        port := dsn.Port()
        username := dsn.User.Username()

        if !strings.HasSuffix(host, ".rds.amazonaws.com") {
            var err error
            host, err = net.LookupCNAME(host)

            if err != nil {
                return nil, err
            }

            host = strings.TrimSuffix(host, ".")
        }

        // import "github.com/jackc/pgx/v5/stdlib"
        opts = append(opts, stdlib.OptionBeforeConnect(func(ctx context.Context, cc *pgx.ConnConfig) error {
            awscfg, err := config.LoadDefaultConfig(ctx)

            if err != nil {
                return err
            }

            // import "github.com/aws/aws-sdk-go-v2/feature/rds/auth"
            token, err := auth.BuildAuthToken(ctx, host+":"+port, awscfg.Region, username, awscfg.Credentials)

            if err != nil {
                return err
            }

            cc.Password = token
            return nil
        }))
    }

    cfg, err := pgx.ParseConfig(dsn.String())

    if err != nil {
        return nil, err
    }

    // import "github.com/jackc/pgx/v5/stdlib"
    connector := stdlib.GetConnector(*cfg, opts...)
    db := sql.OpenDB(connector)
    return db, nil
}

データベースのエンドポイントにはCNAMEのエイリアスをつけているので、net.LookupCNAME()で実際のエンドポイントを取得しています。

Djangoアプリ

管理用のDjangoアプリはlabd/django-iam-dbauthで対応しました。

DATABASES = {
    "default": {
        "HOST": "<hostname>",
        "USER": "<user>",
        "NAME": "<db name>",
        "ENGINE": "django_iam_dbauth.aws.postgresql",
        "OPTIONS": {
            "use_iam_auth": True,
            "sslmode": "require",
            "resolve_cname_enabled": True,
        }
    }
}

CNAMEの解決機能が実装されているのが便利です。

DBマイグレーション

GoアプリのDBマイグレーションにはAlembicを使っています。 データベースのパスワードは環境変数経由でAlembicに渡されます。

環境変数で認証トークンを渡す場合、AWS CLIで生成することが多いのですが「AWS CLI一式をDockerイメージに入れたくない」「ホストやユーザー名の渡し方を簡潔にしたい」「CNAMEの解決をしたい」といった理由から、認証トークンを生成するCLI rdsauthを作成しました。

github.com

rdsauthはDB URLを渡すと認証トークンを生成します。また-eオプションをつけることでexport PGPASSWORD=...の形式で出力することもできます。

rdsauthを使って、以下のようにDBマイグレーションを実行するようにしました。

export DB_USER=...
export DB_HOST=...
export DB_PASSWORD=$(rdsauth postgres://${DB_USER}@${DB_HOST})

if [ "$MIGRATE" = "true" ]; then
  python manage.py migrate
else
  python manage.py showmigrations
fi

AlembicをIAM認証に対応させる上で少しつまずいたのがconfigparserのエスケープです。 env.pyで以下のようにしてDB接続情報を渡すことがあると思うのですが、configは内部的にconfigparserを使っているため%エスケープが必要になります。

# dbpass = os.environ.get("DB_PASSWORD") # NG
dbpass = os.environ.get("DB_PASSWORD").replace("%", "%%")
config.set_section_option("alembic", "DB_PASSWORD", dbpass)

connectable = engine_from_config(
    config.get_section(config.config_ini_section),
    # ...
)

psql

基本的にpsqlで直接データベースに接続することはないのですが、どうしてもpsqlでの作業が必要になることがあります。 そのための作業用ユーザーもIAM認証で接続するようにしました。

psqlの接続情報は.pg_service.confで管理されており、$(rdsauth -e postgres://..) ; psql service=my-dbと実行すればデータベースに接続することができるのですが、rdsauthの接続情報は.pg_service.confから自動的に取得してほしかったので簡単なラッパースクリプト pxを作成しました。

#!/bin/bash
PC_SERVICE_CONF=~/.pg_service.conf

if [ $# -eq 0 ]; then
  echo "usage: px <service> [extra-args ...]"
  echo -e "\nservice:"
  grep '^\[' $PC_SERVICE_CONF | tr -d '[]' | sed 's/^/  - /'
  exit 0
fi

set -eo pipefail

NAME=$1
shift

JSON=$(ini2json $PC_SERVICE_CONF | jq --arg name "$NAME" '.[$name]')

if [ "$JSON" = "null" ]; then
  echo "error: service not found - $NAME"
  exit 1
fi

DB_HOST=$(echo "$JSON" | jq -r '.host')
DB_PORT=$(echo "$JSON" | jq -r '.port')
DB_USER=$(echo "$JSON" | jq -r '.user')

$(rdsauth -e "postgres://${DB_USER}@${DB_HOST}:${DB_PORT}")

psql service="$NAME" "$@"

px my-dbを実行することでIAM認証を意識せずにpsqlでデータベースに接続できます。

Terraform(PostgreSQL)

PostgreSQLのロールはterraform(cyrilgdn/terraform-provider-postgresql)で管理しているので、これもIAM認証で接続するようにしました。

プロバイダーにはrdsauthを使って環境変数経由で認証トークンを渡すこともできるのですが、aws_rds_iam_authというオプションを有効にすればプロバイダー内部で認証トークンを生成してくれます。

terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = ">= 1.25"
    }
  }
}

variable "aws_rds_iam_auth" {
  type    = bool
  default = false
}

provider "postgresql" {
  # ...
  aws_rds_iam_auth = var.aws_rds_iam_auth
}

Redash

Redashは、Redash本体のデータベースへの接続とPostgreSQLデータソースの接続でIAM認証をすることになりますが、残念ながら今のところどちらもIAM認証には対応していません。

しかし、修正自体はそれほど難しくはないのでPull Requestを作成しました。マージされることを祈るばかりです。

github.com github.com

まとめ

AWS RDSの機能を使って固定パスワードをなくす話を書きました。

今までは積極的にこれらの機能を使ってこなかったのですが、やってみると特に問題なく導入することができました。 パスワードのローテーション作業は気をつかう手間のかかる作業なので、今後はなるべくデフォルトで有効にして運用コストを下げていきたいと考えています。