マスキングと保持
何が一度も保存されないのか、何が保存されなくなるのか。秘匿化は書き込みの前に走ります。後から削除された秘密も、一度は書かれているからです。
秘匿化は保存の前に走ります
プロンプトと補完は、mcpd が持つもののうち最も価値があり、同時に 最も危険です。秘匿化は取り込みパイプラインの 3 段目、書き込みより前に あるので、ルールに一致した値はデータベースにまったく届きません。
-
Decode
OTLP or native, gzip or plain
-
Sample
Decided once, at the root
-
Redact
Before anything is written
-
Price
From the configured price book
-
Store
One transaction per batch
Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.
この順序こそが要点です。事後に消された値も、ディスクの上にはあり、先行 書き込みログの中にもあり、おそらく昨夜のバックアップの中にもあります。 一度も書かれていないことは、後から削除されたこととは別の保証であり、 尋ねてきた人にそのまま言えるのは前者だけです。
秘匿化は既定で有効です。設定して初めて守ってくれる道具は、誰も守りません。
--no-redact、または MCPD_NO_REDACT で無効に
なります。これが妥当なのは、上流ですでに秘匿化されている場合だけです。
秘匿化は、この経路のデーモン側の端です。もう一方の端は SDK が持ちます。
MCPD_CAPTURE_PAYLOADS=false、またはコードの中の
capture_payloads は、プロンプトと補完をプロセスの中に
留めます。スパンは時間、状態、使用量、モデル、相関、エラーを持ったまま
届き、input と output はキューに入る前に落とされる
ので、ネットワークを渡ることはありません。そもそもバイト列を送っては
いけないチームはこれを設定し、機微なものを含まないスパンは、そのスパン
だけ送信に戻すことができます。
既定のルール
ルールは、小文字の部分文字列の集合とモードの組です。キーの名前を小文字に
したものが、そのいずれかを含むとき一致します。部分文字列で一致させている
ので、大文字と小文字の組み合わせや接頭辞を 1 つずつ並べなくても、
X-API-Key、authorization、
customer_email がどれも捕まります。
| モード | 一致するキー名 |
|---|---|
mask | api_key, apikey, api-key,
secret, password, passwd,
access_token, refresh_token,
id_token, bearer,
authorization, auth_token,
private_key, client_secret,
session_token, credential,
signature, x-api-key |
partial | email, phone, ssn,
credit_card, card_number, iban |
hash | user_id, userid |
一致したキーがオブジェクトや配列を持つ場合、その配下もすべて秘匿化されます。
{"credential": {"value": "hunter2", "note": "prod"}}
は両方のフィールドを失います。危険に見える名前のほうだけではありません。
限界は、はっきり書いておく価値があります。ルールが一致するのはキー
名であって、値ではありません。利用者がプロンプトの途中に貼り付けた
API キーが content という名前のキーの下に保存された場合、ここに
ある何にも一致しません。秘匿化が止めるのは、資格情報や識別子が構造化された
形で取り込まれることです。内容のスキャナーではないので、そう思って使うと
期待を裏切られます。
4 つのモード
| モード | 値がどうなるか |
|---|---|
drop | キーごと取り除かれます。最初から送られなかったのと同じです。 |
mask | 値が [redacted] に置き換わります。 |
partial |
先頭の 2 文字と末尾の 2 文字が残り、間が省略記号でつながれます。
alice@example.com は al…om になります。
すでに知っている値を見分けるには十分で、使うには足りません。6 文字
以下の値は、代わりに全体がマスクされます。5 文字のうち 4 文字を残せば、
ほとんど開示したのと同じだからです。
|
hash |
値が sha256: と、鍵付き SHA-256 ダイジェストの先頭
12 バイトを 24 桁の 16 進で表したものに置き換わります。
|
組み込みのルールは、4 つのうち 3 つを使います。既定では何も破棄されません。
drop は、あなたが自分で足すルールのためにあります。フィールドが
存在すること自体、残したくない場合です。
$ curl -s "$MCPD/v1/api/traces/$TRACE" | jq .spans[0].input{ "question": "Where is my order A-1042?", "api_key": "[redacted]", "email": "al…om", "user_id": "sha256:9f2c1b7d4a08e5c3f6b1d0a4"} ハッシュに鍵を使う理由
ハッシュ化は、フィールドを使える状態に保ちます。1 つの鍵のもとでは、同じ 入力から同じダイジェストが出ます。だから、その人が誰であるかを持たないまま でも、秘匿化されたユーザー id でまとめたり、ある失敗に何人が当たったかを 数えたり、その 1 人をセッションをまたいで追ったりできます。
鍵が無ければ、その性質は穴になります。識別子は狭くて推測しやすい空間から 来るので、ダイジェストとこのソースを持つ人なら、一致するまで候補を ハッシュ化していけます。ダイジェストを、単に不便なだけのものではなく復元 できないものにしているのは、鍵です。
組み込みの鍵はソースの中の定数なので、公開されています。何かをハッシュ化 するデプロイでは、自分の鍵を設定してください。
ハッシュ化と期限切れを行うデプロイ
mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30 鍵のローテーションは、意図的につながりを切ります。すべてのダイジェストが 変わるので、ローテーションの前に書かれた行と後に書かれた行は、もう結合 できません。それがローテーションの目的であり、同時にその代償です。 つながりを切りたいときにローテーションしてください。習慣で予定を組む ためではありません。
ローテーションの前に、その代償の後半も読んでください。個人単位の消去は 保存されたダイジェストと照合するので、新しい鍵が効き始めた瞬間から、古い 鍵で書かれたすべての行は、削除の請求の手が届かないところに行きます。mcpd はそれが起きたと教えることはできません。どの鍵がどの行を書いたかを、何も 記録していないからです。できるのは、その結果として出たゼロを答えとして 通さないことだけで、下のゼロについての節が、その拒否にあたります。
スパンだけでなく、すべてのシグナルに
秘密は、プロンプトからと同じくらい簡単にログの 1 行からも漏れます。その ため取り込みの途中で、同じ秘匿化処理が 6 つのシグナルすべてに走ります。
- スパン:
input、output、attributes、metadata。 - ログ:構造化された
fields。 - メトリクス:
labels。 - スコア:
metadata。 - フィードバック:
metadataと、自由記述のcomment。 - 観点ノート:文そのものである
text。
スパンの名前とログレコードのメッセージは、秘匿化処理を通しません。それらは ペイロードではなく識別子であり、書き換えるとグルーピングが壊れます。スパン の名前を顧客のメールアドレスから組み立てないでください。
実行ストアは秘匿化されません
永続実行は例外で、それは意図的です。実行ストアは、実行の入力、状態、出力、
相関と、各ステップの入力、出力、エラーを、送られたとおりに保持し、
user_id もハッシュ化しません。それらはワークフローの作業状態
です。あとのステップがそれを読み返すので、秘匿化処理が書き換えた値は実行の
動きを変えてしまいます。実行がトレースのストアに書くスパンは通常の取り込み
経路を通るので、トレースのストアはそれらを、ほかのスパンと同じように秘匿化
し、ハッシュ化して保持します。下の節で述べるとおり、保持期間と消去はどちらも
実行ストアに及び、API を通じた実行の読み取りはすべて監査ログに記録されます。
長い文字列と深いドキュメント
ルールに一致したかどうかに関係なく、すべてに 2 つの上限がかかります。
128 KiB を超える単一の文字列は切り詰められ、
… [N bytes truncated] が付きます。読む人が、その値は短いのでは
なく不完全なのだと分かるようにするためです。切れ目は文字の境界に落ち、
マルチバイトのコードポイントの内側で切れることはありません。暴走した 1 つの
プロンプトが、データベースの大きさを決めるべきではありません。
走査は深さ 32 で止まり、それより下は [too deeply nested] に
置き換わります。開いた取り込みポートに届いた病的な、あるいは自己参照する
ドキュメントが、CPU を占有することはできません。
保持期間
--retention-days、または MCPD_RETENTION_DAYS は、
その日数より古いシグナルを削除します。指定しなければ、何も削除されません。
既定でデータを残すほうが正しい向きです。先月のトレースを黙って捨てる道具
では、そのことに気づくのが、いちばん困るときになります。
この処理はデーモンの中で 1 時間ごとのタイマーで走り、クエリの時点では走り
ません。保持期間の掃除は非常に多くの行を削除しうるので、その作業が誰かの
リクエストの中に入り込んではいけません。1 回ごとに、1 つのトランザクション
で、received_at が期限より古いスパン、ログ、メトリクス、
トレースの行と、自身のタイムスタンプが古いスコア、フィードバック、
観点ノートを削除し、何行消えたかをログに出します。全文検索インデックスの項目は、それを指す行が
まだ存在するうちに先に消されるので、迷子になるものはありません。
同じ処理が、同じ期間で実行ストアも削ります。ルートが期限より前に終わった すべての永続実行を、その子の実行と、それらについて保存されたすべてとともに 削除します。削除はバッチに分けて行うので、その間ワーカーが待たされることは ありません。終わっていない実行は、どれだけ長く待っていても削除されません。 定義は削られません。
この処理の対象は、--project が指すプロジェクトと、設定ファイル
の projects ブロックにあるすべてのプロジェクトで、既定のものだけ
ではありません。そこで自分の retention_days を設定している
プロジェクトはその値で掃除され、残りはデーモンの値で掃除されます。どちらにも
書かれていないプロジェクト、つまり送信側が勝手に名付けただけのものは、期間を
持たず、まったく削られません。これは 1 つのデーモンが複数のチームに使われる
ときに効いてきます。30 日で消すと約束したチームのデータが、掃除の読んだ設定
が別のチームのものだけだったという理由で黙って残り続けることはなく、そして
誰も書き留めなかったチームのデータは、すべて残ります。
個人についての消去の請求
DELETE /v1/api/subjects/{user_id} は、1 人の個人についての
請求に 1 回の呼び出しで答えます。識別子は、あなたの計装が設定したままの形で
送ってください。デーモンは取り込み時と同じハッシュ化を適用するので、
user_id をハッシュ化するデプロイでも、あなたが先にハッシュ化
する必要はありません。
消去の単位は行ではなく、実行です。user_id を持つのはたいてい
ルートのスパンだけで、プロンプトと補完を持つのは子のスパンです。
そのため、その個人を指す行だけを削除しても、ペイロードは残ってしまいます。
実行が対象になるのは、そのトレースの射影がその個人を指すとき、または
そのスパンのいずれかが指すときで、そのときは実行まるごとが消えます。
- その実行のすべてのスパンと、それが持っていた入力と出力。
- それにぶら下がるログ、メトリクス、スコア、フィードバック、観点ノート。
-
span_idを指定しtrace_idを指定していない シグナル。その個人の実行の 1 ステップに紐づけられたスコアやログの行には、 対象となる実行のトレース id からだけでなく、そのスパン id からも到達 します。 -
ぶら下がる実行を持たないまま、その個人に対して記録されたログの行。
これは自分自身の
user_idで照合されます。 -
その個人が書いたフィードバック。どこにぶら下がっていても
author_idで照合されます。その行にはその人自身の文章が 入っており、他人の実行にぶら下がることもあります。以前は、そこで生き 残っていました。 - 対象となる実行から取り込まれたデータセットの事例。これらは削除ではなく、 空にされます。理由は 1 つ先の節にあります。
-
永続実行を提供するデーモンでは、相関がその
user_idを持つ、 終わったすべての永続実行。実行ストアはそれをハッシュ化せずに保持するので、 照合は実行を開始したときの形のままで行われます。その子の実行の木全体と、 実行ストアがそれらについて持つすべても消えます。その個人の永続実行のうち 終わっていないものには触れません。削除すると、それを含むワークフローが 壊れるからです。それはruns_in_flightに数えられ、noteで名指しされるので、キャンセルするか終わるのを待って から、請求をもう一度送れます。
実行ストアが先に消去されます。永続実行のスパンは、トレースのストアに書かれる までそのアウトボックスで待っているので、先に実行を消去すれば待っている スパンも削除され、そのあとのトレースのストアの消去が、すでに書かれたスパンを 取り除きます。
レスポンスが持つもの
シグナルごとの件数、実行ストアについての 2 つの件数、そしてその横に、件数
では表せないことを伝える 6 つのフィールドがあります。意図的に残したものは
何か、そして小さな数字を「この人については何も保存されていない」と読んで
よいかどうかです。deleted は 6 つのシグナルの件数の合計です。
実行の件数と aspect_notes_erased はその横に報告され、合計には
加えられません。
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq{ "project": "checkout", "traces": 1, "spans": 2, "logs": 1, "metrics": 0, "scores": 1, "feedback": 1, "deleted": 6, "runs_erased": 1, "runs_in_flight": 0, "dataset_items_erased": 1, "aspect_notes_erased": 0, "datasets": [ "checkout-regressions" ], "deletes_pending": false, "unmatchable_identifiers": 0, "note": "1 captured dataset cases were emptied in place rather than deleted, so a regression test is not destroyed without anyone being told. They hold no payload any more."} | フィールド | それが伝えること |
|---|---|
runs_erased |
実行ストアから削除された永続実行。子の実行を含みます。
deleted には数えられません。デーモンが実行を提供して
いないときは、ありません
|
runs_in_flight |
終わっていないために、まるごと残されたその個人の永続実行。
note がキャンセルすべき実行を名指しします。デーモンが
実行を提供していないときは、ありません
|
dataset_items_erased |
その場で空にされた、取り込み済みの事例。行はまだ残っているので、
deleted には数えられません
|
aspect_notes_erased |
削除された観点ノート。deleted には数えられません。
deleted は、その上に出力される 6 つのシグナルの件数の
合計です
|
datasets | それらの事例がどのデータセットにあるか。見に行けるようにするためです |
deletes_pending | バックエンドが応答した後に削除を適用するときに真になります。件数は、 すでにディスクから消えたものではなく、一致したものを表します |
unmatchable_identifiers | このプロジェクトに保存されている識別子のうち、このリクエストでは 照合しようのない形のものの数 |
note | 上の数字が答えのすべてではないかもしれない理由。すべてであるときは、 ありません |
取り込まれた事例は、削除ではなく空にされます
本番から取り込まれたデータセットの事例は、入力をそのまま写して持っています。 実行だけを消してその写しを残せば、その人の言葉はデータセットの中に残ります。 かといって事例をそのまま削除するのは、別の種類の害です。誰かがその実行を 取り込んだのは、あるバグが戻ってこないようにするためであり、その回帰テストを 黙って壊すのは、mcpd が独断で下してよい判断ではありません。
そこでペイロードを上書きし、行は残します。事例は、入力とメタデータがあった
場所が {"erased": true} になり、元の実行への参照は消され、
レスポンスが事例の数とデータセットの名前を伝えます。事例は目に見えて空に
なるので、運用者が手を打てます。
まったく手の届かない事例が 1 つあり、理由はテナントです。別の プロジェクトに属するデータセットへ取り込まれた事例は、空にされず、数にも 入りません。この呼び出しは 1 つのプロジェクトに対して認可され、1 つの プロジェクトにだけ触れます。その境界を越える消去は、読めないデータセットの 中身を知る手段になってしまいます。そのプロジェクトからも、そこに届くキーで その個人を消してください。
これがやらないこと
触れるのは 1 つのプロジェクトです。?project= でそれを指定し、
省略した場合はデーモンの既定のプロジェクトが使われます。そのプロジェクトに
届かないキーは 403 で拒否され、何も削除されません。キーが開けない
プロジェクトにある同じ個人の行は、触れられも数えられもしないので、件数から
その個人が他の場所に存在すると分かることはありません。1 人を複数の
プロジェクトから消すには、プロジェクトごとに 1 回ずつ、それぞれに届くキーで
呼び出します。
何も保存されていない個人には、404 ではなく、ゼロを持つ 200 が返ります。 あなたが求めた状態は成り立っており、404 は誰にも尋ねる資格のない問い、 つまりこのキーで見える範囲のどこかにその人がいるかどうかに答えてしまいます。 タイムアウトの後にやり直しても安全なのは、これが理由でもあります。2 回目の 呼び出しは、エラーではなく、2 回目の処理としての小さい件数を報告します。
これは、あなた自身のシステムから個人を消す手段ではありません。mcpd が削除 するのは、mcpd が保存したものです。別の OTLP エンドポイントに転送したもの は、そちらでも消す必要があり、mcpd はそれがどうなったかを知りません。
キーと、その記録
キーが設定されている場合、消去には admin キーが必要で、admin キーが設定
されていなければ read キーにフォールバックします。既存のデプロイが持って
いた到達範囲を保つためです。消去は、操作、プロジェクト、要求したキーの
ダイジェストとともに監査ログに書かれ、拒否された試みも同じように記録され
ます。user_id がハッシュ化されている場合、行が指すのはその
ダイジェストです。監査ログは、それが記述するデータより長く残ります。生の
識別子をそこに書けば、その人が消してほしいと言った名前が、保持期間の掃除
が触れないテーブルに残ってしまいます。
1 回に 1 つの実行を消す方法も、そのままあります。
DELETE /v1/api/traces/{trace_id} は、1 つの実行と、それに
付随するすべてを 1 つのトランザクションで削除し、削除した行数を返します。
消去が返すゼロは、疑うべき値です
運用者が個人に「あなたについては何も持っていません」と伝えるとき、その 根拠になるのがゼロです。そしてゼロは、このエンドポイントが証明できない 唯一の数字です。保存された識別子は秘匿化の鍵でハッシュ化されており、どの鍵 がどの行をハッシュ化したかは記録されていないので、ゼロは、それらの行が 書かれた後に鍵が変わったことも同じくらい意味しえます。mcpd はこの 2 つを 区別できません。できるのは、前者に読める裸のゼロを決して返さないことです。
次は同じ個人を、実行が記録された後にローテーションされた秘匿化の鍵のもと で消去した例です。件数はすべてゼロですが、行はまだそこにあります。
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 0, "unmatchable_identifiers": 0, "note": "Nothing matched. Stored identifiers are hashed with the redaction key, so this means no stored identifier hashes to this subject under the key in force now. It does not mean nothing about this person was ever stored: rows written under a different redaction key cannot be matched by this endpoint."} note が証明できることは、プロジェクトによって変わります。そのプロジェクトの何も 誰にも帰属していなければ、鍵の変更が隠しうる行は存在せず、note はそのとおりの ことを書きます。そうでなければ、ゼロは証明されていません。それらの行が書かれた ときの秘匿化の鍵を戻して、リクエストをやり直してください。
拒否するのではなく報告するのは、意図した引き換えです。拒否すれば、 タイムアウトの後のやり直しが壊れます。「一度も見ていない」への応答と 「1 時間前に消した」への応答が食い違い、このエンドポイントは、ある個人が そのプロジェクトに知られているかどうかを尋ねる手段になってしまいます。 さらに、秘匿化の設定が一度でも変わったデプロイでは、正しく何にも一致 しなかったものまで含めて、すべての消去が止まります。
このリクエストが照合しようのなかった識別子
ローテーションは目に見えません。識別子をそもそもハッシュ化するかどうか
の変更は、目に見えます。unmatchable_identifiers が、mcpd に
見えている部分です。ハッシュ化された識別子は sha256: という
接頭辞で見分けられるので、ストアは、保持している識別子のうち、この
リクエストが持っていた形と逆の形のものが何件あるかを数えます。ハッシュ化
されたリクエストに対する平文の行、またはその逆です。それらの行は違う
秘匿化の設定のもとで書かれており、その個人のものかもしれず、この
リクエストでは指定できません。
この計数は、ゼロのときだけでなく、すべての消去で走ります。理由は下の
レスポンスです。このプロジェクトには、まず秘匿化を有効にして書き込み、
その後に無効にして書き込みました。リクエストは新しいほうの行に一致して
それを削除し、古いほうの 2 つの識別子はハッシュ化されているので、この
リクエストでは最初から指定できませんでした。deleted で止まる
件数だけなら、完全な答えのように読めてしまいます。
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 2, "unmatchable_identifiers": 2, "note": "2 stored identifiers in this project are in a form this request could not match, so rows written under a different redaction setting were not searched and rows about this person may remain. Restore the redaction setting those rows were written under and repeat the request."} この計数は、その個人自身の行ではなく、プロジェクトが持つすべての識別子を 対象にしています。だからその 2 件は、まったく別の人のものかもしれません。 言えるのは、そこまでです。これが排除するのは、成功したように見える消去を、 完全な消去だと読んでしまうことです。
保証は、3 つのバックエンドで同じではありません
SQLite と Postgres は、あなたに応答したトランザクションの中で削除します。
レスポンスが届いたときには行は消えており、deletes_pending は
false です。
ClickHouse は違います。そこでの削除は非同期のミューテーションです。サーバー
は文を受け付け、バックグラウンドで適用するので、レスポンスは、すでに
ディスクから消えたものではなく、一致したものを伝えます。これらの消去は
deletes_pending が true で返ってきます。2 つの
保証のうち弱いほうが有効だと、mcpd が伝えているということです。この
バックエンドでは、データセットの事例だけが例外です。ミューテーションでは
なく自分のキーのもとで書き直されるので、レスポンスが送られた瞬間から、
どの読み取りも空になった事例を返します。ミューテーションなら、それが終わる
まで古いテキストが読める状態で残っていたはずです。
ある時点までにデータが消えたと誰かに証明する必要があるなら、書き留めて おくべき違いはここです。ClickHouse では、答える前にサーバー上で ミューテーションが完了したことを確認してください。
ポートが到達可能になる前に
秘匿化が守るのは、トレースの中の値です。誰がそのトレースを読めるかについて
は、何もしません。mcpd は、取り込みか読み取り API が開いたままループバック
以外のアドレスにバインドしたとき、起動時に警告します。そのとき、保存された
プロンプトと補完は、ポートに到達できる誰にでも読めるからです。先に
--ingest-key と --read-key を設定し、
Docker と Kubernetes を読んで
ください。