リリース2年後、セキュリティ視点でSaaSのバックエンドのインフラを振り返る

### はじめに

今日は、僕が担当しているサービスがリリースして2年ぐらい経ったので、バックエンドのインフラの変遷について振り返ります。

詳しくは書きませんが、サービスリリースから2年間、ユーザー数も増えましたし大規模法人にも使っていただけるようにもなりました。ユーザーさんが増えるにともなって、システム側に必要な要件も増えてきたため、それにどのように対応したのか？インフラ構成がどのように変えたのか？などを振り返ってみました。

会社がAWSベースでシステムを構築しているため、全てAWSのインフラの話になるのですが、参考になれば幸いです。


### リリース時の構成

自分のチームは新規プロダクトとしてチームが誕生しました。B向けのSaaSなのでユーザー数も最初はそこまで急に増えないこともあり、ある程度のリクエストを捌くことができて、少しのレイテンシは許容できるという状況だったので、AWSのLambdaを使うことにしました。

リリース時のインフラ構成は以下のような状況でした。


```plantuml

() ブラウザ

package AWS環境 {
   () Lambda
   () APIGateway
   () CloudFront
   () AuroraMySQL
   () LambdaAuthorizer
   () CognitoUserPool
   () CognitoUserGroup
}

ブラウザ <-- CloudFront
ブラウザ <-- CognitoUserPool
CognitoUserPool <-- CognitoUserGroup
CloudFront <-- APIGateway
APIGateway <-- Lambda
APIGateway <-- LambdaAuthorizer
Lambda <-- AuroraMySQL

note top of CloudFront : カスタムドメイン設定\nレスポンスのキャッシュ\n地域制限
note top of LambdaAuthorizer : Cognito User GroupでRBAC
```

他チームも利用していたので、認証にはCognitoを使っています。
認可処理は、API Gatewayの[Lambda authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html)を使っています。AuthorizerはCognitoが発行するJWTトークンを使って誰のリクエストがを知り、URLごとにリソースを返して良いかを判断します。Cognitoのユーザーグループの機能を使って、ユーザーのロールを表現しAuthorizerでRBAC(Role Based Access Control)を実現していました。
Lambdaはステートレスなので、永続化したい情報はAuroraMySQLで管理しています。図には書いていないですがLambda-Aurora間にはコネクション数が増える問題を避けるために[RDS Proxy](https://aws.amazon.com/jp/rds/proxy/)を挟んでいます。
キャッシュの管理などを想定して、API Gatewayの前段にCloudFront(CDN)を置いていて、そこでカスタムドメインを設定したり、[地域制限](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html)で海外からのアクセスを制限したりしています。

今思うと、CloudFrontはAPIGatewayの中にも入っているのに冗長では？など、AWSの知識不足による非効率な構成もありました。これを作り始めた当時はエンジニア経験5年目ぐらいで、AWSの知識も浅かったこともあり、インフラを作った後にはなるのですが[AWSのソリューションアーキテクト-アソシエイト](https://aws.amazon.com/jp/certification/certified-solutions-architect-associate/)の認定を取得しました。

ただ、当初としてはこの構成はうまくいき、レイテンシもサービスを使う上ではそこまで問題にならないような状況でした。

想定外だったポイントとしては、当初CDNでのキャッシュを使うかと思っていたのですが同じリクエストがほとんど来ないことがわかり、あまりメリットは享受できませんでした。

### Cognito UserPoolの移設に伴う構成変更

ある時、法人からの要望で、CognitoのUserPoolの要件レベルが上がり、UserPoolの作り直しが必要になりました。（UserPoolはユーザーのIDのデータベースのようなもので、ID/Passwordなどを管理したり、パスワードの再設定などの仕組みも提供してくれます。）

UserPoolが切り替わるとなり、ユーザーグループを使ったRBACの仕組みの運用が難しくなってしまいました。今まで使っていたユーザーのIDも変更になりますし、ユーザーのロール情報も新しいUserPoolには入っていません。
UserPoolは他のチームで管理していたため、ロール情報の移設も他チームにお願いすることになるとややこしいので、ロール情報をAurora管理に移設しました。

移設後の構成は以下

```plantuml

() ブラウザ

package AWS環境 {
   () Lambda
   () APIGateway
   () CloudFront
   () AuroraMySQL
   () LambdaAuthorizer
   () CognitoUserPool
}

ブラウザ <-- CloudFront
ブラウザ <-- CognitoUserPool
CloudFront <-- APIGateway
APIGateway <-- Lambda
APIGateway <-- LambdaAuthorizer
Lambda <-- AuroraMySQL
LambdaAuthorizer <-- AuroraMySQL : ロール情報

note top of CloudFront : カスタムドメイン設定\n地域制限
note top of LambdaAuthorizer : Auroraのロール情報\nでRBAC
```

Auroraにロール情報を入れる構成を作る際、ロール情報の移設は思ったよりスムーズでした。

懸念は、Authorizerは全てのリクエストの前に発火されるので、Authorizerのレイテンシが全体のレイテンシに影響してしまうことでしたが、LambdaAuthorizerからAuroraへの疎通もそこまでオーバーヘッドが大きくなかったのと、Authorizerの認可処理の結果のキャッシュも行えたので、大きな影響なく移設することができました。

良い副作用としては、サービスの利用状況を知りたい時にAuroraのクエリだけでロールごとの集計ができるようになったので、サービスの運用が楽になりました。


### IPアドレス制限機能追加に伴う構成変更

リリースから1年後、大規模法人から利用いただけることになり、さらに要件が増えました。特定のIPアドレスからのみアクセスできるようにしてほしいというものでした。

全ての顧客のデータを同じAuroraに格納していたので、特定の法人のリクエストの場合のみIPアドレス制限を実現するためには、認証されたユーザーの法人情報とIPアドレスの両方を使った認可処理が必要になりました。

ここで、問題になったのはAPIGatewayの前段にCloudFrontを立てていたことで、初期設定だとAPIGatewayはクライアントのIPアドレスを知ることができません。カスタムドメインを設定していたことから、CloudFrontを安全に外すことは難しかったため、WAFを使ってIPアドレス制限を行うことにしました。


```plantuml

() ブラウザ

package AWS環境 {
   () Lambda
   () APIGateway
   () CloudFront
   () AuroraMySQL
   () LambdaAuthorizer
   () CognitoUserPool
   () WAF
}

ブラウザ <-- CloudFront
ブラウザ <-- CognitoUserPool
CloudFront <-- APIGateway
CloudFront <--> WAF : リクエストヘッダの法人IDとIPアドレスで\nIPアドレス制限と地域制限
APIGateway <-- Lambda
APIGateway <-- LambdaAuthorizer
Lambda <-- AuroraMySQL
LambdaAuthorizer <-- AuroraMySQL : ロール情報

note top of CloudFront : カスタムドメイン設定\nレスポンスのキャッシュ
note top of LambdaAuthorizer : Auroraのロール情報\nでRBAC\nヘッダの法人IDの検証
```

他のやり方としては、CloudFrontからAPIGatewayにipアドレスを流すなども考えられたのですが、実装工数や既存システムへの影響の観点でWAFが一番良いだろうということで、このような構成にしました。
リクエストヘッダに法人IDを入れるようにしたのですが、偽装される可能性もあるのでAuthorizerでヘッダの法人IDの検証を入れることで、WAFを通過したリクエストのヘッダ情報が認証されたユーザーのものであることを再確認しています。


工数が許すなら、下記のような構成が良かったかなとも思います。


```plantuml

() ブラウザ

package AWS環境 {
   () Lambda
   () APIGateway
   () AuroraMySQL
   () LambdaAuthorizer
   () Cognito
}

ブラウザ <-- APIGateway
ブラウザ <-- Cognito
APIGateway <-- Lambda
APIGateway <-- LambdaAuthorizer
Lambda <-- AuroraMySQL
LambdaAuthorizer <-- AuroraMySQL : ロール情報\n法人情報

note top of APIGateway : カスタムドメイン設定
note top of LambdaAuthorizer : Auroraのロール情報でRBAC\nAuroraの法人情報でIPアドレス制限
```

こちらは、IPアドレスをAPIGatewayがAuthorizerに流せるので、Auroraから法人情報・APIGatewayからIPアドレスを取得し、AuthorizerがIPアドレス制限をする構成になります。
さっきの構成と比べると、必要なコンポーネント数も減りますし、シンプルに実現できそうです。

### まとめ

B向けのSaaSは、法人の規模によってセキュリティ要件が異なっていて、リリースしてから数年経っただけでもいくつか発生するということがわかりました。大規模法人ほど要求が厳しくなるので、早期に大規模法人を攻める場合は早い段階で対応が必要になりそうだなと思います。
また、認証・認可周りの機能の修正はインフラ構成の変更が必要になることも多いという気づきがありました。大規模法人向けの機能はビジネス面でも優先度が高くなり、じっくり工数を割いて実現するのかどうかの判断も悩ましかったです。
しかし、AWSが後付けで既存の仕組みを変えなくても良いようなWAFなどの仕組みを提供してくれていたので今回は軽微な修正で済んだのかなとも思うと、クラウドサービスのありがたみを感じました。

今回は主にセキュリティ要件周りの振り返りをしましたが、また別の機会にレイテンシや可用性視点での振り返りもしてみたいと思います。