本記事は AWS LambdaとServerless Advent Calendar 2021 の4日目です。
たまたま空きがあることに気付いたため、せっかくでしたらと急遽参加させていただきます! よろしくお願いいたします 🙇
こんにちは。Togetter を運営しているトゥギャッター株式会社でエンジニアをしている @MintoAoyama です。
Togetter はツイートを始めとした様々な情報を組み合わせてコンテンツを作り出すキュレーションサービスです。
2009年に誕生してから今年で13年目に突入し、現在も月間PV約1億、月間UU約1500万という規模感で成長を続けています。
そんなトゥギャッター社もコロナ禍に入り、全従業員がフルリモートワーク体制に移行しました。
もっとも、以前からリモートワークは実施されていました。オフィスは東京ですが地方からフルリモートで出勤されているメンバーも多く、エンジニアは基本週に一度リモートワークを実施する試みが行われていました。
コロナ禍でもトゥギャッター社のリモートワーク、うまくいってます|Togetter(トゥギャッター )|note(2020年5月の記事)
コロナ禍以前から実施してきた弊社の"リモートワーク遍歴"を振り返る|Togetter(トゥギャッター )|note(2020年11月の記事)
しかし、全ての従業員・全ての職種で完全なフルリモートワークに移行し始めたところ(2020年2月 - )、想定が及んでいない課題が出てきました。社内サイト・ドキュメントへのアクセス権限です。
意外にも色々あった "社内限定リソース"
弊社ではグループウェアとして Google Workspace (旧 G Suite) を活用しています。また、ソースコードやチケット管理ツールとして GitHub 、コミュニケーションツールとして Slack も活用しています。
これらについては以前から2FA(2段階認証)設定も義務化しているくらいで、フルリモートワークにあたって特別障壁になるものはありませんでした。
ただし、それ以外の手段で社内ネットワーク向けに限定公開しているリソースも存在していました。
- 社内向けドキュメント(Webサイト)
- 静的サイトジェネレーターで生成される
- 開発者向けとして別サイトもある
- "CloudFront"
- すごい便利な社内ツール(Webアプリケーション)
- "ALB"
- 検証環境・ステージング環境(Webアプリケーション)
- "ALB"
- 開発用データベースのためのデータ(MySQL の Docker DataVolume / sqldump)
- "CloudFront"
- 各種環境へのssh踏み台サーバ
- "EC2"
(ざっと思い出した限りこのあたりです。他にもあるかもしれません。)
上記に関してはすべてIPアドレス制限を行っていました。
まず「オフィスネットワークのIPアドレスを許可」し、「社外からのアクセスを必要とする際は都度申請してもらい、私の方で手動で許可」していました。
ただ、「申請の際には自身でIPアドレスを調べて貰う」必要がありましたし、「プロバイダなど通信環境によってはIPアドレスが頻繁に変わる」可能性もあり、お互いの負担になっていました。また、「退社などを想定した定期的な棚卸し」も課題になります。
…それらが全従業員のフルリモートワーク移行により、ほぼキャパオーバーになったというわけです 🤪
普通に考えてこれらを個別申請・手動で管理すること自体が非合理的ですよね…。
何らかの形で仕組みを見直す必要がありました。
様々な従業員の環境からスムーズにアクセスしてもらうために
皆さんならどうするでしょうか…。
ドキュメントであれば Google Drive や GitHub Wiki に移行するという手もありそうです。
しかし、それだけでは賄えないリソースもあります。Webサイト(Webアプリ)でないと満たせない要件もありました。IPアドレス認証が必要なリソースもあります。
Digest認証も一瞬検討しましたし、GoogleアカウントやIAMなどを利用した認証手段がないかなども考えました。しかしいずれにせよ課題があり(全ての従業員・関係者が対象のアカウントを持っているわけではなかったり)、あまり明解ではありませんでした。
※ SSO / OIDC / VPN など、よりベターな認証手段があることは把握していますが、今回は従来のIPアドレス認証を "拡張" しようという "お手軽" な手段を取っています 🙇
と、ここで、そもそも 本番プロダクト(Togetter)のための管理画面 があることを思い出しました。記事情報の閲覧やフラグ管理、本番バッチの実行状況の確認など、本番データの参照・操作に利用されている仕組みです。当然Webアプリ内で認証しており、全従業員が日々ログインしています。
つまり、「認証後であれば正常にアクセスできるパスがある」ということです。
Lambda サブスクリプションフィルター で実現できるIPアドレスによるアクセス制御
Webサーバ(nginx)のアクセスログは json 形式で CloudWatch Logs に送っています。
特定のパス(認証を必要とするパス)に status=200
でアクセスしているIPアドレスが分かれば、それをホワイトリストとして扱えそうです。
CloudWatch Logs には「サブスクリプションフィルター」という機能があり、るログデータを Kinesis / Lambda / Kinesis Data Firehose に送信できます。
CloudWatch Logs サブスクリプションフィルターの使用 - Amazon CloudWatch Logs
Lambda であれば、対象のIPアドレスをWAFに登録する処理を実装できそうです。
なお、サブスクリプションフィルタにはフィルターパターンを設定して、特定の条件に当てはまるログのみを送信対象として絞り込めます。
Lambda を実行する場合、その 実行回数 = 利用料金 にも影響するため、極力条件を付けることをオススメします。
フィルターとパターンの構文 - Amazon CloudWatch Logs
以下の例は、GETリクエストで status=200
で 管理画面配下(例: /adm/*
) にアクセスできたリクエスト、みたいな感じです。
{ $.request_method = "GET" && $.status = "200" && ( $.request_uri = "/adm/*" }
WAFを定期更新できれば CloudFront や ALB に関連付けしてアクセス制御できますし、セキュリティグループを更新すれば ssh など他のポートも制御の対象になります。
今回、個人的にとっつきやすいRubyで実装してみました ✋
以下、受け取ったログデータを取り出す簡単な実装例です。
require 'base64'
require 'json'
require 'zlib'
def lambda_handler(event:, context:)
# base64エンコードされているデータをデコードする
decoded_data = Base64.decode64(event['awslogs']['data'])
# バイナリ圧縮されているログデータを展開する
json_data = Zlib::Inflate.new(Zlib::MAX_WBITS + 16).inflate(decoded_data)
data = JSON.parse(json_data)
# ログデータを1件ずつループ処理
# ここではとりあえずIPアドレスとリクエストパスを出力している
for log in data['logEvents'] do
access_log = JSON.parse(log['message'])
remote_addr = access_log['remote_addr']
p "remote_addr = #{remote_addr}"
request_uri = access_log['request_uri']
p "request_uri = #{request_uri}"
end
end
CloudWatch Logs から送信されてくるログデータは base64エンコード + gzip圧縮 されているためそれらに対する処理が最初に必要になりますが、後は配列上になっているので1件ずつ処理します。
WAFのIPアドレスリスト(IP addresses
)の更新には AWS SDK 、Ruby であれば Aws::WAF::Client
クラス を使います。なお、 WAFv2
であればこれで良いですが、 旧バージョンの WAF(AWS WAF Classic)を対象としたい場合は Aws::WAFRegional::Client
クラスを使う必要があります。地味な罠になっていますのでご注意ください…。
Class: Aws::WAF::Client — AWS SDK for Ruby V3
Class: Aws::WAFRegional::Client — AWS SDK for Ruby V3
以下、複数の対象IPアドレスを追加する簡単な実装例です。
require 'aws-sdk-waf'
def update_ip_sets(ip_set_id, ipaddresses)
waf_client = Aws::WAF::Client.new(
region: region_name
)
# 対象IPアドレスのリストから更新リクエスト内容を生成
# (今回は "IPv4" の "追加" としています)
updates = []
ipaddresses.each do |ipaddress|
updates.push({
action: 'INSERT',
ip_set_descriptor: {
type: 'IPV4',
value: "#{ipaddress}/32"
}
})
end
# 更新処理に必要なトークンを取得する
waf_responce = waf_client.get_change_token({})
change_token = waf_responce.change_token
# 更新
response = waf_client.update_ip_set({
change_token: change_token,
ip_set_id: ip_set_id,
updates: updates
})
end
なお、実装にあたっては考慮すべきポイントが色々出てきます。
WAFのIPアドレスリスト(IP addresses
)にはサイズ制限がありますし、先ほど書いたように登録されたIPアドレスはその性質上永続的に保存するものではなく、定期的な棚卸しが必要になります。
そこで、「それぞれのIPアドレスの登録日時を記録し、一定期間が経過したものを削除する」などの処理を入れたりします。
記録用のデータストアとしては通常 DynamoDB などを使うかと思いますが、今回は規模感なども踏まえ、お手軽に Parameter Store (AWS Systems Manager) を利用してみます。
AWS Systems Manager Parameter Store - AWS Systems Manager
Parameter Store は 階層型のパラメータに対して値を格納できるKVSのようなサービスです。「スタンダード」の条件を満たせば無料(大体の場合はこれで必要十分なはずです)。
バージョニングにも対応している上、Lambdaからの読み書きが可能。1秒間に最大1,000件のアクセスにも耐えられるスケーラビリティを備えているため、簡易的なデータストアとして重宝する仕組みでもあります。用途次第ですが、使わないと損?かもしれません。
Class: Aws::SSM::Client — AWS SDK for Ruby V3
以下は Parameter Store の読み書きをする簡単な実装例です。
require 'aws-sdk-ssm'
def ssm_sample()
ssm_client = Aws::SSM::Client.new
# 追加(更新)
res = ssm_client.put_parameter({
name: 'test',
value: 'aaa',
type: 'String',
overwrite: true
})
# 取得
res = ssm_client.get_parameter({
name: 'test',
with_decryption: false
})
p res.parameter.value
end
詳細は割愛しますが、例えば json で「"IPアドレス" と "登録日時" の組み合わせ配列」を定義・保存するよう実装してみたりします。
スタンダードの場合は保存できるデータサイズの上限が 4,096 byte
なので、超えないように注意する必要もあります。
ということで、ひとまず合理的な形で制御できるようになりました。
今回は少し特殊なケースかも知れませんが、サブスクリプションフィルターはログ1行1行に対してほぼリアルタイムで処理できるため、様々な用途が考えられると思います。
ご存じなかった方は是非ご活用ください 💪💪💪