AWS 管理コンソールを社内認証と SSO 化させる #m3dev

この記事は「[エムスリー Advent Calendar 2015](http://qiita.com/advent-calendar/2015/m3)」の 5 日目の記事です。

## IAM で個人ユーザとか作りたくない

AWS といえば SDK や CLI で API 連携して色々と操作できるわけですが、一方で管理コンソールも使うことも多いかと思います。この管理コンソールへのアクセス権限の管理はどのようにされているでしょうか？もし逐一 IAM ユーザをつくって運用するとしたら、数十人規模になるとかなり煩雑ですね。

エムスリーの場合、2015 年現在ではオンプレで運用されているサービスの方が多くて、ほとんどのサービスがヘビーに AWS を使っているというわけではないのですが、とはいえ、マネージドサービスはある程度使っていますし、新しいサービスでは全面的に AWS を使っているものもあります。また、AWS アカウント（ルートアカウント）を Sandbox 環境、QA 環境、本番環境と複数つくって運用していますし、関連会社用のアカウントはまた別だったりして、AWS アカウントが結構たくさんあります。

もしこれらに一つ一つログイン可能な IAM ユーザを作って回ったり、入退職に合わせてそのアカウントの一覧も適切に整理しないといけない・・と考えると、ちょっと自分ではやりたくない感じの作業ですよね。

・・ということを考えていた二年前、タイトルのように社内認証でそのまま AWS 管理コンソールにログインできる仕組みをつくりました。これはそのまま今も運用し続けていますが、なかなか便利かなと思っているので、どういったものかをご紹介してみたいと思います。

## M3 AWS 認証ゲートウェイ

どういったものかは以下のスクリーンショットの説明が端的でわかりやすいかと思います。

![Screen Shot 0027-12-04 at 22.15.39.png](https://qiita-image-store.s3.amazonaws.com/0/19163/a86f07f4-8ae8-f4a8-2940-760cd925e2b9.png)

やっていることは、社内認証（OpenID）でログインした状態で AWS アカウントを選択すると、そのユーザに許すべき IAM 権限を持った状態で AWS 管理コンソールにログインさせるというものです。

それだけ聞くとちょっと厄介そうな要件に思われるかもしれませんが、実はそうでもなくて、以下のドキュメントにあるように自社認証との間にフェデレーションプロキシを一つ用意し、これを「Custom Federation Broker」として実装するだけで割と簡単に実現できます。

http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html

この「M3 AWS 認証ゲートウェイ」ではそれを Ruby で実装しましたので、その実装例を公開します。

## 実際の動作イメージ

実際の操作イメージを見てみましょう。まず社内認証（OpenID）で k-sera としてログインしています。

![Screen Shot 0027-12-04 at 22.15.39.png](https://qiita-image-store.s3.amazonaws.com/0/19163/a86f07f4-8ae8-f4a8-2940-760cd925e2b9.png)

以下のように AWS アカウントごとに「ログインする」ボタンが並んでいます。

![Screen Shot 0027-12-04 at 22.15.54.png](https://qiita-image-store.s3.amazonaws.com/0/19163/6d3c3f4e-6c94-ecc5-fdc6-cb50accb0143.png)

カスタムポリシーを変更する場合は「カスタムポリシーの設定」ボタンから変更します。これは JSON 定義を手で編集します... この辺は混み入った設定を確認しようとするとまだまだしんどいのが実際のところかなと思います。

![Screen Shot 0027-12-04 at 22.16.08.png](https://qiita-image-store.s3.amazonaws.com/0/19163/1d0e5ba7-8b3f-e15c-8035-d1ec6a2d5512.png)

[AWS Policy Generator](https://awspolicygen.s3.amazonaws.com/policygen.html) というものが AWS から提供されていますが、新規でざっくりなポリシーをつくるのにはよいものの、ログインしていなくても使えるものなので ARN を絞った設定をつくるなどのケースになってくると検証できません。また、少し前から IAM にシミュレータ機能が提供されるようになりましたが IAM で設定済のポリシーの検証に使う感じなので、これからつくるポリシーのダイナミックな検証の用途ではちょっとやりづらかったりします。

ともあれ、カスタムポリシーを適切に設定できたら、この「AWS マネージメントコンソールにログイン」ボタンを押します。

![Screen Shot 0027-12-04 at 22.15.54.png](https://qiita-image-store.s3.amazonaws.com/0/19163/6d3c3f4e-6c94-ecc5-fdc6-cb50accb0143.png)

すると、以下のように社内アカウントの k-sera として AWS の管理コンソールにログインできます。先ほど表示されていた Power User な IAM ポリシーが有効になっているので、ほとんどの操作が可能な状態になっています。

![Screen Shot 0027-12-04 at 22.44.12.png](https://qiita-image-store.s3.amazonaws.com/0/19163/65d6c1e7-5565-ae8e-3fb9-e86953d09d6b.png)

ここでのポイントは、**この「k-sera」という IAM ユーザは実際には存在しない**という点です。画面上 k-sera と出ていますが、これはあくまでラベルでしかなくて、ログイン時にカスタムポリシーを指定されてテンポラリの IAM ユーザとしてログインしているような状態です。この状態での AWS 管理コンソールの利用にはいくつか制限がありますが、基本的には IAM ユーザの制限がそのまま適用されます。

- ルートアカウントでしかできないことはできない（例：EC2 インスタンスのメール送信制限の解除）
- AWS 管理コンソールには認証フェデレーションでは利用できないサービスや機能があるらしい

後者の方はエムスリーの用途では特に問題になったことはないですが、そのサービスを操作できない場合はトップページでそのサービスのリンクが disabled になると思います。

## 実装の詳細

それでは実装の詳細を見ていきます。

まず、このフェデレーションプロキシがやっていることの概要をまとめます。

- AWS アカウント毎に認証連携で許可して良い最大の権限を持った IAM ユーザをつくり crendentials を１つずつシステムに登録する
- ユーザ + AWS アカウントごとに IAM ポリシーを事前設定可能にする（設定されていなければデフォルトで read-only のポリシーを使う）
- 社内認証でログイン中に AWS へのログインボタンを押されたら IAM ユーザとしてログインしたかのように AWS 管理コンソールにログインさせる
- AWS アカウントに本番かどうかフラグを持ち、本番は管理者のユーザだけが操作できるよう制限する

最初に crendentails をシステムに登録しますが、これは実際の画面の説明を見ていただく方が早そうです。

![Screen Shot 0027-12-04 at 22.20.24.png](https://qiita-image-store.s3.amazonaws.com/0/19163/b17725c0-3cb7-73ab-391a-310c9160318f.png)

IAM ユーザの名前自体は何でもよいのですが「m3-aws-gateway」という IAM ユーザをつくって、その credentails をダウンロードしてシステムに保存していきます。この IAM ユーザが持つポリシーでできる操作が認証連携でログインユーザが実行しうる最大の範囲になります。

今回のフェデレーションプロキシは Ruby on Rails のアプリケーションで、システムの設定を RDB に保持するようになっていますが、テーブル構成は以下のようにとてもシンプルです。管理者の概念がなければ federated_users テーブルはなくても運用できます。

```ruby
# AWS アカウント
create_table "aws_accounts", force: true do |t|
  t.integer  "sort_order",        default: 1,    null: false # 画面上の表示順
  t.string   "name",                             null: false # 画面上の表示名
  t.string   "access_key_id",                    null: false # 最大権限 IAM ユーザの credentials
  t.string   "secret_access_key",                null: false # 最大権限 IAM ユーザの credentials
  t.boolean  "is_production",     default: true, null: false # 本番環境なら true
  t.datetime "created_at"
  t.datetime "updated_at"
end

# デフォルトを上書きするために個別設定されたカスタムポリシー
create_table "custom_policies", force: true do |t|
  t.integer  "federated_user_id",             null: false # federated_users.id
  t.integer  "aws_account_id",                null: false # aws_accounts.id
  t.text     "policy_document",   limit: 255, null: false # JSON 形式のカスタムポリシー
  t.datetime "created_at"
  t.datetime "updated_at"
end

# 認証させるユーザアカウント
create_table "federated_users", force: true do |t|
  t.string   "name",                          null: false # 社内認証のユーザ ID (OpenID)
  t.boolean  "is_admin_user", default: false, null: false # 本番操作可能な管理者なら true
  t.datetime "created_at"
  t.datetime "updated_at"
end

add_index "custom_policies", ["aws_account_id", "federated_user_id"], unique: true
add_index "federated_users", ["name"], unique: true
```

これらの設定を使って、実際にフェデレーションプロキシをやっているところの controller のコードを紹介します。先ほどの「AWS マネージメントコンソールにログイン」ボタンを押すと auth() メソッドが呼ばれます。

```ruby
require 'open-uri'
require 'cgi'
require 'aws-sdk'

class GatewayController < ApplicationController

  before_action :login_required

  # フェデレーションプロキシの URL、公開されていない URL も可
  # AWS 側のログインセッションが切れた時に表示されるリンク先
  ISSUER_URL = 'http://xxx/' 

  CONSOLE_URL = 'https://console.aws.amazon.com/console/home'
  SIGNIN_URL = 'https://signin.aws.amazon.com/federation'

  # あまりサイズが大きいと IAM の仕様上エラーになるので要注意
  # >"User policy size cannot exceed 2,048 characters"
  # http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-limits.html
  ENG_DEFAULT_POLICY = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "autoscaling:Describe*",
        "cloudfront:Get*",
        "cloudfront:List*",
        "cloudwatch:Describe*",
        "cloudwatch:Get*",
        "cloudwatch:List*",
        "directconnect:Describe*",
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
        "dynamodb:Scan",
        "dynamodb:DescribeTable",
        "dynamodb:ListTables",
        "ec2:Describe*",
        "elasticache:Describe*",
        "elasticloadbalancing:Describe*",
        "glacier:ListVaults",
        "glacier:DescribeVault",
        "glacier:GetVaultNotifications",
        "glacier:ListJobs",
        "glacier:DescribeJob",
        "glacier:GetJobOutput",
        "iam:List*",
        "iam:Get*",
        "route53:Get*",
        "route53:List*",
        "redshift:Describe*",
        "redshift:ViewQueriesInConsole",
        "rds:Describe*",
        "rds:ListTagsForResource",
        "s3:Get*",
        "s3:List*",
        "ses:Get*",
        "ses:List*",
        "sns:Get*",
        "sns:List*",
        "sqs:GetQueueAttributes",
        "sqs:ListQueues",
        "sqs:ReceiveMessage",
        "storagegateway:List*",
        "storagegateway:Describe*"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
EOF

  def auth()
    aws = AwsAccount.where(id: params[:aws_account_id]).first
    if aws
      if current_user.groups.find { |_| _ == 'Engineering' } ||
          FederatedUser.where(name: current_user.name).first.present?

        policy = CustomPolicy.where(
            federated_user_id: current_user.id,
            aws_account_id: aws.id
          ).first.try(:policy_document) || ENG_DEFAULT_POLICY.dup
        # 最大文字数制限があるので空白・改行を削除してケチケチ文字数を減らしている
        policy.gsub!(/\s/, '')

        begin
          login_url = build_aws_login_url(aws.access_key_id, aws.secret_access_key, policy)
          redirect_to login_url
        rescue => e
          Rails.logger.info "Failed to authenticate because #{e} response:[#{e.to_json}]"
          redirect_to root_path, alert: "認証に失敗しました。（原因: '#{e}' at #{e.backtrace.first}）"
        end

      else
        redirect_to root_path, alert: 'アクセス権限がありません。必要な場合は管理者に問い合わせてください。'
      end
    else
      redirect_to root_path, alert: '存在しない AWS アカウントです。'
    end
  end

  private

  # ここの処理が要点
  def build_aws_login_url(access_key_id, secret_access_key, policy)
    sts = AWS::STS.new(
      access_key_id: access_key_id,
      secret_access_key: secret_access_key
    )
    session = sts.new_federated_session(current_user.name, policy: policy, duration: 3600)
    session_json = {
      sessionId: session.credentials[:access_key_id],
      sessionKey: session.credentials[:secret_access_key],
      sessionToken: session.credentials[:session_token]
    }.to_json

    get_signin_token_url = "#{SIGNIN_URL}?Action=getSigninToken&SessionType=json&Session=#{CGI.escape(session_json)}"
    returned_content = URI.parse(get_signin_token_url).read
    signin_token = JSON.parse(returned_content)['SigninToken']
    token = CGI.escape(signin_token)
    issuer = CGI.escape(ISSUER_URL)
    destination = CGI.escape(CONSOLE_URL)

    "#{SIGNIN_URL}?Action=login&SigninToken=#{token}&Issuer=#{issuer}&Destination=#{destination}"
  end

end
```

`build_aws_login_url` のところがキーポイントですね。

- STS でフェデレーテッドセッションを開始（社内認証の ID をラベルに指定、IAM ポリシーを設定）
- federation のエンドポイントに Action=getSigninToken でリクエストして Signin Token を取得する
- 取得した Signin Token と遷移先を指定して Action=login の signin URL にブラウザをリダイレクトさせる
- 認証に成功したらブラウザは AWS 管理コンソールのログイン状態になる

といった感じの流れです。

この辺の STS の利用方法については、日本語情報だと以下の PDF ドキュメントが参考になると思います。
http://docs.aws.amazon.com/ja_jp/STS/latest/UsingSTS/sts-ug-ja_jp.pdf
（注：PDF ファイル）

あと、Ruby での実装の注意点としては上記のコードは aws-sdk gem の 1.x で動作しています。2.x だと動かなかったのを確認していますので、2.x でやりたい場合は修正が必要になると思います。

ちなみに余談ですが、パラメータなしで https://signin.aws.amazon.com/federation にアクセスすると Tomcat のエラー画面が見られますね。Java / Servlet で出来てるんだなぁと思ったり。

<img width="494" alt="Screen Shot 2015-12-04 at 11.56.58 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/19163/bb120016-50c0-eff3-acba-e6be8188bcc1.png">

## まとめ

以上、STS を使った社内認証と AWS 管理コンソールの SSO 化についてサンプルコード付きでご紹介しました。エムスリーでは元々社内にあった認証と連携していますが、Google や Facebook などのアカウントとも簡単に連携できるようです。

もし、IAM ユーザの管理でお困りの方がいれば試してみてください。