Watari はオープンソースのインシデントレスポンス ケース管理で、看板は本物のテナント分離です。 customer カラムと注意深い WHERE 句ではなく、PostgreSQL Row-Level Security による強制。そのポリシーは 2 番目のマイグレーション以来スキーマにありました。そして一度も効いていませんでした。

何も失敗していませんでした。アプリは動き、テストは通り、ポリシーは構文的に正しく、マイグレーション 0002 を読んだレビュアーなら誰でも「テナント分離は強制されている」と結論づけたはずです。されていません でした。

その隔たり — 統制が存在することと、統制が作動していることの差 — がこのプロジェクトについて私が 書ける最も有用なことなので、この記事はほぼその話です。

4同時に着地させる必要があった変更
18実際に FORCE されたテーブル
0新規に書いたポリシー

このプロジェクトの目的

Watari は SOC アナリスト、CSIRT、CERT のためのもの — インシデントについて報告する人ではなく、扱う人のため です。TheHive や DFIR-IRIS と同じ場所に立っており、どちらより若く、実戦経験も少ないです。今日すでに 実績のあるものが必要なら、それらを使ってください。

他と違う点が 3 つあり、1 つ目がこの記事の主題です。

  • 本物のマルチテナンシー。 アプリケーションコードではなくデータベースで強制するので、テナント フィルタを忘れたクエリは他人のインシデントではなく何も返しません。
  • OCSF ネイティブな取り込み。 アラートは OCSF 1.8.0 Detection Finding なので、準拠したプロデューサーは 変換層なしで API に直接 POST できます。
  • 見るために作ってある。 エンティティグラフ、ATT&CK マトリクス、スワイムレーンのタイムライン、地理 情報ビューが後付けではなく一級市民です。

コマンド 1 つでデータ入りのインスタンスが立ちます。

git clone https://github.com/BlueSquadron/Watari.git
cd Watari
./bootstrap.sh
Watari のダッシュボード。オープンケース数、総ケース数、平均解決時間、稼働アナリスト数と、深刻度別・ステータス別・時系列のグラフ。
2 テナント、13 ユーザー、32 ケース、20 件の OCSF アラートを投入済み。clone から 60 秒後です。

監査のきっかけ

私は統合ガイドを書いていました。認証し、アラートを取り込み、ケースに昇格させるまでを人に案内する文書 です。**動いているシステムに対してドキュメントを書くことは、私が知る最も安い監査です。**散文は主張を 強制し、主張は検証できるものだからです。

検証できなかったのが分離の主張でした。そこで 1 テナントとして接続し、行数を数えました。

全部返ってきました。

4 つの原因、1 回の着地

番号付きの 4 パネル。権限を持たないアプリケーションロール、FORCE ROW LEVEL SECURITY、実際に設定されるテナントコンテキスト、そして例外ではなくフィルタするポリシー。各パネルに単独で着地した場合どうなるかが書かれている。
各パネルの赤い行を読んでください。これを小さな PR の連続にできなかった理由です。

アプリがスーパーユーザーとして接続していた。 POSTGRES_USER は、Postgres コンテナの entrypoint が 常に SUPERUSER として作ります。そしてスーパーユーザーは RLS を無条件でバイパスします。ポリシーが どれだけ正しくても関係ありません。対応は新しい watari_app ロール、NOSUPERUSERNOBYPASSRLS、 そして意図的にテーブル所有者ではないこと。

ENABLE はテーブル所有者を免除する。 マイグレーションは所有者として走り、その所有者がリクエストも 処理していたので、スーパーユーザーでなくても免除されていました。18 テーブルを FORCE ROW LEVEL SECURITY に移行。

テナントコンテキストが設定されていなかった。 これが一番考えさせられたもので、次節を丸ごと使います。

ポリシーがフィルタではなく例外を投げていた。 読解ではなくテストスイートが見つけました。ポリシーは セッション設定を直接 UUID にキャストしていました。トランザクションローカルな設定は、トランザクション 終了時に NULL に戻りません — 空文字列に戻ります。だからプールされた接続では、次のトランザクションが ''::UUID を評価して型エラーを投げていました。NULLIF で包むと、コンテキスト不在は NULL になり、行が フィルタされます。

このどれ 1 つも、単独では何もしないかアプリを壊します。権限なしロール単独では挙動が変わりません。 FORCE 単独ではマイグレーションが締め出されます。コンテキスト単独ではどこでも 0 行になります。だから 1 つの変更・1 つの検証として着地させましたし、分割してくれというレビュー依頼には反対します。

論理ではなく順序のバグ

3 つ目の原因は今も考え続けているものです。個々のファイルはすべて正しかったからです。

ルーターは DB セッションのパラメータを auth のパラメータより前に宣言していました。FastAPI は依存を宣言順に 解決するので、get_dbget_current_user が呼び出し元を特定する前にセッションを開き、テナント コンテキストを適用する関数は auth 依存がまだ埋めていない状態を読んでいました。認証必須の 71 ルートのうち 70 が影響を受けていました。

Before と After。Before は get_db が先に走り、auth コンテキストがまだ存在せず、auth が後から state を設定しても遅く、ポリシーはテナントを見ない。After は auth が呼び出し元を解決し、リクエストのキャッシュ済みセッションをスコープするので、順序が依存グラフの性質になる。
取れる修正は関数シグネチャ 70 個の並べ替えでした。私はそれを取りませんでした。

70 個のシグネチャを並べ替えれば動いたはずです。同時に、来月このコミットを読んでいない誰かが書く 71 番目の ルートがバグを再導入することも意味します。しかも静かに。失敗の形がエラーではなく返しすぎるクエリ だからです。

なので逆方向に直しました。get_current_user が呼び出し元を特定した時点で、リクエストのセッションに テナントコンテキストを適用します。FastAPI はセッション依存をリクエストごとにキャッシュするので、 エンドポイントはパラメータをどの順序で宣言していても、同じスコープ済みセッションを受け取ります。

正当にテナントを越える部分

ロールを分けたことで、避けていた問いに向き合わされました。どの処理が本当に全テナントを見てよいのか。

答えは短く、名前を挙げられるリストでした。マイグレーション、シード、Celery ワーカー、監査書き込み、 そして認証時のユーザー参照。認証はユーザーがどのテナントに属するかを知る前にユーザーを見つける必要が あるので、これから確立しようとしている対象でスコープすることはできません。このリストが ADMIN_DATABASE_URL に紐づき、リクエストパス上のすべては権限なしロールで接続します。

このリストの列挙を強制されたことは、分離の修正そのものより価値がありました。書き留めたテナント越え 能力は、レビューできるテナント越え能力です。

Watari のアラートキュー。Wazuh・Proofpoint・Suricata・Falcon からの OCSF 1.8.0 検知結果が、深刻度・ステータス・昇格アクションとともに一覧されている。
取り込み経路 — そして次のバグ。これらのルートはどれも API キーを受け付けていませんでした。

すでに書かれていた依存

同じ監査の 2 番目の発見。X-API-Key は全ルートで 401 を返していました。README が最初に見せる OCSF 取り込み も、統合ガイドの 8 つの例も、書かれたとおりには動きませんでした。

原因は 2 つ。どちらも特定の種類の恥ずかしさです。

症状: X-API-Key が全ルートで 401。原因 1: 権限チェックが bearer 専用の依存に依存していた。原因 2: サービスアカウントのロールが空の権限セットにマップされていた。
どちらの原因も 1 点に集まっていました。これがリファクタではなく小さな変更で済んだ唯一の理由です。

全ルートが通る権限チェックが bearer トークンの依存に依存しており、その依存は API キーが検討される前に 例外を投げます。そして bearer を試して失敗したらキーにフォールバックする統合済みの依存が、まさにこの 目的で書かれてコードベースに既に存在し、どこからも参照されていませんでした。

2 つの依存を差し替えるだけで、ルーターごとの編集なしに 70 ルート全部をカバーできました。統合経路は すでにテナントコンテキストを適用していたので、分離はどちらの資格情報でも同一に振る舞います。

2 番目の原因はさらに悪い。サービスアカウントのロールが空の権限セットにマップされていたので、認証を 通ったあとでも 403 で拒否されていました。しかも「権限はユーザー行のカラムから解決される」と説明する コメントがありました。その関数はロールだけを取り、そんなことはしません。そのカラムを埋めるコードも 存在しません。**コードが持たない挙動を説明するコメントは、コメントがないより悪い。**アカウント単位の スコープを実現するには何が必要かという注記に置き換えました。

サービスアカウントは analyst の権限セットを持ちます。検知結果を取り込み、生じたケースを扱うには十分で、 意図的に tenant admin は含めません。漏洩した取り込みキーでユーザー・テナント・テナント設定を管理 できないようにするためです。テストがその付与を analyst セットに厳密に固定しているので、後で広げるには 誰かの意図的な行為が必要になります。

いくつかのルートは意図的に bearer 専用のままにしました。ログアウトと自分のパスワード変更はセッションに 紐づくもので、機械の資格情報には意味がありません。

地味なパス

この 2 つの修正の間に、ユーザーに見える成果が何もない作業を 1 日費やしました。また同じことをします。

CONTRIBUTING.md は新規参加者にフォーマッタを実行するよう指示していました。クリーンなチェックアウトで 実行すると 129 ファイルが書き換わります。フォーマッタが一度もコードベース全体にかけられていなかった からです。つまり指示に従った人は、自分の変更と並んで巨大な無関係の差分を生み、レビュアーには 2 つを 区別する手段がありませんでした。make test は 1 ファイルもマッチしないディレクトリを選択し、本来の スイートが走る前に終了していました。make test-integration は環境変数を 1 つ落とし、その失敗をシェルの || の裏に隠していたので、テストが落ちているのに成功と報告していました。npm run lint は一度も動いた ことがありません。ESLint の設定がコミットされていなかったからです。ruff check はクリーンなツリーで 67 件を報告し、貢献者はどれが自分のものか分かりませんでした。

どれも取り下げられた招待状です。プロジェクトは貢献を歓迎すると書き、そしてガイドの最初のコマンドが、 貢献者が説明しなければならない散らかりを生みます。

関連する判断。bootstrap.sh は RLS 変更より前に書かれた環境ファイルに対して起動を拒否するようになりました。 古い .env はアプリをスキーマ所有者に向けるので全ポリシーをバイパスします — スタックは立ち上がり、 すべて正常に見え、分離は静かにオフです。それを検出し、追加すべき行をそのまま出力し、停止します。 既知の危険な設定に対して大きな声で失敗することは、成功して起動することより価値があります。

Watari のケース詳細のタイムラインタブ。ケース作成、初期トリアージで追加されたオブザーバブル、ステータス変更が並び、手動エントリ追加フォームがある。
システムの追記と手書きのエントリが 1 つのタイムラインに。どちらも記録です。
1 件のケースの Watari エンティティグラフ。ホスト、IP アドレス、ファイルハッシュ、ドメインと、相関する他 2 件のケースへのエッジ。
クロスケース相関は既定で有効。灰色のノードはオブザーバブルを共有する別のケース — アナリストが 3 番目に訊き、多くのツールが一度も答えない問いです。

正直に弱いところ

Alpha、v0.1.0、Apache-2.0。バックエンドは Python 3.12 と FastAPI、フロントは React 18。スキーマは 1.0 前に 動きます。

既知のギャップは私の頭の中ではなく CONTRIBUTING.md に公開しており、最大のものは有用な意味で恥ずかしい です。CI がありません。 リンタとテストスイートを走らせるワークフローがあれば、過去の PR で修正した いくつかの 500 はコミットされる前に捕まっていたはずで、再発も止められます。利用可能な最も価値の高い貢献 として書き留めてあります。私の私的な「知っていることリスト」よりそこにある方が良いからです。

より小さく、同じくらい実在するもの。Map タブで Leaflet のマーカーアイコンが 404 になる、特定の ビューポート幅でダッシュボードのバーが髪の毛のように細くなる、そしてシード後に監査ログのページが空 (シードスクリプトはエントリを書くと主張している)。

一番役に立つのは ./bootstrap.sh を走らせ、クリックして回り、驚いたことを Issue にすることです。 **セットアップの摩擦はバグです。**外部の貢献者が既に修正を 1 つ landing させました — 409 時にセッションが 汚染されるのを防ぐトランザクションのロールバック — そういう変更が最も見たいものです。実際に動かしている 人から来たものだからです。