🛡️

Rust + Clean Architecture で実装する REST API サーバーの安全な認可の仕組みを考える

2025/02/17に公開

はじめに

Web アプリケーションサーバーを実装するうえで、認可処理については頻繁に議論されているかつ実装の難しさを指摘されることが多い分野です。認可制御を適切に行わないと、権限のないユーザーがリソースにアクセスできる脆弱性が生じる可能性があり、ソフトウェア開発をする上で重要な実装です。
本稿では、Rust と Clean Architecture(DDD) を用いた Web アプリケーションサーバーにおいて、安全な認可の仕組みをどのように実装できるかを考察します。これは、実際に Rust と Clean Architecture を用いて REST API サーバーを実装するうえで直面した課題を基に検討したものです。
※文章構成の都合で一部時系列を入れ替えていますが、ご容赦ください。

前提

本稿では、Web アプリケーションフレームワークとして Axum を使用したソースコードを掲示していますが、結論とする方法ではフレームワークに依存しない実装になっています。この Web アプリケーションは、Google Form のようなフォーム機能を提供します。(正確には他にも機能がありますが、本稿ではフォーム機能を例に取り扱います。)
また、Rust や Clean Architecture に対しての基本的な知識があるということを前提とします。

https://github.com/GiganticMinecraft/seichi-portal-backend

「安全な認可」の仕組みとは

安全な認可の仕組みを考えるうえで、「安全な認可の仕組み」とはなんであるかを定義する必要があります。
IPA の安全なウェブサイトの作り方によると

認証機能に加えて認可制御の処理を実装し、ログイン中の利用者が他人になりすましてアクセスできないようにする。
ウェブサイトにアクセス制御機能を実装して、利用者本人にのみデータの閲覧や変更等の操作を許可する際、複数の利用者の存在を想定する場合には、どの利用者にどの操作を許可するかを制御する、認可(Authorization)制御の実装が必要となる場合があります。

とされています。
この定義を踏まえ、本稿では「安全な認可」とは、「あるリソースに対する操作を適切に制御すること」と定義します。
適切に制御するには、

  • 仮に実装を忘れても問題が発生しない実装(フェイルセーフな実装)
  • 実装を忘れない実装(フールプルーフな実装)

という2つの要素が重要です。
1人で開発しているかつ小規模なソフトウェア場合は、実装漏れしないことを意識しながらコードを記述できるかもしれません。しかし、開発者が複数人いる、もしくは場合は認可制御の実装漏れが発生しやすく、レビュアーへの負担が増加します。したがって、認可制御は最低でもフェイルセーフな実装である必要があり、フールプルーフな実装をすることが望ましいと考えます。

検討した方法

方法1: 認可情報をミドルウェアとして URL のパスレベルで管理する

最初に試みた方法で、認可制御をミドルウェア上で URL のパスレベルでアクセス制御を行います。ミドルウェアはプレゼンテーション層の実装として扱います。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/cc2196a7facd86b4e0c253c32de9d0557f3cb20e/server/presentation/src/auth.rs#L25-L107
この実装では、パスを追加し忘れた場合は常にアクセスを拒否し続けるため、仮に実装を忘れたとしても安全であり、フェイルセーフな実装です。
しかし、以下の問題があります。

  • パスレベルの制御ではミドルウェア上で細かい認可制御を行うことが困難である
  • 細かい認可制御が必要になった場合、認可制御を行う処理がユースケースやドメインサービスに分散する(処理が分散した場合、フェイルセーフではなくなる可能性がある)

これらの問題を解決するため、より安全で拡張性のある方法を模索しました。

方法2: 認可処理が必要なことを型レベルで表現する (その1)

先述の方法では、パスレベルで認可制御を行うので細かい認可制御をミドルウェア上で実装できないという問題があります。
例えば、フォーム機能を持つ Web サービスを考えたときに「フォームの情報を取得するエンドポイント /forms/{form_id} に一般ユーザーはアクセスできるが、アクセスできるのは公開状態になっているフォームのみである」という仕様があったとします。この仕様をパスレベルで管理することを考えると、/forms/{form_id} というエンドポイントを一般ユーザーがアクセス可能なエンドポイントであるとしたうえで、form_id に紐づくフォームが公開状態であるかをユースケースまたはドメインサービスの処理として制御することになります。このレベルの認可制御が求められる場合、ミドルウェア上の一般ユーザーがアクセス可能なエンドポイントとして /forms/{form_id} を追加し忘れた場合は一般ユーザーはアクセスすることができないことから安全です。しかし、「非公開状態のフォームを一般ユーザーが参照できない」という仕様は、ユースケースやドメインサービスに実装する必要があります。これでは実装を忘れてしまう可能性があり、先ほど定義した「安全な認可の仕組み」とは言えません。この問題を解決するため、Rust の強みである強力な型システムを使用して認可を制御できないかを考えます。

まず、ドメインオブジェクトに対する操作内容は CreateReadUpdateDelete の4種類であるとします。
これを Actions として実装します。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/7e7ddef9955732c02c810f4ff9889526553b2aad/server/domain/src/types/authorization_guard.rs#L5-L28

※ Rust では Enum として定義されたものをトレイト境界として扱うことができず、後述部でトレイト境界を使用する必要があるため、各 Action を構造体として定義しています。

次に、「actor がこれらの操作をすることが可能であるか」を定義するためのトレイトを用意します。[1]
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/7e7ddef9955732c02c810f4ff9889526553b2aad/server/domain/src/types/authorization_guard.rs#L235-L240

次に、AuthorizationGuard と名付けた構造体を定義し、AuthorizationGuardDefinitions トレイトがドメインオブジェクトに実装されていれば AuthorizationGuard を使用可能だとします。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/7e7ddef9955732c02c810f4ff9889526553b2aad/server/domain/src/types/authorization_guard.rs#L31-L35

AuthorizationGuardactionCreate からは ReadUpdate に、ReadUpdate は相互に変換可能であり、Delete からはいかなる action へも変換できないという制約を設けたうえで、AuthorizationGuard で保護された構造体自体は自由に生成可能とします。

https://github.com/GiganticMinecraft/seichi-portal-backend/blob/7e7ddef9955732c02c810f4ff9889526553b2aad/server/domain/src/types/authorization_guard.rs#L37-L196

そして、ドメインオブジェクト Form には以下のように AuthorizationGuardDefinitions トレイトの実装を行います。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/7e7ddef9955732c02c810f4ff9889526553b2aad/server/domain/src/form/models.rs#L196-L382

このトレイトを実装することで AuthorizationGuard<Form, Action> 型を作成できるようになります。
次に、ドメイン層の repository トレイトの定義で Form オブジェクトを返す場合は AuthorizationGuard<Form, Action> 型の値を返すようにします。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/repository/form/form_repository.rs#L14-L38

これによってリポジトリから取得するデータは AuthorizationGuard で保護されていることから、処理を忘れるとコンパイルに失敗し安全です。

方法3: 認可処理が必要なことを型レベルで表現する (その2)

先述の方法でも「安全な認可の仕組み」を実装できてはいますが、その操作が可能であるかどうかを判別するために、別集約を取り扱う必要がある場合に対応できないという問題点があります。
どのような場合に別集約をを取り扱う必要がある必要があるかを、具体例を用いて説明します。「フォームに対して回答が送信可能か」を判定することを考えます。回答が送信可能であるためには、「フォーム設定を取得し、フォームが公開状態であると設定されている必要がある」とします。回答を扱うドメインオブジェクトはフォームの設定を保持してない(別集約である)ので、既存のAuthorizationGuard の実装では判別したい集約のデータと actor の2つによって判別することから、別集約を跨いで判別できません。そこで、既存の実装を改善した AuthorizationGuardWithContext を定義し、実装時に必要な Context を注入する形式に実装し直すこととします。
AuthorizationGuardWithContext の定義は以下のようになります。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard_with_context.rs#L34-L42

また、AuthorizationGuardWithContext に対する実装は以下のようにします。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard_with_context.rs#L58-L278

さらに、AuthorizationGuardDefinitionsAuthorizationGuardWithContextDenifinitions として定義し、Context を受け取ります。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard_with_context.rs#L310-L315

次に、既存の AuthorizationGuardAuthorizationGuardWithContextContext&() であるという実装にします。(AuthorizationGuardWithContext が主、AuthorizationGuard が従の関係であると定義します。)
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard.rs#L15-L18

さらに、AuthorizationGuardDefinitions が実装されていれば AuthorizationGuardWithContextDefinitions が自動で実装されるようにします。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard.rs#L192-L211

そうすることで認可制御を型レベルに表現できるようになりました。

次に、実際に AuthorizationGuardWithContext で保護されたデータをどのように取り扱っているかを示します。例として、回答の送信をする処理を取り扱います。
回答を扱うドメインモデル AnswerEntry に対して、AuthorizationGuardWithContextDefinitions を実装します。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/form/answer/service.rs#L17-L48

回答の集約を取り扱うリポジトリの実装は以下のとおりです。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/repository/form/answer_repository.rs#L19-L72

次に、ユースケースの処理を示します。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/usecase/src/forms/answer.rs#L50-L83

このように処理を行うことで、安全な認可処理を行うことができます。[3]

Context の生成のために、AuthorizationGuardWithContext に保護されたデータが必要なことがある

上記の例は Context を単純に生成できるため問題になりませんでしたが、Context を生成するために AuthorizationGuardWithContext に保護された値を必要とする場合や、認可制御を行いたい集約自体が、別の集約の特定の操作が可能である場合に限るというような表現をしたい場合があります。この問題に対処するため以下のような実装をしました。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/domain/src/types/authorization_guard_with_context.rs#L280-L308

このような実装をすることで、AuthorizationGuardWithContext に保護されていたとしても、CRUD 権限を確認することや、Context を生成することが可能になりました。

コードの解説は行いませんが、以下の例はすべての回答データを取得するユースケースで、Context を生成するために AuthorizationGuardWithContext で保護されたデータを必要としたものです。
https://github.com/GiganticMinecraft/seichi-portal-backend/blob/62357ed0c3495e43214cd6d30fd36ba524ba1f84/server/usecase/src/forms/answer.rs#L85-L166

まとめ

Rust で Clean Architecture を採用しているプロジェクトで安全な認可の仕組みを考えました。
AuthorizationGuardWithContext 型を定義し保護したいデータは repository 層でデータを取り扱う場合に AuthorizationGuardWithContext または AuthorizationGuard によってデータを保護することで安全な認可処理を行うことができるという結論になりました。

脚注
  1. AuthorizationGuardDefinitions や、後述するAuthorizationGuardWithContextDefinitions の定義で不要な型パラメータ T が指定されていますが、修正時のコードを提示すると正しくないコードになってしまうことから、型パラメータ T については読み飛ばしていただけると助かります! ↩︎

  2. repository を受け取ったとしても、すべてのデータに対して同じ認可処理を行えることを保証できるのであれば、Context の値を一度だけ取得し、Vec の要素のなかの一つデータについて認可処理を行うということが可能かもしれません。 ↩︎

  3. AuthorizaztionGuardWithContext 型を導入したとしても、結局 repository トレイトの関数で AuthorizationGuardAuthorizationGuardWithContext を返し忘れることがあるので安全ではないのではという指摘があると思います。もちろん repository トレイト内でそのようなことが発生する可能性もありますが、ユースケースやドメインサービスが変更・追加される頻度と repository トレイトの定義が変更される頻度を比較したときに、圧倒的に repository トレイトの実装が変更される頻度のほうが少ないことから、新規機能の追加や変更に強い実装であり、安全だと考えています。 ↩︎

Discussion