GoバックエンドにRegoを導入して失敗した話

Goで開発しているバックエンドへ、認可ポリシーを記述するためにRegoを導入した。

先に結論を書くと、このプロジェクトにRegoを本採用したことは、私が行った技術選定の中でも最大級の失敗だった。

ただし、Regoそのものが悪いわけではない。

失敗だったのは、認可要件がRegoに適しているか、そしてチームがRegoを使った設計を維持できるかを十分に確認しないまま、アーキテクチャの中心へ組み込んだことである。

Regoを導入した理由

このシステムには複数のロールが存在し、ロールごとに操作可能なリソースと範囲が異なっていた。

認可条件には、次のようなものがあった。

  • 対象リソースを作成・閲覧・更新・削除できるか
  • 自分が所有するデータだけを操作できるか
  • 同じ校舎に所属するデータだけを閲覧できるか
  • 同じグループに所属するか
  • 対象ユーザーがどのロールか
  • 契約に対応する認可キーを持っているか

これらをGoの各ユースケースへ直接書くと、認可ロジックが分散すると考えた。

そこで、業務ロジックをGo、認可ポリシーをRegoへ分離しようとした。

将来的にはバックエンドだけでなく、フロントエンドやCMSでも共通のポリシーを利用し、画面や操作の可視性を制御する構想もあった。

構想だけを見れば、Regoを検討したこと自体は不自然ではなかったと思う。

実際には分離できなかった

実装後の認可処理は、おおむね次のようになった。

  1. RegoがロールごとのCRUD可否を返す
  2. Goがロール・リソース・操作からownschoolなどの条件を決める
  3. Goが対象データを取得する
  4. Goが所有者IDや校舎IDを比較する
  5. 一覧取得ではRepositoryやUsecaseが検索条件を追加する

Regoが返していたのは、最終的な認可結果ではなかった。

たとえば、Rego上では、あるロールがグループを作成・閲覧・更新・削除できると定義されている。一方で、実際には「自分が作成したグループのみ」「所属校舎のグループのみ」といった制限がGo側に存在する。

Regoだけを読んでも、実際にどのデータへアクセスできるか分からない。

Goだけを読んでも、そもそもその操作が許可されているか分からない。

認可ポリシーを分離したのではなく、一つの認可判断をRegoとGoへ分断してしまった。

複雑さの種類を見誤った

認可が複雑であれば、Regoが適しているとは限らない。

今回の認可で複雑だったのは、ロールごとのCRUD表よりも、業務データとの関係だった。

「自分のデータか」「同じ校舎か」「契約対象か」といった条件を判断するには、MongoDBから対象データを取得しなければならない。

一覧検索では、取得した全データをRegoで一件ずつ判定するより、校舎IDなどをMongoDBの検索条件へ組み込む方が適切である。

つまり、認可の中心部分がGoのドメインモデルやRepositoryと密接に結びついていた。

このような認可を無理にRegoへ分離しようとすると、Go側にはデータ取得、入力変換、検索条件、後処理が残る。結果として認可ロジックは消えず、Regoとの境界だけが増える。

複数システムで使う構想も実現できなかった

Regoを採用した理由の一つには、フロントエンドやCMSでも同じ認可ポリシーを利用する構想があった。

しかし、複数システムで共有したいものが何なのかを、十分に分けて考えられていなかった。

バックエンドが次のような結果を返し、フロントエンドがボタンの表示制御に使うだけなら、Regoは必要ない。

{
  "read": true,
  "write": false,
  "delete": false
}

Goで判定した結果を返せばよい。

Regoが有効になるのは、複数のサービスが同じポリシーを独立して評価する場合である。

そのためには、少なくとも次の設計が必要になる。

  • 共通の入力スキーマ
  • 共通の判定結果
  • ポリシーのバージョン管理
  • ポリシーの配布方法
  • 各システムでの評価方法
  • 互換性とロールバック
  • ポリシーの変更責任者

今回のシステムでは、この基盤を作る前にRegoを本採用してしまった。

しかも、最終的なownschoolの条件はGo側に存在した。そのため、RegoだけをフロントエンドやCMSへ配布しても、実際の認可仕様を再現できない状態になった。

技術だけでなく、チームにも適合していなかった

もう一つ見落としていたのが、チームがRegoを維持できるかという問題だった。

Regoを導入するなら、チームには次の能力が必要になる。

  • GoとRegoの両方を読める
  • 業務ロジックとポリシーの境界を判断できる
  • Regoの入力と出力を設計できる
  • ポリシーとGoの二重定義をレビューで防げる
  • 認可処理全体を横断して確認できる

当初の構想を理解している人が一人しかいない状態では、チームとしてその技術を採用できているとは言えない。

実際、私は途中から認可設計へ継続的に関与できなくなった。設計の主体が変わった後、Regoで完結しない条件がGo側へ追加され、認可の責務が複数のレイヤーへ広がっていった。

ただし、「私が設計を続けていれば成功した」と断定するつもりはない。

一覧検索や校舎スコープなど、元からRegoだけでは扱いにくい要件が存在した。私が関与していたとしても、別の形でGoとの境界に苦しんだ可能性はある。

問題は、特定の人が外れただけで設計思想を維持できなくなる技術を、チームの基盤として採用したことである。

今ならどうするか

今なら、認可処理をGoの型付きコードとして実装する。

たとえば、ロールと対象リソースから権限を返すAuthorizerを用意する。

type Permission struct {
    CanCreate bool
    CanRead   bool
    CanWrite  bool
    CanDelete bool
    Scope     Scope
}

type Authorizer interface {
    GroupPermission(
        actor Actor,
        target GroupTarget,
    ) Permission
}

ScopeにはOwnSchoolGroupAllなどを定義する。

一覧取得では、ScopeをRepositoryの検索条件へ変換する。単体操作では、取得したEntityに対して最終的な許可・拒否を判定する。

フロントエンドやCMSには、Goが返したPermissionを渡して可視性を制御する。

これなら、

  • Goの型検査が使える
  • IDEで参照関係を追える
  • リファクタリングしやすい
  • 認可処理をGoのテストで完結できる
  • Rego用の入力変換が不要になる
  • 実際の検索条件と認可条件を近くに置ける

という利点がある。

将来、本当に複数サービスが同じポリシーを独立して評価する必要が生じた時点で、Regoや外部認可基盤を改めて検討すればよい。

Regoを採用してよい条件

今回の経験から、少なくとも次の条件が揃わなければRegoを本採用しない。

  • 複数のサービスが同じポリシーを評価する
  • ポリシーをアプリケーションと独立して配布する
  • ポリシー変更とアプリケーションのデプロイを分離する
  • Regoが最終的な認可判断を返す
  • 入力・出力スキーマが定義されている
  • 複数人がRegoを保守できる
  • ポリシーのレビュー責任者が決まっている
  • Go側へのポリシー重複を検知できる

「将来使うかもしれない」だけでは採用しない。

将来の構想は、現在の複雑性を正当化しない。

結論

Regoを導入すれば、業務ロジックと認可ポリシーが自然に分離されるわけではない。

分離可能な境界、共通の入出力、配布方法、運用責任が先に存在し、その境界をチームが継続的に維持できる場合に、Regoは有効になる。

今回のプロジェクトでは、技術要件とチーム要件の両方を見誤った。

認可の複雑さは業務データと検索処理に密接に結びついており、Regoへ綺麗に分離できる種類ではなかった。さらに、その難しい境界を維持できるチーム構成や設計体制もなかった。

Regoが高機能であることと、そのプロジェクトへ導入すべきことは別である。

私の最大の失敗はRegoを知ったことでも、検討したことでもない。

Regoが成立する条件を確認しないまま、本採用したことである。