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

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

Regoは、Open Policy Agentで利用される宣言型のポリシー言語である。アプリケーションから認可ルールを分離し、「誰が、何に対して、どの操作を実行できるか」をポリシーとして記述できる。

先に結論を書くと、私がテックリードを担当したe-learning開発プロジェクトへのRego導入は最大級の失敗だった。

ただし、Regoそのものが悪かったわけではない。当初の構想に対して、Regoを選択肢に入れたことにも一定の合理性はあった。

失敗だったのは、Regoを採用する前提となっていた構成を十分に検証しないまま本採用し、その前提が崩れた後も技術選定を見直さなかったことである。

1. なぜRegoを選んだか

このシステムには複数のロールが存在し、ロールによって操作できるリソースや範囲が異なっていた。

認可では、単純な「管理者なら許可する」という判定だけでなく、次のような条件を扱う必要があった。

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

これらをGoの各usecaseへ直接書くと、認可ロジックがアプリケーション全体へ分散すると考えた。

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

さらに、バックエンドだけでなく、フロントエンドやCMSでも同じRegoポリシーを利用する前提で設計していた。

RegoをWebAssembly(Wasm)へコンパイルし、同じpolicy.wasmを各システムへ配布する構想である。

バックエンドではWasmを実行して最終的な認可を強制する。フロントエンドやCMSでは、同じWasmを使ってボタンやコンテンツの可視性を制御する。

ブラウザ上の判定は利用者が改変できるため、セキュリティ上の最終判断は必ずバックエンドで行う。ただし、同じポリシーを使えば、バックエンドの認可結果と画面上の表示制御を一致させやすい。

複数のシステムが同じポリシーを評価するのであれば、認可ルールをアプリケーションコードから分離して管理する意味がある。

そのため、Regoを選択肢に入れたこと自体は不自然ではなかったと思う。

2. 想定していた責務分離

当初は、一つのRegoポリシーからWasmを生成し、それを複数の実行環境で利用する構成を想定していた。

Rego
  ↓ Wasmへコンパイル
policy.wasm
  ├─ Goバックエンド:認可を強制
  ├─ フロントエンド:可視性を制御
  └─ CMS:可視性や操作可否を制御

Goバックエンド側では、Goが認可に必要な事実を集め、Regoが最終的な許可・拒否を判断する。

たとえば、GoからRegoへ次の情報を渡す。

  • 操作するユーザーのIDとロール
  • 実行しようとしている操作
  • 操作対象のリソース
  • 所有者や所属組織など、判定に必要な属性

Regoはこれらを受け取り、最終的な判定を返す。

Go
  └─ 認可に必要なデータを取得する
       └─ Regoへ事実を渡す
            └─ Regoが許可・拒否を決定する

この境界を維持できれば、Goは認可ルールの詳細を知らなくてよい。

フロントエンドやCMSでも同じポリシーを評価できるため、同じ認可条件をTypeScriptなどで再実装する必要もない。

各システムが最終的に利用する権限情報は、次のような形式になる。

{
  "canRead": true,
  "canWrite": false,
  "canDelete": false
}

認可ポリシーの原本をRegoに集約し、Go、フロントエンド、CMSへ同じルールを重複して実装しないことが前提だった。

3. 実際には何が分散したか

この構想で最初に問題になったのが、GoバックエンドでWasm版のRegoポリシーを実行する方法だった。

Go側では、次の公式SDKを利用する予定だった。

github.com/open-policy-agent/golang-opa-wasm

しかし、このSDKを対象のARM64実行環境で採用できないことが分かった。

Wasm自体は特定のCPUアーキテクチャに依存しない。しかし、GoからWasmを実行するためのランタイムやSDKには、ネイティブライブラリ、CGO、対応OS、対応アーキテクチャなどの制約がある。

その結果、Goバックエンドとフロントエンドで同じpolicy.wasmを実行する構成を実現できなかった。

フロントエンドだけでWasmを使い、GoではRegoソースを直接評価する構成も理論上は可能だった。しかし、それでは実行方式が分かれ、同じポリシーから同じ結果が得られることを二つの経路で検証しなければならない。

複数システムで一つのWasm成果物を共有するという当初の利点が失われたため、フロントエンド側でのWasm実行も断念した。

Goバックエンドでは、.regoファイルを読み込み、OPAのGo APIを使って直接コンパイル・評価する方式へ変更した。

.regoファイル
  ↓
Goバックエンド内で読込・コンパイル
  ↓
Goバックエンドだけで評価

この変更によって、複数システムで同じポリシーを評価するというRego採用の主要な目的は失われた。

本来であれば、この時点で技術選定を見直すべきだった。

選択肢としては、次のようなものがあった。

  • OPAを独立した認可サービスとして稼働させる
  • バックエンドで認可を判定し、フロントエンドには判定結果だけを返す
  • Regoをやめ、認可をGoの型付きコードとして実装する
  • Goとフロントエンドで異なる評価方式を採用し、互換性をテストで保証する

しかし、当初の構想が成立しなくなった後も、Regoの採用自体は見直さなかった。

さらに、実際の認可処理もRegoだけでは完結しなかった。

認可には、大きく分けて二つの段階がある。

一つは、その種類のリソースを操作できるかという「リソース単位の認可」である。

もう一つは、具体的にどのデータを操作できるかという「レコード単位の認可」である。

たとえば、「グループを更新できる」というのがリソース単位の認可であり、「自分が作成したグループだけを更新できる」というのがレコード単位の認可に当たる。

実装後は、前者をRego、後者をGoが担当する形になった。

Rego
  └─ このロールはグループを更新できる

Go
  ├─ 更新できるのは自分が作成したグループだけ
  ├─ 対象データの所有者を取得する
  └─ 操作するユーザーと所有者を比較する

さらに、一覧取得では、所属組織や所有者による絞り込みをデータベースの検索条件へ組み込む必要がある。

最終的な認可処理は、次のように分散した。

  1. Regoがロール単位のCRUD可否を返す
  2. Goが追加の認可条件を決める
  3. Repositoryが対象データを取得する
  4. Goが所有者や所属情報を比較する
  5. 一覧取得ではusecaseやRepositoryが検索範囲を制限する
  6. 最終結果をフロントエンド向けの権限情報へ変換する

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

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

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

Regoへ新しいリソースを追加する場合も、Regoファイルだけでは完結しなかった。

  • Regoへロール別のCRUD可否を追加する
  • GoへRego用のSubjectやRequestを追加する
  • GoへRegoの評価処理を追加する
  • Goへレコード単位の条件を追加する
  • usecaseやRepositoryへ検索・比較処理を追加する
  • RegoとGoの双方へテストを追加する

認可を一元管理するためにRegoを導入したはずが、認可変更時に確認する場所はかえって増えた。

4. 技術要件とチーム体制の見誤り

今回の失敗には、技術要件とチーム体制の二つの問題があった。

技術要件と撤退判断を見誤った

当初想定していた共通Wasm構成を実現できるのであれば、Regoを採用する理由はあった。

問題は、その構成が対象の実行環境で本当に成立するかを、本採用前に十分検証しなかったことである。

特に次の点は、早い段階で実証すべきだった。

  • RegoからWasmを生成できるか
  • GoバックエンドでWasmを評価できるか
  • 対象のARM64環境で動作するか
  • フロントエンドでも同じWasmを評価できるか
  • Goとフロントエンドで同じ入力から同じ結果を得られるか
  • ポリシーを独立した成果物として配布・更新できるか

これらを最初にPoCで確認していれば、構想が成立しないことを、認可実装が広がる前に判断できた。

また、共通Wasm構成を断念した後に残った認可要件が、Regoに適しているかも再評価すべきだった。

このシステムで複雑だったのは、ロールごとのCRUD表よりも、業務データとの関係だった。

「自分が所有するデータか」「同じ組織に所属しているか」「契約対象か」といった条件を判断するには、データベースから対象データを取得しなければならない。

一覧検索では、取得したデータをRegoで一件ずつ評価するより、認可条件をデータベースのクエリへ組み込む方が適切である。

つまり、複雑さの中心がGoのドメインモデルやRepositoryに強く結びついていた。

Regoを導入しても、この複雑さは消えない。

Go側にはデータ取得、Rego用の入力変換、検索条件、判定後の処理が残る。Regoを追加したことで、元から存在した複雑さに、GoとRegoの境界管理まで加わった。

最初にRegoを選んだことよりも、Regoを選んだ前提が崩れた後も採用を継続したことの方が大きな失敗だった。

チーム体制を見誤った

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

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

  • GoとRegoの両方を読める
  • 業務ロジックと認可ポリシーの境界を判断できる
  • Regoの入力と出力を設計できる
  • Go側へのポリシー重複をレビューで防げる
  • 認可処理全体を横断して確認できる
  • 実行環境や配布方式の制約を理解できる

私は途中から認可設計へ継続的に関与できなくなり、設計の主体も変わった。
バックエンドチームには2人のテックリードがおり、それぞれが別のスコープに対して責務を負っていたため、私の責務がもう一人のテックリードに移る、というようなことが発生するチーム体制だったのだ。

その後、Regoだけでは判断できない条件がGo側へ追加され、認可の責務が複数のレイヤーへ広がっていった。

ただし、私が設計を続けていれば成功したと断定することもできない。

データベース検索やレコード単位のスコープ制御など、元からRegoだけでは扱いにくい要件が存在した。私が関与し続けていても、別の形でGoとの境界に苦しんだ可能性はある。

問題は、特定の設計者が関与し続けなければ維持できない技術を、チームの基盤として採用したことである。

技術的に実装できることと、そのチームが継続的に運用できることは別である。

5. 今ならどう判断するか

今なら、共通Wasm構成が本当に必要であるかを最初に確認する。

複数システムが同じポリシーを独立して評価する明確な必要性があり、その構成を対象環境で実証できるなら、Regoを検討する。

一方、フロントエンドやCMSがバックエンドの判定結果を使って可視性を制御するだけなら、Regoは必要ない。

その場合は、認可処理を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には、自分が所有するデータ、所属組織のデータ、すべてのデータといった範囲を定義する。

単体操作では、取得したEntityに対して最終的な許可・拒否を判断する。

一覧取得では、ScopeをRepositoryの検索条件へ変換する。

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

この方式なら、Goの型検査、IDEの参照検索、リファクタリング支援を利用できる。認可処理のテストもGoの中で完結する。

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

Regoを採用してよい条件

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

  • 複数のサービスが同じポリシーを評価する
  • 各実行環境で同じポリシーを評価できることをPoCで確認している
  • 対象のOS・CPUアーキテクチャ・実行環境に対応している
  • ポリシーをアプリケーションと独立して配布する
  • ポリシー変更とアプリケーションのデプロイを分離する
  • Regoが最終的な認可判断を返す
  • 入力・出力スキーマが定義されている
  • 複数人がRegoを保守できる
  • ポリシーのレビュー責任者が決まっている
  • Go側へのポリシー重複を検知できる
  • 前提が崩れた場合の撤退基準が決まっている

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

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

ただし、今回の構想は単なる将来の可能性ではなく、Go、フロントエンド、CMSで同じWasmポリシーを利用する具体的な設計だった。

それでも、その設計が対象環境で成立することを確認する前に本採用した点は変わらない。

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

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

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

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

Regoを採用した前提が崩れた時点で、技術選定をやり直さなかったことである。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です