GitLab Secrets Manager ADR 008: Rails データベーステーブルを使用しないシークレットマネージャーの再設計
Tanukey のデータベース設計における主要な問題のひとつは、2 つの異なるサービス間でデータの同期が必要となり、共通のトランザクションが使用できないことです。OpenBao を使用した外部ストレージ操作は不可能であるため、次善の設計として OpenBao を唯一の信頼できる情報源(single source of truth)として使用し、必要な拡張機能を特定することにしました。
私たちの設計目標は以下のとおりです:
- OpenBao を可能な限り厳格に使用し、データベース同期の問題を回避する。
- OpenBao への新規拡張を最小限に抑える。
- OpenBao のパフォーマンス特性を考慮する。
背景
さまざまな 議論の中で、現在の設計がスプリットブレインの問題を引き起こす可能性があることに気づいてきました。シークレットの作成や削除には、OpenBao への書き込みと PostgreSQL テーブルへの書き込み(Rails/Puma 経由)の両方が必要です。どちらかが失敗した場合、整合性回復ステップが必要になります。考えられる設計には、バックグラウンドジョブを使用してシークレットまたはデータベーステーブルエントリの再プロビジョニングをリトライする方法(これはシークレットが OpenBao の外の Runner のキューに広がる可能性があります)や、後続の整合性回復バックグラウンドジョブを使用する方法(OpenBao 内のすべてのシークレットをリストアップしてデータベースエントリが欠落しているものを特定する必要があり、コストがかかる可能性があります)が含まれます。
スプリットブレイン問題を解決する簡単な方法がないことから、OpenBao を使用したシングルソースオブトゥルースの影響を探ることにしました。
まとめ
シークレットの慎重なレイアウトにより、MVC では OpenBao のみをシングルソースオブトゥルースとして使用できるはずです。GA 後については、特定の操作でのパフォーマンス向上のために将来的な改善が必要なものもありますが、その多くはスタンバイ HA ノードで読み取り専用リクエストを提供することでの(再)水平スケーリングによって解決できます。
説明
以下の設計はプロパティベースの認証に依存しています。GitLab Rails はリクエストのコンテキスト(「プロパティ」)を理解します(ユーザーからの場合はロールや識別子、パイプラインからの場合は環境、ブランチ、および潜在的にはステージとジョブ名)。これらのプロパティに基づいて OpenBao ACL ポリシーを作成し、認証エンジンが明示的またはグループを通じて両者を結びつけます。
特に、権限の付与(ユーザーロールの場合は_カテゴリ的_、直接ユーザー権限の場合は_細粒度_)は認証されたエンティティのプロパティに対して行われます。付与されたロール、環境・ブランチなどのスコープ情報、またはエンティティのデータベース ID(通常はユーザーのみ。ジョブやパイプラインには実行をまたぐ永続的な識別子がないため)が対象です。これにより、JWT 経由の認証と認可は、ユーザーのプロパティ(そのプロジェクトで XYZ ロールを付与されており、ユーザー ID は 1234)またはパイプライン(プロジェクトの DEF 環境の ABC ブランチで実行中)を証明でき、GitLab Rails はこれを既存のデータベースで把握・管理します。OpenBao はその ACL ポリシーを通じてプロパティ→権限のマッピングを管理します。
各潜在的なプロパティのポリシーがトークンにプロビジョニングされますが、OpenBao はシステム内に存在しないポリシーを無視します。これにより Rails は権限が付与されているものに対してのみ OpenBao にポリシーを作成できます。Rails はポリシーのリストと内容をクエリして権限情報を表示し、それをデータベースを通じて具体的なロールやユーザーなどに紐付けることができます。
このように、関心の分離が実現されます。Rails はアイデンティティを管理し、OpenBao は ACL ポリシーとそれが持つ具体的な権限付与を管理します。このアプローチではサービス間のストレージ同期は不要です。
提案するシークレットレイアウト
既存のシークレットレイアウトを使用します。
この設計における唯一の注意点は、「オプション 2」(同じシークレットが異なるコンテキストで異なる名前として現れる)の強制が難しくなることです。そのため、オプション 1 のみを使用します。理論的には命名の解決策を使用できますが、ACL を更新する際に 2 つのパスが別々のスコープを持つことを容易に検証できないため、完全に別の名前を使用することを選択します。注目すべき制限セクションにこれを修正するアプローチがあります。
この制約を無視して名前を完全に自由な値とし、ユーザーが私たちが選んだ識別子でシークレットを参照させると、混乱や不十分な UX を招く可能性があります(また、本番/ステージングの差異を最小限に保ち、ブランチを変えるだけで同じ .gitlab.yml ファイルを両方に使えるようにするという根本的な問題も解決しません)。
パイプライン向け ACL
これらの ACL は既存設計の JWT 認証方式とともに使用されます。
注目すべき変更点として、グループを使用してパイプラインへの権限関連付けを行い(実行ごとのパイプライン JWT ロールと ACL ポリシーのプロビジョニングをしなくなるため)、ネームスペース内のプロジェクトごとに GitLab のキーにバインドされた単一の JWT ロールを使用できます。これにより、OpenBao への往復通信を必要とせずに直接 JWT を発行してパイプラインを開始できます。
ユーザー向け ACL
現在の設計に基づくと、ユーザーからのプロジェクト横断クエリを処理する必要はありません。ユーザーは特定のスコープの特定のシークレット管理ページにのみアクセスします。さらに、初期設計はロールベースのアクセスのみを必要とします。細粒度の権限アクセス(将来的)には逆引き問題を解決する必要がありますが、実現可能であり OpenBao へのより複雑な拡張が必要です。ただし、カスタムロール(細粒度スコープ付き)は名前付きであれば新しいレイアウトで完全に表現でき、GitLab Rails がクエリして JWT に付与できます。
以前と同様に、GitLab Rails は最初のうちはフロントエンドコードに実装する前にバックエンドでユーザー操作を実行します。ただし、GitLab Rails はユーザーアクセス認可テーブルを保存しませんが(リクエストを開始したユーザーと、プロジェクトで明示的またはグループを通じて暗黙的に付与されたロールは把握しています)、ポリシーユニオンが実装されるまでロールが競合する場合は明示的なサブトークンでプロパティベースのアプローチを継続できます。
OpenBao Proxy はグローバル AppRole トークンを使用するよう、明示的なトークンで送信されたリクエストを書き換えないことに注意してください。直接トークンの使用は長期的には理想的ではありませんが、有効期限付きのトークンを作成でき、使用されるリクエストよりもはるかに長く有効でなく、通常の認証方式よりもオーバーヘッドが少ないため、このケースでは有効なツールです。
操作の種類
この設計を効果的にするために、シークレットに対するあらゆる種類の操作を効率的に実行できることを確認する必要があります。以下に列挙します。
パイプラインがシークレットを取得する
パイプラインは OpenBao からシークレットを取得できる必要があります。シークレットを取得するには:
- GitLab Rails が JWT をプロビジョニングします。このトークンには、
groups_claimマッチングに使用されるクレームに、具体的な環境、ブランチ、プロジェクト情報が含まれます。- 現時点では GitLab Rails は OpenBao に対して直接グロブグループをクエリする必要がありますが、グループエイリアスがグロブをサポートするよう更新されれば、最終的な環境を直接プロビジョニングでき、OpenBao 内部でエイリアス→グロブのマッチングが行われます。
- GitLab Rails は JWT と関連パスを OpenBao とともに Runner に渡します。
- Runner は OpenBao に認証してシークレットを取得します。
ステップ 2 と 3 は複雑な操作に依存しません。ステップ 1 のみが現在、GitLab Rails→OpenBao 側で LIST 操作を必要とします。
観察: CI パイプラインはデフォルトで静的シークレットになる可能性がありますが、ほとんどの動的シークレットエンジンには Runner が使用する操作の種類を理解するための type: キーワードが必要です。
ユーザーがシークレットをリストする
ユーザーによるシークレットのリストは特定のスペース(プロジェクトなど)に限定されるため、(初期は)単一の LIST 操作が発生します。将来的には動的シークレットバックエンドがこれを複雑にするかもしれませんが、プロファイルを使用して後で対応できます。
表示の詳細度によっては、特定のシークレットの表示からの情報も適用され、追加の複雑さが生じる場合があります。
ユーザーが特定のシークレットを表示する
特定のシークレットを表示するには、ストレージ内のシークレットのパスを知っている必要があります。
特定のシークレットを表示するために、LIST-PAGE コール(size=1)を発行して、シークレットがリストに表示されることを確認できます。GitLab Rails はその後、プレフィックス付きパスに対して後続の LIST 操作を使用して、関連する ACL とメタデータ情報を取得できます。これらの ACL は、(上記に基づいて)関連するスコープを理解するためにシークレットをポリシーにマッチさせる READ が必要です。
将来的には、ポリシーの逆引きを使用することで無方向の LIST 操作とすべての READ 操作をスキップできます。これにより、このシークレットにアクセスできるものを(ポリシー名から)把握できます。
ユーザーが権限を変更する
権限を変更するために、GitLab Rails はユーザーに代わって行動し、関連するポリシーを取得してから、新しいスコープを持つよう変更するか既存のスコープを削除する必要があります。ポリシーは更新(追加のパスがポリシーに残っている場合)またはそうでなければ削除されます。対応するグループも作成または削除する必要があります。
グループはエンティティに対してまだ存在しますが(進行中のリクエストについて)、これはリアルタイムの影響を持ちます。これはパイプラインの実行ごとに一時的なポリシーを生成する現在の設計とは異なります。
ユーザーが値を設定する
値を設定するために、ユーザーは OpenBao への書き込み操作を実行します(直接、または GitLab Rails がユーザーに代わって行動することで)。ユーザーはすでにパスを知っているため、これは何も影響を与えません。
ユーザーのアクセスを監査する
OpenBao 内のすべてのシークレットへのユーザーのアクセスを監査することは、プロジェクト情報に基づいた関連 API エンドポイントへの 1 回以上の LIST リクエストという点では比較的簡単です。特に、ユーザーのロールに対するポリシーが存在するかどうか、およびユーザーが直接付与された権限があるかどうか(ユーザー ID を持つポリシーに基づく)を確認するための LIST 操作が必要です。
プロジェクトをまたぐ場合はこれが難しくなります。これらのリストはクロステナントで行う必要があり、別々のネームスペースになるため共通の LIST 操作を共有しません。GitLab Rails のユーザー削除に個別のバックグラウンドクリーンアップワーカーを追加することが望ましいですが、ユーザー ID が再利用されない限り、使用されていないポリシーを OpenBao に残しておいても問題ありません。権限に長期的な有効期限を設けることでこれに対処することもできます。
プロジェクトの削除
プロジェクトを削除するには、関連するシークレットマウントを削除し、その後 OpenBao でそのプロジェクト識別子のすべてのプロジェクト固有の ACL ポリシーとグループをリストして削除します。LIST 操作を使用してこのプロジェクト識別子のポリシーのリストを確認し、マッチするすべてのポリシーやグループを削除できます。
ユーザーの削除
これは概ね上記(ユーザーのアクセスを監査する)と同等ですが、マッチするポリシーに対して削除が発行されます。
タイムスタンプの計算
Rails が OpenBao の監査データを取り込んで解析することになるため、作成日時、最終変更日時、最終アクセス日時を管理できます。より効率的な検索のためにテーブルに保存することもできます。
注目すべき制限
一意性チェックが難しい
特定の K/V エンジン内での一意性はサポートできますが、将来的に階層的なシークレット(グループレベルなど)を導入する場合、特にユーザーが将来的に直接書き込みできるようになると、グループ→プロジェクト間でのグローバルな一意性を確保することが難しくなります。
そのため、シークレットに省略可能な scope: タグ(デフォルトは project)を追加して、このシークレットが階層のどこから読み込まれるかを明確にすることを提案します。シークレットはスコープをまたいでのみ明確化が必要になります。
しかし、タイプを示す単一の統合 K/V エントリを除いて、動的シークレットについても同じスコープで一意性の問題が生じます。その場合、(YAML ファイル内のシークレットの)type: フィールドを使用することで明確になります(またはある程度の混乱を招く可能性もあります)。単一のテーブルを使用することにはいくつかの利点があります。Runner が K/V エントリに格納されたデータに基づいてタイプを推測でき、「オプション 2」ベースの同名シークレット(異なるスコープ内)が異なるタイプに関連付けられる(テスト用の静的認証情報だが、ステージングと本番では動的な値)という利点があります。
これは将来の課題です。
説明とメタデータ
説明フィールドやその他のメタデータフィールド(suggested rotation など)の扱いはここでは解決されていません。
K/V シークレットの場合、これはメタデータ値として使用できますが、非 K/V 動的シークレットには機能しません。一つのオプションとして、すべての値に K/V エントリをプロビジョニングすること(上記で言及)があります。説明のメタデータフィールドが使用でき、すべてのタイプのシークレット間での一意性も潜在的に強制できます。
理論的には、継承された値(ローカルエンジンがそれらを作成することを「ブロック」するため)についても同様のことができ、一意性チェックの問題が修正されます。ただし、これは理想的ではないため、その場合は scope: 構文の使用が好ましいでしょう。
再利用されるシークレット名
具体的には、すべてのシークレット(静的か動的かに関わらず)に対して、次のエントリを作成するとします:
.../secrets/kv/metadata/explicit/<name>
このメタデータエントリは存在チェック(KVv2 メタデータエントリは .../secrets/kv/data の LIST 操作にも表示されるため)に使用でき、シークレットについての GitLab 固有のメタデータを保持できます。
メタデータフィールドには以下が含まれます:
type:シークレットのタイプを示す(MVC のシンプルなシークレットではkv)。description:このシークレットのテキスト説明。scopes:シークレットの既知のスコープを示す。以下のようなリストが考えられます:[ { "id": "<uuid>", "environments": [...], "branches": [...], "combined": [...], "stages": [...] }, { ... } ]これにより、単一のメタデータ名からスコープごとの識別子へのマッピングが可能になります。その後、実際の(静的)シークレット値は
.../secrets/kv/data/explicit/<name>/id/<uuid>のようなパスに配置でき、Runner は希望するマッチ条件に基づいてこの値を取得できます。同様のスキームを動的シークレットエンジン(例:explicit-<name>-id-<uuid>)向けに設計することもできます。creator:このシークレットの作成者の識別子。… など
これにより Runner に少し多くの作業が課されますが、すべての Runner が新しい GitLab バージョンに更新されることを前提とした(または GitLab Rails の更新より先にサポートが追加された)移行手順を設計できます。移行は、シークレットが追加のスコープを追加するために変更されたときに行われます。これにより、同じシークレットが異なるスコープで必要になるまでの OpenBao 内のエントリ数が削減されます。直感的なシークレット値の作成/管理が可能な UI を構築できます。シークレットへのある程度のアクセス権を持つすべてのユーザーには、メタデータフィールドへの読み取りアクセスが付与されます。
OpenBao KVv2 はチェックアンドセットセマンティクスをサポートしています。これにより、外部トランザクションなしで、K/V エントリを安全に作成・変更できます。
将来の拡張
以下の OpenBao への拡張により、パフォーマンスを改善し、既存設計の特定の欠点を解決できます。
まとめると、短期拡張の 2 つのみが必要です。ワイルドカードを MVC 後(または GA 後)まで遅らせることができれば、ACL リストプレフィックスも遅らせることができます。つまり、この設計は OpenBao への変更なしに今日でも機能するはずですが、ACL リストが実装されていない場合はパフォーマンスが低下します。
短期
以下の短期拡張は、MVC を通じて提示された設計のために必要であるため、GA までに完了することが必要です。GitLab Rails の作業を削減できるパフォーマンスへの影響がある可能性があります。
ACL リストプレフィックス
ACL ポリシーには / 文字を含めることができ、これはまだ実際の K/V パスコンポーネントセパレータとして解釈されます。つまり、理論的に LIST /sys/policies/acl はオプションの [/:prefix...] を取ることができ、これはポリシーストア上のオンディスクサブパスに対応し、反復的な LIST-PAGE を使用せずに結果を制限します。
特に、ポリシーにセグメントのプレフィックスを付ける(project_{id}/ や user/ や roles/ など)ことで、それぞれに適用可能なポリシーのみをリストできます(プロジェクトパイプライン、またはユーザーに直接付与された権限)。
OpenBao はすでにほぼこれをサポートしています。ListPolicies(...) にオプションの prefix が与えられた場合に SubView を作成するサポートが必要なだけです。
複雑さ: シンプル 影響: スケール時に必要なパフォーマンス改善 MVC: 厳密には必要ではない。パフォーマンス改善 GA: 必須
グループエイリアスのグロブマッチング
アイデンティティサブシステムのグループエイリアスは、特定のグループに適用される他の名前(1 つのマウントパス内)の代替名です。これらを明示的なグロブをサポートするよう拡張することで、グロブポリシーをグロブグループにマッピングし、将来的に OpenBao がそれらを適用できます。
これにより、GitLab Rails が JWT を発行する前に、まず適用するグループとポリシーの正確なセットをクエリする必要がなくなります。
このマッピングを適用するために refreshExternalGroupMembershipsByEntityID は groupAliases パラメータでグロブをサポートするよう更新する必要があります。特に、基盤となる MemDB はネイティブなグロブマッチングをサポートしていないため、これはかなり複雑です。新しいテーブルセットを作成し、実際の値に対して各候補を検証してマッチするものを確認する前に、グロブの候補を解決するための最長プレフィックスマッチを実行する必要があります。
別の設計として、Rails テーブルでこの情報を何らかの形で伝えることです。その場合、同期ズレのリスクは最小限になり、「オプション 2」の設計問題を解決するためにこのテーブルを使用できる可能性があります。
これを置き換えることは、MemDB がすべてのエンティティとグループをメモリ内に保持する必要があるため、有用かもしれませんが、ストレージバックエンドがトランザクションをサポートしていなかった頃は有用でした。トランザクショナルストレージバックエンドに切り替えることで、これを LRU キャッシュとより多くのディスク上のエントリで置き換え、望む場合は異なるパフォーマンス特性を持てます。ただし、MemDB ユーザーにはグロブベースのグループのサポートを引き続き許可したい場合もあります。
複雑さ: 複雑 影響: Rails→OpenBao クエリ数を削減するためのスケール時の必要なパフォーマンス改善 MVC: 環境やブランチでグロブを許可しない場合は厳密には不要 GA: 顧客が複雑な環境名を持つ場合はおそらく必要
長期
以下の長期拡張は、GitLab Secrets Manager に追加機能を追加するために必要ですが、GA までの初期設計では必要ありません。
ポリシーユニオン
アップストリームの OpenBao コメンターは、ACL の奇妙な結合動作をすでに指摘しており、ポリシーに対する厳格なUNIONでこれに対処したいと考えています。これは短期的には有用ではありません(個別のポリシーを持つすべてのパイプラインは READ アクセスのみのパスを持つため、すべて同じ機能を持ちます)が、CREATE や様々な種類の動的シークレットをサポートする場合は将来的に必要になります。
複雑さ: 複雑。多くの RFC 設計作業と慎重な検討が必要。 影響: サブトークン要件の問題を解決する。
ポリシーの逆引き
特に、現在の ACL サブシステムは逆引きを一切処理できません。パスが与えられた場合、どのポリシーが適用されるか?
パス→許可ポリシーの効率的な検索を可能にするために、関連するポリシーに対して PathManager を構築する拡張が必要です。
これは sys/policies/lookup/acl[/:prefix] のようなパスにあり、(結局のところアクセスを確認したい OpenBao パスであるため)既存の PathManager の期待値に準拠した path=... パラメータを取ります。
現在の実装はシリアライズ可能ではないため、インデックスをディスクに永続化(更新時に構築)してメモリに保存しないようにしたいです(おそらく多くのポリシーがあり、相対的にルックアップは少ない)。LRU キャッシュを使用して、よくアクセスされるクエリをメモリに保存し、取得の局所性を向上させることができます。
複雑さ: パフォーマンスを出すには中程度の複雑さ 影響: Rails データベースが必要だったユースケースを解決する。
操作としての存在確認
OpenBao は LIST(および現在は LIST-PAGE)をサポートしており、これらを存在確認の代替として使用できます。ただし、ユーザーがアクセスできないシークレットの名前が漏洩するという注目すべき制限があります。将来的にはスコープ付きリストをサポートしたいですが、その間に、ユーザーが特定のパスに対して特定のクエリを行って存在するかどうかを確認できるような直接的な存在確認も検討する必要があります。理論的には十分な量があれば LIST 操作と同等ですが、(クライアントが)階層的なシークレットが存在するかどうかを効率的に確認し、適切に条件分岐できます。
複雑さ: Core の知識が必要だが実現可能。 影響: 低。回避策がある。
サイズ制限
これは直接ユーザーアクセスに必要であり、議論の他の場所でも言及されている可能性がありますが、言及する価値があります。直接ユーザーアクセスにはリクエストサイズクォータが必要です。
複雑さ: 中程度。他の種類のクォータのための多くの配管がすでに存在する。 影響: 大きい。セキュリティ態勢を大幅に改善する。
プロファイル
多くの理由から、プロファイル(指定した入力から複数のリクエストをサーバーサイドで実行し、レスポンスにフォーマットする)は、複数のクライアント→サーバーリクエストへの魅力的な解決策です。
これらは、このドラフトブログ記事で詳しく説明されています。
JWT ダイレクトプロファイル
ユーザー ACL について、ACL ポリシーが作成されたときにすべてのロールとユーザー ID にグループをプロビジョニングする必要があります。これは問題ありませんが、OpenBao への複数の操作が必要です。より理想的なのは、JWT 認証方式に policies_claim オプションを作成することです。これにより JWT 発行者が認証後 OpenBao トークンが持つべき ACL ポリシーを直接指定できます。これにより OpenBao のグループを大幅に削減し、ワイルドカード環境/ブランチスコープのセットだけに減らすことができます。
外部トランザクション
トランザクショナルストレージにより、OpenBao は内部的なトランザクション保証を持てます。ただし、etcd は外部(呼び出し元駆動)のトランザクションセマンティクスを公開しています。同様の API を OpenBao に追加することで(ただし、ワンショットではなくインタラクティブ)、複数の OpenBao リクエストをトランザクション的に実行し、変更が発生した場合に競合させることができます。
以前このアプローチの実装に取りかかりましたが、Core における慎重な変更が必要であり、他の変更と競合する可能性が高かったため、一時的にアプローチを断念しました。これにより GitLab Rails からのコールチェーンがより安全になります。
