プログラミング
Heliconeは、ユーザーが共有ClickHouseに直接SQLを記述できるようにします
Helicone allows users to write SQL directly to a shared ClickHouse (justintorre.com)
要約
Heliconeは、HQL(Helicone SQL)と呼ばれるSQLエディタを導入しました。これにより、ユーザーはテキストボックスにSELECT文を記述するだけで、共有ClickHouseクラスターに対して直接クエリを実行できます。各組織のリクエストデータは単一のテーブルに保存され、organization_id列で分離されます。ClickHouseの行ポリシーとカスタム設定を連携させることで、組織Aのクエリが組織Bの行を読み取ることをデータベースレベルで保証しています。
全文翻訳
HeliconeにHQLと呼ばれるSQLエディタを追加しました。顧客はテキストボックスにSELECT文を記述し、クエリはClickHouseクラスターに対して直接実行されます。各組織のリクエストデータは、organization_id列のみで区切られた単一のテーブル、request_response_rmtに格納されます。
共有テーブルに対して、見知らぬ人に任意のSQLを実行させることは、追加の手順を踏んだセキュリティインシデントのように聞こえるため、出荷前にデータベース自体が、組織Aからのクエリが組織Bに属する行を決して読み取れないことを保証する必要がありました。ClickHouseは、あまり注目されていない2つの機能、つまり行ポリシーとカスタム設定でこれを実現できます。ここでは、それらをどのように連携させたか、そしてアプリ側のコード(ASTパーサーを含む)がまだ何をしなければならないかを示します。
HQL本番環境にて。この画面上のすべてのクエリは、同じ共有テーブルに対して実行されます。
行ポリシー
ClickHouseの行ポリシーは、テーブルごと、ユーザーごとにサーバーがすべての読み取りに追加するフィルターです。設定は移行からの2つのステートメントです。行ポリシーを持つ一意のユーザーを作成し、そのユーザーにポリシーをアタッチします。
CREATE USER IF NOT EXISTS hql_user;
CREATE ROW POLICY hql_organization_filter ON request_response_rmt FOR SELECT USING organization_id = getSetting('SQL_helicone_organization_id') TO hql_user;
最初のステートメントのポイントは、一意のユーザーです。hql_userはHQLトラフィックを提供するためだけに存在し、ポリシーはTO hql_user句を通じてそれにバインドされます。私たちのインジェストコードとダッシュボードコードは、異なるユーザーとして接続し、ポリシーを見ることはありません。hql_userの場合、顧客が実際に記述したSQLに関係なく、request_response_rmtに対するすべてのSELECTは、WHERE organization_id = getSetting('SQL_helicone_organization_id') がクエリに含まれているかのように動作します。
getSettingが興味深い部分です。現在のクエリのコンテキストから設定を読み取ります。ClickHouseは認識しない設定名を拒否し、カスタム設定は宣言されたプレフィックスで始まる場合にのみ有効です。セルフホストする場合、サーバー設定でそのプレフィックスを選択します。
<clickhouse>
<custom_settings_prefixes>SQL_</custom_settings_prefixes>
</clickhouse>
私たちのクラスターが実行されているClickHouse Cloudでは、選択はありません。サーバー設定を変更することはできず、ClickHouseのドキュメントには「カスタムプレフィックスを指定することはできません」と記載されています。すべてのカスタム設定は例外なくSQL_で始まります。そのため、よりクリーンな名前ではなくSQL_helicone_organization_idと呼ばれ、その厄介なプレフィックスは命名選択ではなく、負荷を担っています。リポジトリのどこにもcustom_settings_prefixesのエントリがないことに気づくでしょう。Cloudでは設定するものがありません。
プレフィックスには歴史もあります。MySQLクライアントは接続時にSQL_AUTO_IS_NULLのようなセッション変数を設定し、ClickHouseはそれらをノーオペレーションとして受け入れます(ClickHouse PR #50013)。これにより、TableauのようなMySQL対応ツールが接続時に問題を起こさなくなります。おそらく、CloudがSQL_を唯一許可されるプレフィックスとして標準化したのはそのためであり、私たちのテナンシーフィルターはMySQLドライバーのために構築された命名規則に乗っています。
クエリパラメータが登場する場所
ClickHouseの設定はクエリごとに設定できます。HTTPインターフェースではURLクエリパラメータとして渡されるため、すべてのHQLリクエストはクエリ文字列にテナントIDを運びます: ?SQL_helicone_organization_id=<org>。公式Nodeクライアントはそれをclickhouse_settingsで隠蔽しており、私たちのものはClickhouseWrapper.tsにあります。
const result = await clickHouseHqlClient.query({
query: userSql,
format: "JSONEachRow",
clickhouse_settings: {
SQL_helicone_organization_id: organizationId,
readonly: "1",
allow_ddl: 0,
max_execution_time: 30,
max_memory_usage: "4000000000",
max_result_rows: "10000",
},
});
organizationIdは私たちの認証レイヤーから取得され、顧客が入力したものではありません。他の設定は、顧客がインジェストパイプラインや他の顧客とクラスターを共有しているため存在します。30秒のタイムアウト、メモリキャップ、結果行数キャップです。
readonlyは独自の段落に値します。ClickHouseのSELECTはSETTINGS句で終わることができます。つまり、顧客はSELECT * FROM request_response_rmt SETTINGS SQL_helicone_organization_id = 'victim-org' と試して、私たちが渡した値を上書きしようとする可能性があります。readonly = 1 にすると、サーバーはクエリ時に設定の変更を拒否するため、その穴は閉じられます。さらに、アプリは設定名に言及しているクエリさえ拒否します。
const forbiddenPattern = /sql[_\]s*helicone[_\]s*organization[_\]s*id/i;
if (forbiddenPattern.test(query)) {
return err("Query contains a reserved setting name");
}
ClickHouseは実際のブロックを強制します。正規表現は、試行がデータベースエラーではなく、拒否されたクエリとしてログに表示されることを意味します。
他のすべてを剥奪する
1つのテーブルに行ポリシーがあっても、データベースの他の部分には何の効果もありません。ClickHouseには非常に読みやすいシステムテーブルが付属しています。system.query_logだけでも、すべてのテナントが実行したすべてのクエリ(SQLテキストを含む)が含まれています。そのため、hql_userは正確に1つのGRANTにまで削減されます。
REVOKE ALL ON system.* FROM hql_user;
REVOKE ALL ON information_schema.* FROM hql_user;
REVOKE ALL ON default.* FROM hql_user;
GRANT SELECT ON default.request_response_rmt TO hql_user;
また、エディタのオートコンプリートを駆動するDESCRIBEの結果からorganization_id列をフィルタリングします。列はまだ存在し、ポリシーはそれを参照していますが、エディタ内からはテーブルが共有されている兆候はまったく見えません。
ASTは何のためにあるのか
テナンシーの強制はアプリケーションでは行われません。これは意図的です。魅力的な設計は、各クエリを解析し、ASTをウォークし、実行前にorganization_id = ? をWHERE句にスプライスすることです。そうすれば、セキュリティ境界はClickHouse構文に追いつくJavaScript SQLパーサーになり、ClickHouse構文には、ほとんどのパーサーが詰まるproperties['user_id']マップサブスクリプトやarrayJoinのようなものが含まれます。パーサーが誤って処理したクエリは、漏洩するクエリになります。
クエリは引き続き解析しますが、失敗が許容されるものだけです。node-sql-parserにはClickHouseダイアレクトがないため、Postgresダイアレクトで解析し、ASTにLIMITをクランプまたは挿入し、クエリをシリアライズして戻します(ダンス全体はHeliconeSqlManager.tsにあります)。
const ast = parser.astify(sql, { database: "Postgresql" });
const limitedAst = addLimit(normalizeAst(ast)[0], limit);
firstSql = parser.sqlify(limitedAst, { database: "Postgresql" });
ClickHouse固有の構文が解析を壊した場合、例外をキャッチし、生の文字列で正規表現を使用してLIMITをクランプするフォールバックに切り替えます。そのフォールバックは、orgフィルターがそれに依存している場合、恐ろしいものになるでしょう。フィルターはデータベースに存在するため、パーサーのバグがここでできる最悪のことは、期待よりも多くの行を返すクエリを許可することであり、max_result_rowsがそれをキャッチします。
これらが実行される前に、検証パスが、プレーンなSELECT以外のすべて、および現在1つの名前のみを含む許可リスト外のテーブル参照を拒否します。
サブクエリは恐ろしい部分でした
設計中に私たちを不安にさせたクエリの形状はサブクエリでした。テナンシーがアプリサイドのリライターに存在する場合、SELECT * FROM (SELECT * FROM request_response_rmt) AS sub はそれを壊すクエリです。外側のSELECTにWHERE organization_id = ? をスプライスしても、何もフィルタリングされません。内側のSELECTはすでにテーブル全体を読み取っています。正しいリライターは、ネストされたSELECT、CTE、UNIONブランチ、JOINオペランドのすべてのテーブル参照を見つけ、それぞれをフィルタリングしたことを証明する必要があります。
私たち自身の検証コードは、アプリでクエリをウォークすることの限界を示しています。テーブル許可リストは、FROMとJOINの後に識別子が続くものをスキャンすることで機能し、サブクエリはテーブル名ではないため、括弧を開くものはすべて意図的にスキップします。ガードレールとしてはそれで十分です。セキュリティ境界としては、UNIONを通すことができる穴になるでしょう。そして、node-sql-parserがまったくASTを生成できない場合、実際のClickHouseクエリは常にトリガーされますが、その場合、ウォークするツリーはまったくありません。
そのため、テストスイートは主にパーサーではなくデータベースを攻撃します。hqlSecurityTests.test.tsは、実際のClickHouseインスタンスに対して、実際の