Rust + Clean Architecture で実装する REST API サーバーの安全な認可の仕組みを考える
はじめに
Web アプリケーションサーバーを実装するうえで、認可処理については頻繁に議論されているかつ実装の難しさを指摘されることが多い分野です。認可制御を適切に行わないと、権限のないユーザーがリソースにアクセスできる脆弱性が生じる可能性があり、ソフトウェア開発をする上で重要な実装です。
本稿では、Rust と Clean Architecture(DDD) を用いた Web アプリケーションサーバーにおいて、安全な認可の仕組みをどのように実装できるかを考察します。これは、実際に Rust と Clean Architecture を用いて REST API サーバーを実装するうえで直面した課題を基に検討したものです。
※文章構成の都合で一部時系列を入れ替えていますが、ご容赦ください。
前提
本稿では、Web アプリケーションフレームワークとして Axum を使用したソースコードを掲示していますが、結論とする方法ではフレームワークに依存しない実装になっています。この Web アプリケーションは、Google Form のようなフォーム機能を提供します。(正確には他にも機能がありますが、本稿ではフォーム機能を例に取り扱います。)
また、Rust や Clean Architecture に対しての基本的な知識があるということを前提とします。
「安全な認可」の仕組みとは
安全な認可の仕組みを考えるうえで、「安全な認可の仕組み」とはなんであるかを定義する必要があります。
IPA の安全なウェブサイトの作り方によると
認証機能に加えて認可制御の処理を実装し、ログイン中の利用者が他人になりすましてアクセスできないようにする。
ウェブサイトにアクセス制御機能を実装して、利用者本人にのみデータの閲覧や変更等の操作を許可する際、複数の利用者の存在を想定する場合には、どの利用者にどの操作を許可するかを制御する、認可(Authorization)制御の実装が必要となる場合があります。
とされています。
この定義を踏まえ、本稿では「安全な認可」とは、「あるリソースに対する操作を適切に制御すること」と定義します。
適切に制御するには、
- 仮に実装を忘れても問題が発生しない実装(フェイルセーフな実装)
- 実装を忘れない実装(フールプルーフな実装)
という2つの要素が重要です。
1人で開発しているかつ小規模なソフトウェア場合は、実装漏れしないことを意識しながらコードを記述できるかもしれません。しかし、開発者が複数人いる、もしくは場合は認可制御の実装漏れが発生しやすく、レビュアーへの負担が増加します。したがって、認可制御は最低でもフェイルセーフな実装である必要があり、フールプルーフな実装をすることが望ましいと考えます。
検討した方法
方法1: 認可情報をミドルウェアとして URL のパスレベルで管理する
最初に試みた方法で、認可制御をミドルウェア上で URL のパスレベルでアクセス制御を行います。ミドルウェアはプレゼンテーション層の実装として扱います。
しかし、以下の問題があります。
- パスレベルの制御ではミドルウェア上で細かい認可制御を行うことが困難である
- 細かい認可制御が必要になった場合、認可制御を行う処理がユースケースやドメインサービスに分散する(処理が分散した場合、フェイルセーフではなくなる可能性がある)
これらの問題を解決するため、より安全で拡張性のある方法を模索しました。
方法2: 認可処理が必要なことを型レベルで表現する (その1)
先述の方法では、パスレベルで認可制御を行うので細かい認可制御をミドルウェア上で実装できないという問題があります。
例えば、フォーム機能を持つ Web サービスを考えたときに「フォームの情報を取得するエンドポイント /forms/{form_id}
に一般ユーザーはアクセスできるが、アクセスできるのは公開状態になっているフォームのみである」という仕様があったとします。この仕様をパスレベルで管理することを考えると、/forms/{form_id}
というエンドポイントを一般ユーザーがアクセス可能なエンドポイントであるとしたうえで、form_id
に紐づくフォームが公開状態であるかをユースケースまたはドメインサービスの処理として制御することになります。このレベルの認可制御が求められる場合、ミドルウェア上の一般ユーザーがアクセス可能なエンドポイントとして /forms/{form_id}
を追加し忘れた場合は一般ユーザーはアクセスすることができないことから安全です。しかし、「非公開状態のフォームを一般ユーザーが参照できない」という仕様は、ユースケースやドメインサービスに実装する必要があります。これでは実装を忘れてしまう可能性があり、先ほど定義した「安全な認可の仕組み」とは言えません。この問題を解決するため、Rust の強みである強力な型システムを使用して認可を制御できないかを考えます。
まず、ドメインオブジェクトに対する操作内容は Create
、Read
、Update
、Delete
の4種類であるとします。
これを Actions
として実装します。
※ Rust では Enum として定義されたものをトレイト境界として扱うことができず、後述部でトレイト境界を使用する必要があるため、各 Action を構造体として定義しています。
次に、「actor
がこれらの操作をすることが可能であるか」を定義するためのトレイトを用意します。[1]
次に、AuthorizationGuard
と名付けた構造体を定義し、AuthorizationGuardDefinitions
トレイトがドメインオブジェクトに実装されていれば AuthorizationGuard
を使用可能だとします。
AuthorizationGuard
は action
を Create
からは Read
と Update
に、Read
と Update
は相互に変換可能であり、Delete
からはいかなる action
へも変換できないという制約を設けたうえで、AuthorizationGuard
で保護された構造体自体は自由に生成可能とします。
そして、ドメインオブジェクト Form
には以下のように AuthorizationGuardDefinitions
トレイトの実装を行います。
このトレイトを実装することで AuthorizationGuard<Form, Action>
型を作成できるようになります。
次に、ドメイン層の repository トレイトの定義で Form
オブジェクトを返す場合は AuthorizationGuard<Form, Action>
型の値を返すようにします。
これによってリポジトリから取得するデータは AuthorizationGuard
で保護されていることから、処理を忘れるとコンパイルに失敗し安全です。
方法3: 認可処理が必要なことを型レベルで表現する (その2)
先述の方法でも「安全な認可の仕組み」を実装できてはいますが、その操作が可能であるかどうかを判別するために、別集約を取り扱う必要がある場合に対応できないという問題点があります。
どのような場合に別集約をを取り扱う必要がある必要があるかを、具体例を用いて説明します。「フォームに対して回答が送信可能か」を判定することを考えます。回答が送信可能であるためには、「フォーム設定を取得し、フォームが公開状態であると設定されている必要がある」とします。回答を扱うドメインオブジェクトはフォームの設定を保持してない(別集約である)ので、既存のAuthorizationGuard
の実装では判別したい集約のデータと actor
の2つによって判別することから、別集約を跨いで判別できません。そこで、既存の実装を改善した AuthorizationGuardWithContext
を定義し、実装時に必要な Context
を注入する形式に実装し直すこととします。
AuthorizationGuardWithContext
の定義は以下のようになります。
また、AuthorizationGuardWithContext
に対する実装は以下のようにします。
さらに、AuthorizationGuardDefinitions
はAuthorizationGuardWithContextDenifinitions
として定義し、Context
を受け取ります。
次に、既存の AuthorizationGuard
は AuthorizationGuardWithContext
の Context
が &()
であるという実装にします。(AuthorizationGuardWithContext
が主、AuthorizationGuard
が従の関係であると定義します。)
さらに、AuthorizationGuardDefinitions
が実装されていれば AuthorizationGuardWithContextDefinitions
が自動で実装されるようにします。
そうすることで認可制御を型レベルに表現できるようになりました。
次に、実際に AuthorizationGuardWithContext
で保護されたデータをどのように取り扱っているかを示します。例として、回答の送信をする処理を取り扱います。
回答を扱うドメインモデル AnswerEntry
に対して、AuthorizationGuardWithContextDefinitions
を実装します。
回答の集約を取り扱うリポジトリの実装は以下のとおりです。
次に、ユースケースの処理を示します。
このように処理を行うことで、安全な認可処理を行うことができます。[3]
Context
の生成のために、AuthorizationGuardWithContext
に保護されたデータが必要なことがある
上記の例は Context
を単純に生成できるため問題になりませんでしたが、Context
を生成するために AuthorizationGuardWithContext
に保護された値を必要とする場合や、認可制御を行いたい集約自体が、別の集約の特定の操作が可能である場合に限るというような表現をしたい場合があります。この問題に対処するため以下のような実装をしました。
このような実装をすることで、AuthorizationGuardWithContext
に保護されていたとしても、CRUD 権限を確認することや、Context
を生成することが可能になりました。
コードの解説は行いませんが、以下の例はすべての回答データを取得するユースケースで、Context
を生成するために AuthorizationGuardWithContext
で保護されたデータを必要としたものです。
まとめ
Rust で Clean Architecture を採用しているプロジェクトで安全な認可の仕組みを考えました。
AuthorizationGuardWithContext
型を定義し保護したいデータは repository 層でデータを取り扱う場合に AuthorizationGuardWithContext
または AuthorizationGuard
によってデータを保護することで安全な認可処理を行うことができるという結論になりました。
-
AuthorizationGuardDefinitions
や、後述するAuthorizationGuardWithContextDefinitions
の定義で不要な型パラメータT
が指定されていますが、修正時のコードを提示すると正しくないコードになってしまうことから、型パラメータT
については読み飛ばしていただけると助かります! ↩︎ -
repository を受け取ったとしても、すべてのデータに対して同じ認可処理を行えることを保証できるのであれば、
Context
の値を一度だけ取得し、Vec
の要素のなかの一つデータについて認可処理を行うということが可能かもしれません。 ↩︎ -
AuthorizaztionGuardWithContext
型を導入したとしても、結局 repository トレイトの関数でAuthorizationGuard
やAuthorizationGuardWithContext
を返し忘れることがあるので安全ではないのではという指摘があると思います。もちろん repository トレイト内でそのようなことが発生する可能性もありますが、ユースケースやドメインサービスが変更・追加される頻度と repository トレイトの定義が変更される頻度を比較したときに、圧倒的に repository トレイトの実装が変更される頻度のほうが少ないことから、新規機能の追加や変更に強い実装であり、安全だと考えています。 ↩︎
Discussion