4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Databricks で個人情報をマスクするなら「見せない・消す・見つける」で考える(Free Edition で検証)

4
Last updated at Posted at 2026-10-02

この記事のサンプルに出てくる「加茂鹿男」さんの氏名・生年月日・住所・電話番号・メールアドレス・カード番号・マイナンバーは、すべて架空のテスト用ダミーです。実在の人物やカードとは一切関係ありません。また、各機能のリリース状態や制約は 2026 年 9 月時点のものです。利用前に最新のドキュメントを確認してください。

はじめに

生成 AI でデータ活用が進むほど、「このデータに個人情報が混ざっていないか」「それが誰に見えているのか」が気になってきます。分析基盤に色々なデータを集めるなら、なおさらです。

この記事では、Databricks で分析するときの個人情報の守り方を紹介します。Databricks(Unity Catalog)には、カラムマスク、ABAC ポリシー、ai_mask()、Data Classification と、個人情報まわりの機能がいくつもあります。ただ、並べて見るとどれをどの場面で使うのかが分かりにくいので、「見せない・消す・見つける」の 3 つに分けて整理しました。

分類 やること 保存データ 機能
見せない 読み取り時にマスクして返す そのまま残る カラムマスク / ABAC ポリシー
消す データ自体を書き換える 書き換わる(非可逆) ai_mask() / ai_query()
見つける どの列に PII があるか検出してタグを貼る 変わらない Data Classification

この 3 つを混同すると事故ります。「見せない」だけで満足していると、権限を持つ人やエクスポート経路からは元データがそのまま見えます。逆に「消す」を生データに直接かけると、後から「やっぱりこの項目は残したかった」となっても戻せません。

ドキュメントを読むだけでなく、無料で使える Databricks Free Edition で実際に動かして、挙動も確かめています。各章の終わりの「実運用で使うなら」で、業務で使うときに足すものを書きます。

検証環境: Databricks Free Edition

Community Edition の後継として 2025 年に出た無料版です。メールアドレスか Google / Microsoft アカウントでサインアップすると、ワークスペースが 1 つ自動で作られます。serverless 限定・クォータ付きですが、Unity Catalog や AI Functions は商用版とほぼ同じ機能を使えるので、機能の挙動を確かめるには十分です。

検証では、最初から用意されている SQL ウェアハウス(serverless、2X-Small)で SQL を流しています。無料枠なので使えない機能もあり、今回だと Data Classification は有効化できませんでした。そこは機能の紹介にとどめます。

検証の進め方

以前、音声の文字起こしテキスト(ASR ログ)の個人情報マスキングを、Microsoft Presidio とローカル LLM で試しました。

今回も同じ要領で、個人情報を詰め込んだ台本を自分で読み上げ、文字起こししたテキストを使います。今回の台本は、奈良公園に住む中年男性「加茂鹿男」さんの自己紹介です。人名・生年月日・住所・電話番号・メール・会員番号・カード番号・マイナンバーを一通り仕込みつつ、残すべきもの(社名・寺社名・金額・日付)も混ぜてあります。

台本の全文
はじめまして。加茂鹿男と申します。かも、しかお、です。1978年4月1日生まれの48歳で、生まれも育ちも奈良です。

住所は、奈良県奈良市雑司町469番地、鹿寮ハイツ203号室です。東大寺の大仏殿から歩いて5分くらい、春日大社の参道にも近い、奈良公園のど真ん中に住んでいます。窓を開けると鹿がいます。

仕事はカモシカモバイルという会社で、法人営業を20年ほどやっています。会社の直通電話は0742-12-3456、携帯は090-0123-4567です。メールアドレスは shikao.kamo@example.com です。かもの綴りは k、a、m、o です。

趣味は鹿の観察で、奈良公園シカ友の会という会に入っています。会員番号は NP-2026-000731 です。年会費は3,600円で、鹿せんべいが年に一度12袋届きます。1袋200円なので、まあまあ元は取れています。

最近は若草山の山焼きの写真を撮るのにハマっていて、今年の1月24日は朝5時から場所取りをしました。カメラは去年の12月に買い替えて、支払いに使ったカードは 4111-1111-1111-1111、有効期限は2028年3月です。

あと、先日役所でマイナンバーを聞かれて、123456789012 と答えたら「読み上げなくていいです」と言われました。気をつけます。

そんな感じで、鹿と暮らす中年です。よろしくお願いします。

台本に仕込んだ個人情報と、残すべきものを整理すると次のとおりです。

# 種類 台本中の値 期待
1 人名 加茂鹿男(かも、しかお) マスク
2 生年月日 1978年4月1日 マスク
3 住所 奈良県奈良市雑司町469番地 鹿寮ハイツ203号室 マスク
4 電話番号(会社) 0742-12-3456 マスク
5 電話番号(携帯) 090-0123-4567 マスク
6 メールアドレス shikao.kamo@example.com マスク
7 会員番号 NP-2026-000731 マスク
8 クレジットカード番号 4111-1111-1111-1111 マスク
9 マイナンバー 123456789012 マスク
A 組織名 カモシカモバイル / 奈良公園シカ友の会 残す
B 寺社・地名 東大寺 / 春日大社 / 若草山 / 奈良公園 残す
C 金額・日付 3,600円 / 200円 / 1月24日 残す

マスク対象の 9 件が消えるか、残すべき 3 種類を誤って消さないか(過剰マスク)を見ます。寺社名を入れたのは、住所の一部でない地名を ADDRESS として巻き込まないかを見たいからです。

ちなみに Databricks には ai_transcribe() という文字起こしの AI 関数もあるのですが、Beta 時点では英語とスペイン語のみの対応なので、今回は手元で文字起こししたテキストを持ち込みます。

この台本を自分で読み上げて録音し、小さいモデルの Whisper base(faster-whisper, CPU)で文字起こししました。電話番号のハイフンが消え、メールアドレスがカタカナになるなど、以前の記事と同じように崩れています😅

文字起こしの全文と崩れ方
初めましてかもしかおうと申しますかもしかおです1978年4月10日までの48歳でまれもすらちもならです中所は7件7支映像市長4609番地しっかりを配置2丸産合室です東大地下の大武線から歩いて5分ぐらいかすか大社の産道にも近い並行のどの中に住んでいます窓を与うとしかがいます 仕事はかもしかもあるという会社で本人や420年ほどやっています会社の直数点は074223456 期待は090101234567ですメラルですはしかおうどっとかもアットマックイグザンプルドっと込むです 観物にはKAMです趣味はしかの感染性なら公園仕方者会という会に入っています 会員番号はNP2026000731です年間平和3000度ピュカインで4000映画年に1度12袋とときます 人袋200円なのでまままもらっています最近赤くさやもの山焼きの出身を取るのにはまっていて今年の1月24日は朝5時から場所とりましょう 画面の脳年の12月に開催して仕方人使ってカードは1111111111111111111111111159話2028年3月です あと前日役書の前な場合聞かれて1 2 3 4 5 6 7 8 9 01 2と答えたら読みありなくていいですと言われました気をしますそんな感じでしかとこらず中にですよ引っ込みがいします

電話番号は 074223456 と 090101234567 になってハイフンも桁数も崩れ、メールアドレスは しかおうどっとかもアットマックイグザンプルドっと込む と完全にカタカナ化、カード番号は 1111111111111111111111111159話、マイナンバーは 1 2 3 4 5 6 7 8 9 01 2 と空白区切りになっています。これでは、ハイフンや桁数を前提にした正規表現も、カード番号の Luhn チェックも使えません。地名も 7件7支映像市長4609番地 と全滅していて、社名の カモシカモバイル まで かもしかもある になりました。会員番号 NP2026000731 だけは意外と生き残っています。

このテキストを Unity Catalog のテーブルに載せます。文字起こし本文のほかに、氏名・電話番号・メールアドレスを別の列としても持たせておきます。カラムマスクや Data Classification は列に対して効く機能なので、自由記述の列と構造化列の両方で挙動を見たいからです。テーブルは pii_lab.intro.transcripts_raw で、列は speaker_id / name / phone / email / transcript です。

テーブル作成と INSERT の SQL
CREATE CATALOG IF NOT EXISTS pii_lab;
CREATE SCHEMA IF NOT EXISTS pii_lab.intro;

CREATE OR REPLACE TABLE pii_lab.intro.transcripts_raw (
  speaker_id STRING,
  name       STRING,
  phone      STRING,
  email      STRING,
  transcript STRING
);

INSERT INTO pii_lab.intro.transcripts_raw VALUES
  ('S-0001', '加茂 鹿男', '090-0123-4567', 'shikao.kamo@example.com',
   '初めましてかもしかおうと申しますかもしかおです1978年4月10日までの48歳でまれもすらちもならです中所は7件7支映像市長4609番地しっかりを配置2丸産合室です東大地下の大武線から歩いて5分ぐらいかすか大社の産道にも近い並行のどの中に住んでいます窓を与うとしかがいます 仕事はかもしかもあるという会社で本人や420年ほどやっています会社の直数点は074223456 期待は090101234567ですメラルですはしかおうどっとかもアットマックイグザンプルドっと込むです 観物にはKAMです趣味はしかの感染性なら公園仕方者会という会に入っています 会員番号はNP2026000731です年間平和3000度ピュカインで4000映画年に1度12袋とときます 人袋200円なのでまままもらっています最近赤くさやもの山焼きの出身を取るのにはまっていて今年の1月24日は朝5時から場所とりましょう 画面の脳年の12月に開催して仕方人使ってカードは1111111111111111111111111159話2028年3月です あと前日役書の前な場合聞かれて1 2 3 4 5 6 7 8 9 01 2と答えたら読みありなくていいですと言われました気をしますそんな感じでしかとこらず中にですよ引っ込みがいします');

ここから、「見せない・消す・見つける」の順に 1 つずつ見ていきます。

検証 1: カラムマスク(見せない)

一番シンプルな機能から。SQL の UDF(ユーザー定義関数)をマスク関数として定義し、列に紐づけます。テーブルを読むたびに UDF が実行され、読んだユーザーに応じて元の値かマスクした値が返ります。

-- 1. マスク関数を作る(管理グループ以外には下 4 桁だけ返す)
CREATE OR REPLACE FUNCTION pii_lab.intro.mask_phone(phone STRING)
RETURNS STRING
RETURN CASE
  WHEN is_account_group_member('pii_admins') THEN phone
  ELSE concat('***-****-', right(phone, 4))
END;

-- 2. 列に紐づける
ALTER TABLE pii_lab.intro.transcripts_raw
ALTER COLUMN phone SET MASK pii_lab.intro.mask_phone;

-- 3. 読んでみる
SELECT speaker_id, name, phone FROM pii_lab.intro.transcripts_raw;

is_account_group_member() はアカウントレベルのグループ所属を見る関数です。検証は pii_admins に入っていないサービスプリンシパルから流したので、マスクされる側の結果になります。

-- SET MASK 前
| speaker_id | name      | phone         |
| S-0001     | 加茂 鹿男 | 090-0123-4567 |

-- SET MASK 後
| speaker_id | name      | phone         |
| S-0001     | 加茂 鹿男 | ***-****-4567 |

テーブルの中身はそのままで、読むときだけ下 4 桁以外が伏せられました。別の列を関数の引数に渡す USING COLUMNS も試しています。メールアドレスの列に mask_email_by_name(email, name) を付けると ***@example.com になりました。DESCRIBE TABLE EXTENDED の末尾には # Column Masks という欄ができ、どの列にどの関数が付いているかが出ます。ALTER COLUMN phone DROP MASK で外すと、元の 090-0123-4567 に戻りました。

カラムマスクの制約もドキュメントから拾っておきます。

  • 1 列に紐づけられるマスクは 1 つだけ
  • 生成列(generated column)が参照している列にはマスクを付けられない
  • タイムトラベルはマスクと併用できない(マスクを付けた列は VERSION AS OF で過去のバージョンを読めない)。実際に VERSION AS OF 1 を投げると COLUMN_MASKS_FEATURE_NOT_SUPPORTED.TIME_TRAVEL で弾かれました
  • Delta Sharing 経由ではテーブル単位のマスクが効かない
  • マスク関数が複数列を参照したり複雑になると、クエリ性能に影響する

実運用で使うなら

マスク関数を専用スキーマ(例: governance.masks)にまとめてオーナーを絞り、出し分けの条件はアカウントグループで書きます。SCIM(ユーザーやグループを外部から同期する仕組み)で、Entra ID や Okta などの IdP(社員のアカウントを管理する認証基盤)のグループを同期しておけば、人が異動しても IdP 側でグループを直すだけで済みます。

小さく始めるには十分ですが、「テーブルが 200 個あって PII 列が 800 個ある」となると、1 列ずつ SET MASK するのは現実的ではないです。そこで次の ABAC です。

検証 2: ABAC ポリシー + ガバナンスタグ(見せない)

ABAC(属性ベースアクセス制御)は、列ではなくタグに対してマスクのルールを書く仕組みです。「pii = phone のタグが付いている列は、pii_admins 以外には mask_phone を通す」と一度書けば、スキーマ配下のテーブル全部に効きます。

条件に使うタグはガバナンスタグにする必要があります。ガバナンスタグは、アカウント単位で「使ってよいキーと値」と「誰が貼れるか」を管理するタグで、カタログエクスプローラーの Governed tags から作れます。今回はキー pii、許可する値 phone で作りました。SET TAGS で貼っただけの普通のタグを条件にすると、ポリシーを作る時点で弾かれます。

[INVALID_PARAMETER_VALUE.UC_INVALID_POLICY_CONDITION] Invalid condition in policy 'mask_phone_for_everyone'.
Compilation error with message 'Unknown tag policy key `pii`'.

ガバナンスタグを作ったら、列に貼ります。書き方は普通のタグと同じです。

ALTER TABLE pii_lab.intro.transcripts_raw
ALTER COLUMN phone
SET TAGS ('pii' = 'phone');

許可していない値は貼れません。

-- 'pii' = 'name' を貼ろうとした場合
[INVALID_PARAMETER_VALUE.UC_TAG_POLICY_VALUE_NOT_ALLOWED] Tag value name is not an allowed value for tag policy key pii. Allowed values: [phone].

そのタグに対してポリシーを書きます。

CREATE OR REPLACE POLICY mask_pii_phone
ON SCHEMA pii_lab.intro
COMMENT 'pii=phone タグの列を下 4 桁以外マスク'
COLUMN MASK pii_lab.intro.mask_phone
TO `account users`
FOR TABLES
MATCH COLUMNS has_tag_value('pii', 'phone') AS phone_col
ON COLUMN phone_col;

MATCH COLUMNS でタグを条件に列を拾い、ON COLUMN でその列にマスク関数を当てています。USING COLUMNS (4) のように定数や他の列を関数の追加引数として渡すこともできます。ポリシーは ON METASTORE / ON CATALOG / ON SCHEMA / ON TABLE のどの粒度にも置けます。

検証では、transcripts_raw.phone と、携帯番号だけを持つ別のテーブル contacts の mobile 列の両方に pii=phone を貼りました。列に直接マスクを付けていなくても、どちらもマスクされます。

テーブル 列 結果
transcripts_raw phone ***-****-4567
contacts mobile ***-****-4567
contacts(タグを剥がした後) mobile 090-0123-4567

タグを剥がすと、その場でマスクが外れて元の値が見えました。ABAC では「タグを貼れる・剥がせる人」が、そのまま「マスクを外せる人」になるということです。

実運用で使うなら

タグを貼る権限(ASSIGN)をデータオーナーのグループに絞ります。そうすれば、マスクを外せるのもそのグループだけになります。キーの設計も先に決めておきます。pii というキーに phone / name / my_number といった値を並べるか、Data Classification が使う class.phone_number などのシステム定義のタグを使うかです。ポリシーの対象も account users ではなく、TO analysts EXCEPT pii_admins のようにアカウントグループで書きます。

ABAC の行フィルタ・カラムマスクポリシーは GA、メタストア単位のポリシーは Beta です。コンピュートは Databricks Runtime 16.4 以上か serverless が必要です。

検証 3: ai_mask()(消す)

ここからは「消す」側です。AI Functions には、そのものずばりの ai_mask() があります。

SELECT ai_mask(
  'John Doe lives in New York. His email is john.doe@example.com.',
  array('person', 'email')
);
-- [MASKED] lives in New York. His email is [MASKED].

1 行で終わりです。列に対して流すのも同じで、並列化・リトライ・スケーリングは AI Functions 側がやってくれます。

SELECT
  speaker_id,
  ai_mask(transcript, array('person', 'phone', 'address', 'email')) AS masked
FROM pii_lab.intro.transcripts_raw;

気になるのは、公式ドキュメントに「英語向けにチューニングされている」と明記されていることです。以前の記事で問題にした、電話番号や会員番号の読みが崩れた日本語のログにどこまで効くのか。台本の表の 9 件と 3 種類で答え合わせします。

まずは指定するラベルを person phone address email の 4 つにして流しました。

初めまして[MASKED]うと申します[MASKED]です1978年4月10日までの48歳でまれもすらちもならです[MASKED]しっかりを配置2丸産合室です東大地下の大武線から(略)会社の直数点は[MASKED] 期待は[MASKED]ですメラルですはしかおうどっとかもアットマックイグザンプルドっと込むです(略)会員番号はNP2026000731です(略)

人名、住所の前半、電話番号 2 つは消えました。電話番号はハイフンも桁数も崩れているのに拾えていて、ここは正直意外でした。一方、メールアドレスはカタカナになった時点で email とは見なされていません。

ラベルを credit card national id membership id date of birth まで足して 8 つにすると、生年月日・会員番号・カード番号も消えるようになりました。ラベルは自由に書けるので、日本語のデータでも何を消したいかは英語で並べれば伝わるようです。結果を答え合わせ表にまとめます(✅ 消えた、△ 一部だけ消えた、❌ 残った)。

# 種類 文字起こし後の姿 ai_mask ai_query
1 人名 かもしかおう ✅ ❌
2 生年月日 1978年4月10日までの ✅ ✅
3 住所 7件7支映像市長4609番地しっかりを配置2丸産合室 △ 番地まで △ 4609番地 だけ
4 電話番号(会社) 074223456 ✅ ✅
5 電話番号(携帯) 090101234567 ✅ ✅
6 メールアドレス しかおうどっとかもアットマックイグザンプルドっと込む ❌ ❌
7 会員番号 NP2026000731 ✅ ✅
8 カード番号 1111111111111111111111111159話 △ 先頭 16 桁だけ ✅
9 マイナンバー 1 2 3 4 5 6 7 8 9 01 2 ❌ ❌

ai_mask() は 9 件中 5 件を消し、2 件は一部だけ消しました。カード番号は、先頭 16 桁を消して残りの 111111111159 を残しています。カード番号の桁数で切ったように見えます。過剰マスクは無しで、社名・団体名、東大寺や春日大社のような地名、金額や日付はどれも残りました。所要時間は 1 行で 7〜8 秒です。

比較のため、崩れる前の台本をそのまま渡すと、9 件すべてが [MASKED] になり、東大寺の大仏殿 は残りました。英語向けとはいえ、きれいな日本語なら十分に効きます。崩れた文字起こしで消せなかったのは、メールアドレスとマイナンバーのように表記そのものが別物になったものです。

ai_mask() の制約も並べておきます。

  • 置換文字列が [MASKED] 固定で、[PERSON] [PHONE] のようにラベル別に出し分けられない
  • Model Serving の課金がコンピュート費用に上乗せされる

ai_mask() は Public Preview で、AI Functions 対応リージョンでのみ使えます。Databricks SQL Classic では使えません。

実運用で使うなら

英語主体のログや「とりあえず全部 [MASKED] で良い」用途なら ai_mask() で十分です。ラベル別に出し分けたい、日本語で精度を詰めたい、となったら次の ai_query() に切り替えます。

検証 4: ai_query() で「列挙だけさせる」(消す)

ai_mask() で足りないとき、ローカル LLM の記事でやった「LLM には検出だけさせて、置換は決定的に行う」パターンを Databricks 上で再現します。

汎用の ai_query() に responseFormat で JSON スキーマを渡すと、構造化出力が返ってきます。

CREATE OR REPLACE TABLE pii_lab.intro.transcripts_entities AS
SELECT
  speaker_id,
  transcript,
  ai_query(
    'databricks-meta-llama-3-3-70b-instruct',
    concat(
      '次の日本語の自己紹介の文字起こしから個人情報を抽出してください。',
      '文字起こしの誤りで表記が崩れていても、文脈から個人情報と判断できるものは含めてください。',
      '原文の該当箇所を text にそのまま書き、書き換えないでください。\n\n',
      transcript
    ),
    responseFormat => '{
      "type": "json_schema",
      "json_schema": {
        "name": "pii_entities",
        "schema": {
          "type": "object",
          "properties": {
            "entities": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "text": {"type": "string"},
                  "label": {"type": "string", "enum": ["PERSON", "DATE_OF_BIRTH", "PHONE", "ADDRESS", "EMAIL", "MEMBER_ID", "CARD", "MY_NUMBER"]}
                },
                "required": ["text", "label"]
              }
            }
          },
          "required": ["entities"]
        },
        "strict": true
      }
    }'
  ) AS entities_json
FROM pii_lab.intro.transcripts_raw;

モデルは databricks-meta-llama-3-3-70b-instruct を使いました。ワークスペースの「サービング」画面には、ほかに gpt-oss-120b や qwen3-next-80b-a3b-instruct、llama-4-maverick なども並んでいるので、使える基盤モデルの名前に読み替えてください。返ってきた JSON は responseFormat で指定したスキーマどおりで、パースに失敗することはありませんでした。所要時間は 1 行で約 7 秒です。

{"entities": [
  {"text": "1978年4月10日", "label": "DATE_OF_BIRTH"},
  {"text": "4609番地", "label": "ADDRESS"},
  {"text": "074223456", "label": "PHONE"},
  {"text": "090101234567", "label": "PHONE"},
  {"text": "NP2026000731", "label": "MEMBER_ID"},
  {"text": "1111111111111111111111111159", "label": "CARD"},
  {"text": "2028年3月", "label": "DATE_OF_BIRTH"}
]}

答え合わせ表の ai_query 列のとおり、9 件中 5 件を拾い、住所は 4609番地 だけでした。ai_mask() が消せた人名の かもしかお は拾えていません。カード番号は 28 桁を丸ごと拾っていて、ここは ai_mask() より良い結果です。カードの有効期限 2028年3月 は DATE_OF_BIRTH として拾われました。有効期限はマスクされても構わない扱いにしていたので過剰マスクには数えませんが、ラベルは間違っています。LLM の出力は実行のたびに変わりうるので、これは 1 回流した結果です。

返ってきた entities_json を使って、置換は LLM に任せずに自分でやります。ここは Python(pandas UDF)で書くのが楽です。

import json
import pandas as pd
from pyspark.sql.functions import pandas_udf

@pandas_udf("string")
def apply_mask(transcript: pd.Series, entities_json: pd.Series) -> pd.Series:
    def mask_one(text: str, raw: str) -> str:
        entities = json.loads(raw)["entities"]
        # 長い文字列から先に置換して、部分一致による取りこぼしを防ぐ
        for e in sorted(entities, key=lambda e: -len(e["text"])):
            if e["text"]:
                text = text.replace(e["text"], f"[{e['label']}]")
        return text
    return pd.Series([mask_one(t, r) for t, r in zip(transcript, entities_json)])

(spark.table("pii_lab.intro.transcripts_entities")
      .withColumn("transcript_masked", apply_mask("transcript", "entities_json"))
      .drop("transcript", "entities_json")
      .write.mode("overwrite")
      .saveAsTable("pii_lab.intro.transcripts_masked"))

検証では SQL Warehouse だけで済ませたかったので、同じ置換を SQL の高階関数でも書きました。こちらで実際に動かしています。

CREATE OR REPLACE TABLE pii_lab.intro.transcripts_masked AS
WITH ents AS (
  SELECT speaker_id, transcript,
         -- 長い文字列から先に置換する
         array_sort(
           from_json(get_json_object(entities_json, '$.entities'), 'ARRAY<STRUCT<text: STRING, label: STRING>>'),
           (a, b) -> CASE WHEN length(a.text) > length(b.text) THEN -1
                          WHEN length(a.text) < length(b.text) THEN 1 ELSE 0 END
         ) AS entities
  FROM pii_lab.intro.transcripts_entities
)
SELECT speaker_id,
       aggregate(entities, transcript,
                 (acc, e) -> replace(acc, e.text, concat('[', e.label, ']'))) AS transcript_masked
FROM ents;

結果はこうなりました。

初めましてかもしかおうと申しますかもしかおです[DATE_OF_BIRTH]までの48歳でまれもすらちもならです中所は7件7支映像市長[ADDRESS]しっかりを配置2丸産合室です(略)会社の直数点は[PHONE] 期待は[PHONE]です(略)会員番号は[MEMBER_ID]です(略)カードは[CARD]話[DATE_OF_BIRTH]です あと前日役書の前な場合聞かれて1 2 3 4 5 6 7 8 9 01 2と答えたら(略)

ai_mask() と比べて、どの種類の情報を消したのかがラベルで残るのが良いところです。

LLM に原文を書き換えさせない理由は、ローカル LLM の記事と同じです。全文を出力させると、勝手に言い換えたり、一部を落としたり、ありもしない文を足したりします。「該当箇所を列挙するだけ」にしておけば、置換の正しさはこちら側で保証できます。

実運用で使うなら

この抽出 → 置換を Lakeflow のジョブにして、メダリオンアーキテクチャ(生データの bronze、整えた silver、分析用の gold と段階を分ける構成)の bronze → silver のところに組み込みます。モデルはより大きいものに替えたり、プロビジョンドスループットのエンドポイントで動かしたりできますし、ローカル LLM の記事の監査ループ(マスク後のテキストをもう一度 LLM に見せて取りこぼしを探す)もそのまま ai_query() で書けます。

Data Classification(見つける)

最後が「見つける」です。Data Classification は、カタログ単位で有効化すると、エージェントがテーブルの列を走査して「この列は氏名」「この列はメールアドレス」といった class.* タグを自動で貼ってくれる機能です。検証環境では有効化できなかったので、この章はドキュメントをもとにした紹介です。

全体の流れ

Data Classification の流れ。カタログで有効化し、列をスキャンして class.* タグを貼る。結果は画面で確認でき、タグを条件にした ABAC ポリシーで自動マスクされる

有効化はカタログエクスプローラーから、カタログ単位で行います。対象カタログの MANAGE 権限と serverless が必要です。初回は配下のテーブルをまとめてスキャンし、以降はテーブルが増えたり更新されたりした分だけを差分でスキャンします。

どんなタグが貼られるか

検証環境で databricks tag-policies list-tag-policies を叩くと、class.* のタグ定義が 94 種類並んでいました。ざっくり分けるとこうなります。

種類 タグの例
基本の個人情報 class.name / class.email_address / class.phone_number / class.date_of_birth / class.location
決済 class.credit_card / class.card_expiration_date / class.card_security_code / class.iban_code
国別の公的 ID class.jp_my_number / class.jp_pension_number / class.us_ssn / class.uk_nino など
要配慮情報 class.health_data / class.religious_belief / class.political_opinion など
ネットワーク・機器 class.ip_address / class.mac_address / class.imei

日本にデプロイしたワークスペースでは class.jp_my_number(マイナンバー)と class.jp_pension_number(基礎年金番号)が使えます。 Presidio の記事でマイナンバーの検出に手こずったのを思い出すと、これは素直にありがたいです。カードの有効期限を表す class.card_expiration_date まであり、種類はかなり細かく分かれています。

検証用のテーブルに当てはめると

今回のテーブルで有効化した場合、こんなイメージになるはずです(実際には動かしていないので想定です)。

列 中身 貼られそうなタグ
name 加茂 鹿男 class.name
phone 090-0123-4567 class.phone_number
email shikao.kamo@example.com class.email_address
transcript 崩れた文字起こしの全文 未確認(自由記述の列への付き方は試せていない)

貼られたタグは、普通のタグと同じく information_schema から引けます。

SELECT table_name, column_name, tag_name
FROM system.information_schema.column_tags
WHERE catalog_name = 'pii_lab'
  AND tag_name LIKE 'class.%';

ABAC とつなぐ

Data Classification の結果は、ABAC と組み合わせて使います。class.* もガバナンスタグなので、検証 2 と同じ書き方でポリシーの条件にできます。

Data Classification が phone 列に class.phone_number を貼り、そのタグを条件にした ABAC ポリシーで analysts には -*-4567 と見える(値はダミー)

CREATE OR REPLACE POLICY mask_phone_by_classification
ON SCHEMA pii_lab.intro
COLUMN MASK pii_lab.intro.mask_phone
TO `account users`
FOR TABLES
MATCH COLUMNS has_tag('class.phone_number') AS phone_col
ON COLUMN phone_col;

このポリシー自体は検証環境でも作成でき、SHOW POLICIES にも出ました。あとは Data Classification がタグを貼れば、人がタグを付けなくても「見つけたら自動でマスクがかかる」状態になります。

押さえておくこと

  • 初回スキャンはコストが大きく、以降は差分スキャンになる。system.billing.usage の billing_origin_product = 'DATA_CLASSIFICATION' で追える
  • 誤検出は結果画面で除外でき、除外するとそのタグは剥がれて以降の精度に反映される

通常のビューのスキャンは Beta で、ワークスペース管理者による有効化が必要です。

実運用で使うなら

上の ABAC との組み合わせを前提に、新しいテーブルが増えても自動でマスクがかかる状態を作ります。日本リージョンのワークスペースなら class.jp_my_number が効くので、マイナンバー列の見落とし検知にも使えます。人が貼る pii=* のタグと併用する場合は、同じ列に複数のマスクが当たらないよう、どちらのタグを条件にするかを決めておきます。

検証で確認したことと、実運用で足すもの

機能 分類 検証で確認した挙動 実運用で足すもの
カラムマスク 見せない 読み取り時だけ下 4 桁以外が伏せられる。タイムトラベルは弾かれる。DROP MASK で戻る マスク関数の集約スキーマ、SCIM 同期のアカウントグループ
ABAC ポリシー 見せない ガバナンスタグ pii=phone が付いた列が、テーブルをまたいで自動でマスクされる。タグを剥がすと外れる タグを貼る権限(ASSIGN)の統制、キーの設計
ai_mask() 消す 崩れた日本語で 9 件中 5 件 + 一部 2 件。ラベルを英語で足せば効く。過剰マスク無し 対応リージョンの確認、英語主体データへの適用範囲の線引き
ai_query() + 決定的置換 消す 9 件中 5 件 + 一部 1 件。JSON は壊れない。ラベル付きで置換できる Lakeflow ジョブ化、モデル選定、監査ループ
Data Classification 見つける (検証環境では有効化できず未確認) 日本リージョンでの class.jp_*、class.* を ABAC 条件に接続

今回の機能を活かすなら、こんな構成を組むと良いかもしれません。

  1. 生データ(bronze)は ABAC ポリシーで、担当者グループ以外には全文マスクして返す
  2. ai_query() で個人情報を列挙させ、Python で決定的に置換した silver テーブルを作る
  3. 分析者には silver 以降だけ権限を渡す
  4. Data Classification を bronze / silver 両方に回し、silver に取りこぼしがないかを監査する

bronze の生データは ABAC で見せず、ai_query で消した silver 以降だけを分析者に渡し、Data Classification で bronze と silver を監査する構成

「消す」処理をローカル LLM でやるか Databricks 上の基盤モデルでやるかは、コストとデータの持ち出し可否で決まります。データが Databricks から出せないなら、そもそもローカル LLM の選択肢がなく、ai_query() 一択です。

まとめ

Databricks の PII マスキング機能を、Free Edition を検証環境にして一通り確かめました。押さえておきたいのは次の 3 つです。

  • 「見せない」と「消す」は別物。 生データは ABAC で見せず、分析用の派生テーブルにだけ「消す」を使う
  • 「消す」で LLM を使うなら、検出だけさせて置換は自分でやる。 ai_mask() は英語向けなので、日本語の崩れたログは ai_query() + 決定的置換で組む
  • Data Classification は監査役。 日本向けのマイナンバー・基礎年金番号タグがあり、貼ったタグをそのまま ABAC ポリシーの条件に使える

検証で確認できたのは「仕組みがそう動く」ところまでで、実運用では、その上にタグを貼る権限の統制、アカウントグループ、ジョブ化といった運用の仕組みを足すことになります。ただ、どの機能をどう組み合わせるかは、無料の Free Edition で手を動かすだけでも十分に判断できました。

今後は、個人情報の保護方法を、ほかのクラウドサービスやデータ分析基盤とも比べてみたいです。

参考リンク

https://docs.databricks.com/aws/en/getting-started/free-edition-limitations

https://docs.databricks.com/aws/en/tables/row-and-column-filters

https://docs.databricks.com/aws/en/data-governance/unity-catalog/abac/policies

https://docs.databricks.com/aws/en/sql/language-manual/functions/ai_mask

https://docs.databricks.com/aws/en/sql/language-manual/functions/ai_query

https://docs.databricks.com/aws/en/data-governance/unity-catalog/data-classification

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?