エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

サーバーレスでプライベートな STNS のバックエンド API を AWS で実現した話

お久しぶりです、エムスリーエンジニアリンググループ 兼 QLife エンジニアの園田です。

STNS という pam と連携可能な HTTP プロトコルを利用した Linux の認証機構があります。
通常は TOML でアカウント情報を管理するのですが、API のインターフェースが公開されているため、独自のバックエンドサーバーを構築して利用することも可能です。

https://stns.jp/images/stns-architecture.png

弊社 SRE の寺岡も独自のバックエンドを実装した記事を Qiita に投稿しています。

qiita.com

今回実装した STNS のバックエンドは、AWS の API Gateway + Lambda + DynamoDB で実現しました。
ただ、この構成もすでに既出記事がいくつかあり、そんなに珍しくもありません。

今回の実装のポイントは、API Gateway の Private エンドポイントで実装していて、特定の VPC や VPC エンドポイント経由でしか利用できないようにしていることです。

VPC エンドポイントとは、VPC 内から VPC 外のリソースにアクセスする際に、 Private IP が割り当てられた名前解決可能なエンドポイントです。API Gateway での VPC エンドポイントの利用については AWS のドキュメントをご覧ください。

AWS リソースの構築は Terraform を利用します。当初 SAM (Serverless Application Model) での実装を試みたのですが、 Private エンドポイントで必要になる API Key が STNS のリクエスト形式と一致しなかったため SAM での構築は諦め、Custom Authorizer に API Key の処理をさせることでむりやりインターフェースをあわせました。

なお、 Custom Authorizer を利用した副次的効果として、サーバーごとに認証を許可するアカウントやグループを制御することが可能になりました。

ごちゃごちゃ書いていますけど、まとめると、

  • VPC 内 または VPC エンドポイント経由でしかアクセスできない STNS のバックエンド API をサーバーレスで実現した。
  • Custom Authorizer により、サーバーごとに認証を許可するアカウントやグループを制御できるようにした。

ということです。

Terraform のソースは Github に置いてあります。

github.com

使い方

AWS リソースの構築

バックエンド API を AWS に構築します。以下、モジュール利用の例です。

provider "aws" {
    region = "ap-northeast-1"
    version = "1.59.0"
}

variable "stns_allowed_vpce_id" {
    description = "STNS API にアクセスを許可する VPC エンドポイント ID"
}

module "stns-backend" {
    source = "github.com/QLife-Inc/aws-stns-serverless-api"
    api_policy_json = <<EOF
  {
     "Version": "2012-10-17",
     "Statement": [
         {
             "Effect": "Deny",
             "Principal": "*",
             "Action": "execute-api:Invoke",
             "Resource": "execute-api:/*/*/*",
             "Condition": {
                 "StringNotEquals": {
                     "aws:sourceVpce": "${var.stns_allowed_vpce_id}"
                 }
             }
         },
         {
             "Effect": "Allow",
             "Principal": "*",
             "Action": "execute-api:Invoke",
             "Resource": "execute-api:/*/GET/*"
         }
     ]
  }
EOF
}

output "stns_api_endpoint" {
    value = "${module.stns-backend.endpoint_url}"
}

以下は、terraform.tfvars の例です。

stns_allowed_vpce_id = "vpce-xxxxx"

リソースポリシーの自由度を優先しているため、リソースポリシー全体をパラメータとして渡せるようにしています。そのため、VPCエンドポイントIDではなく、VPC ID を利用することも可能です。

設定ができたら、 terraform get もしくは terraform init で Github のソースをローカルにダウンロードし、 terraform plan, terraform apply します。

Terraform により、以下のリソースが生成されます。

  • DynamoDB テーブル 3 つ。ユーザー、グループ、認可トークン の各テーブル。
  • Lambda 関数 2 つ。STNS の API 実装(Sinatra)と Custom Authorizer。
  • API Gateway の Rest API および API Gateway の関連リソース(リソース定義, メソッド定義, デプロイメントや API Key, 使用量プランなど)。
  • IAM ロール 3 つ。 Lambda の実行ロール 2 つと、API Gateway から Custom Authorizer を実行するための Invocation ロール。

VPC エンドポイントは作成されません。必要に応じて作成してください。

apply コマンドを実行すると、以下のように Outputs に API Gateway のエンドポイントが出力されます。

Outputs:

stns_api_endpoint = https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v2

DynamoDB データ登録

残念ながら今のところ API は参照のみで、DynamoDB へのデータ登録はマネジメントコンソールや CLI を利用して実施する必要があります。

まず、アカウントのグループを作成します。デフォルトでは、 stns-groups というテーブルが生成されているので、そこに登録します。 STNS の link_groups もサポートしています。

aws dynamodb put-item \
    --table-name "stns-groups" \
    --item '{
        "name": {"S": "my-group"},
        "id": {"S": "2001"},
        "users": {"L": [ {"S": "oreno-user"} ]},
        "link_groups": {"L": [] }
    }'

続いてユーザーを登録します。なお、RDBMS と違い参照制約はないため、データの登録順序は不問です。STNS の link_users もサポートしています。

aws dynamodb put-item \
    --table-name "stns-users" \
    --item '{
        "name": {"S": "oreno-user"},
        "id": {"S": "2001"},
        "group_id": {"S": "2001"},
        "shell": {"S": "/bin/bash"},
        "directory": {"S": "/home/oreno-user"},
        "keys": {"L": [ { "S": "ssh-rsa AAAAA ........"} ]},
        "link_users": {"L": [] }
    }'

DynamoDB のユーザーテーブルとグループテーブルは基本的に STNS の API インターフェースにおけるレスポンスの JSON スキーマとほぼ同じです。
違いは、 link_userslink_groups フィールドがあればそれを解釈できるくらいです。

最後に、Lambda の認可を行うためのトークンを登録します。この例では、ここで登録したトークンを利用するサーバーで、上記で作成したグループ以外のログインを認めないことにします。

この認可トークンテーブルだけは STNS の仕様にない独自のものなので、詳細は READMEソースをご覧ください。

aws dynamodb put-item \
    --table-name "stns-authorizations" \
    --item '{
        "token": {"S": "oreno-my-stns-token-001"},
        "description": {"S": "my-group にだけ認証を許可するトークン"},
        "groups": {"L": [ {"S": "my-group"} ]},
        "users": {"L": []}
    }'
# groups や users フィールドがなければ、すべての登録ユーザーとグループにログインを許可します

クライアントセットアップ

最後にクライアントの設定です。構築した STNS API にアクセス可能な VPC 内に EC2 インスタンスを構築します。最初のセットアップは ec2-user などで行います。
VPC エンドポイントの利用には VPC の DNS サポートを有効にするなどいくつかの制約があるので AWS のドキュメントで確認してください。

クライアントの設定は、STNS ドキュメントの通りで問題ないですが、 stns-v2 をインストールする必要はありません。libnss-stns-v2 のみのインストールで大丈夫です。
なお、Amazon Linux 2 の場合はもともと独自の認証方法を利用するために SSH のコマンドが用意されているため、設定手順が他と異なります。Amazon Linux 2 の場合の設定方法については後述します。

設定のポイントは、 /etc/stns/client/stns.confapi_endpointauth_token を書き換えることです。

api_endpoint = "https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v2"
auth_token = "oreno-my-stns-token-001"

これで、上記クライアント設定を行った EC2 インスタンスに対して、ユーザーテーブルに登録したアカウント名と公開鍵に対する秘密鍵でのログインが可能になります。

ssh -i "/path/to/keysに登録した公開鍵に対する秘密鍵" \
  stns-usersに登録したユーザー名@サーバーホスト

Amazon Linux 2 で試しましたが、ログインにかかる時間は全く気にならない速度でした。
ちなみに、 CentOS 6 でも試したのですが、ルート証明書が古くて SSL のハンドシェイクができず、リトライを繰り返して SSL Verify を諦めてからログインするため、ログインに 3 秒くらいかかりましたw

Amazon Linux 2 のクライアント設定

そもそも、 STNS ドキュメント通りだと yum でのインストールができないので、rpm からインストールします。 https://repo.stns.jp/centos/x86_64/7 から rpm の URL をコピーしておきます。

sudo yum localinstall -y https://repo.stns.jp/centos/x86_64/7/libnss-stns-v2-2.2.0-1.x86_64.el7.rpm

/etc/nsswitch.conf は STNS ドキュメントの通りで、 stns を追加するだけです。

- passwd:     sss files
- shadow:     files sss
- group:      sss files
+ passwd:     sss files stns
+ shadow:     files sss stns
+ group:      sss files stns

STNS ドキュメントにある通り、 /etc/pam.d/sshd でログイン時にホームディレクトリが作成されるようにしておきます。

echo 'session    required     pam_mkhomedir.so skel=/etc/skel/ umask=0022' >> /etc/pam.d/sshd

最後に /etc/ssh/sshd_config の変更です。これがポイントですが、 Amazon Linux 2 ではもともと独自の AuthorizedKeysCommand が指定されています。 そのため、もともと指定されているコマンドをさらにラップしたコマンドを用意します。

#!/bin/bash
# まずは STNS での公開鍵取得を試みる
pubkeys=$(/usr/lib/stns/stns-key-wrapper $1)
# 失敗したらもともと指定されていた Amazon Linux 2 用のコマンドにフォールバック
if [ $? -ne 0 ]; then
  /usr/bin/timeout 5s /opt/aws/bin/curl_authorized_keys $1 $2
else
  echo "$pubkeys"
fi

上記ファイルを /usr/local/bin/ssh-chain-wrapper といったファイル名にして chmod 755 し、 /etc/ssh/sshd_config を以下のように書き換えました。

- AuthorizedKeysCommand /usr/bin/timeout 5s /opt/aws/bin/curl_authorized_keys %u %f
+ AuthorizedKeysCommand /usr/local/bin/ssh-chain-wrapper %u %f

AuthorizedKeysCommandUserec2-instance-connect のままで問題ありません。

/etc/ssh/sshd_config を書き換えたら sudo systemctl restart sshd.service で sshd を再起動すれば設定完了です。

なお、ラッパーを作成せずに STNS の公式ドキュメント通りに設定しても STNS 上のアカウントでログインできますし、 ec2-user でのログインもできました。 ただ、このラッパーが何を目的としているものなのかググっても出てこなかったため、念の為ラッパーファイルを作成したほうがいいでしょう。

2019/07/08 追記

2019/06/27 に発表された EC2 Instance Connect を利用している場合、もともとのコマンドは /opt/aws/bin/eic_run_authorized_keys になっていると思われます。
その場合は上記スクリプトのフォールバック先のコマンドを eic_run_authorized_keys に書き換えてあげれば動くと思います。

実装解説

Custom Authorizer

ほぼ素の Ruby で書いています。利用している gem は aws-record のみです。 aws-recordAws::DynamoDB::Client よりもちょっとだけ高レベルなAPIを提供する AWS 製の gem です。

STNS クライアントから auth_token で指定したトークンが Authorization ヘッダで渡されるので、それをもとに DynamoDB の認可トークンテーブルを検索し、トークンごとの認可を取得しています。存在しないトークンだった場合は戻り値のポリシーの EffectDeny にすることでクライアント側に 403 を返します。

取得した認可情報は戻り値の context フィールドに格納することで、API 本体の Lambda に渡されるイベントペイロードに含めることができます。

以下、認可を許可する場合のレスポンスの例です。このレスポンスは、クライアントではなく API Gateway の Lambda 統合プロキシに渡されます。

{
    principalId: auth_token,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
           Action: 'execute-api:Invoke'
           Effect: 'Allow',
           Resource: "arn:aws:execute-api:#{AWS_REGION}:#{AWS_ACCOUNT_ID}:#{API_ID}/*/GET/*"
        }
      ]
    },
    usageIdentifierKey: ENV['API_KEY'],
    context: {
        token: auth_token,
        users: authz_context.users || [],
        groups: authz_context.groups || [],
    }
  }

ここでのポイントは、認可不可だった場合は Resource のパスを /*/*/* で、許可だった場合は /*/GET/* で返すことです。 不可だった場合に /*/GET/* で返してしまうと、 POST や DELETE ができることになってしまうためです。(現状は GET しかありませんが)

STNS API (Sinatra)

API サーバーは Ruby の Sinatra で書いています。Lambda で Sinatra を実行するためのサンプルが Github の aws-samples にあったので、Lambda で受けて Sinatra にイベントを渡す処理はほぼ流用しています。

エンドポイントは STNS のインタフェース上、 /users/groups の 2 つのみで、それぞれ DynamoDB のテーブルを参照して、JSON を返しているだけです。
唯一、認可の処理だけは Custom Authorizer から渡された認可コンテキスト(認可トークンテーブルの内容)をもとに返すユーザーやグループをフィルタリングしています。

Custom Authorizer からのレスポンスに context フィールドを含めることで、API Gateway の Lambda 統合によるイベントペイロードの $.requestContext.authorizer という JSON パスに、渡した内容が格納されます。

なので、 lambda_handler で雑に ENV に設定して、Sinatra 側で受け取っています。

def lambda_handler(event:, context:)
  ENV['AUTHORIZATION_CONTEXT'] =
    (event.dig('requestContext', 'authorizer') || {}).to_json
  # ...
end

それ以外はごく普通の API アプリなので特筆することはないです。

Terraform + Lambda

Lambda を Terraform で扱うためにはいろんなハックがあると思いますが、こちらでは external データソース(外部コマンド結果のデータソース)を使って泥臭くアーカイブしてアップロードファイルを生成しています。そのため、この Terraform を実行するために zip や md5, find といった Linux 系のコマンドが必要になります。Windows で実行する場合はご注意ください。

そのままだと毎回 bundle install されるので、 *.rbGemfile などのソースファイルの md5 をローカルファイルに出力し、前回実行時と変更があった場合のみアーカイブを再作成するようにしています。正直ここが一番面倒でしたw

参考URL

エンジニア募集!!

QLife では共にチーム一丸となってより良いものづくりにこだわれる仲間を募集中です! 小さいサービスが多いので新しい AWS のサービスの利用にも非常に積極的に取り組んでいます! カジュアル面談も行っていますので、興味がある方は entry@qlife.co.jp に「カジュアル面談希望」とメールをください!

www.qlife.co.jp

エムスリーでもエンジニアを随時募集しています!共に医療 × テクノロジーの未来を切り拓いてくれる仲間を募集中です! AWS 以外にも GCP や Firebase などのクラウドも活用しています!興味がある方はカジュアル面談やTechtalkにおこしください!!

jobs.m3.com