はじめに こんにちは 開発本部 開発1部 デリッシュリサーチでデータエンジニアをしている吉田です。 今回は、Unity CatalogのData Classificationで機密データを含むカラムを自動検出し、付与されたタグをもとにABACのcolumn maskポリシーでマスクするまでを試した話を紹介します。 注意点 本記事の検証に使ったデータは、すべてFakerで生成した架空のものです。 実在する人物や実環境のデータは含んでいません。 また、テストに使ったテーブルは検証用に新しく作ったものです。 Data Classificationは インテリジェントなスキャンでコストを最適化 とあるため、本番のテーブルでは異なる間隔でスキャンされる可能性があります。 背景 データ基盤には、メールアドレスや氏名のような機密データを含むカラムが含まれる可能性があります。 分析に使う人が増えるほど、こうしたカラムは必要な人以外にはマスクして見せる必要があります。 しかし、カラムごとに人手で対応する運用には以下のような辛さがあります。 テーブルが増えるたびに、機密データを含むカラムを人が探して印を付ける必要がある カラム名から中身が判断できないカラム(自由記述のメモなど)で抜けが出る 印を付けたあと、カラムごとにマスクの設定を追従させる必要がある そこで、機密データの検出とタグ付けを自動化するData Classificationと、タグをもとにマスクを一括適用できるABACを組み合わせ、人手を介さずにマスクまで処理できるかを調査しました。 Data Classificationとは Data Classificationは、カタログ配下のテーブルをAIエージェントがスキャンし、機密データを含むカラムを検出してgoverned tagを付ける機能です。 https://docs.databricks.com/aws/ja/data-governance/unity-catalog/data-classification カタログ単位で有効化し、スキャン対象のスキーマを絞れる 検出できるクラスは class.email_address や class.name など90種類以上ある 日本向けには class.jp_my_number (マイナンバー)と class.jp_pension_number (基礎年金番号)がある 検出結果はsystemテーブル system.data_classification.results にカラム単位で保存される 確信度(HIGH / LOW)と一致したレコードの割合を持つ 新しいテーブルやカラムは、通常は作成から24時間以内にスキャンされる 自動タグ付けを有効にする前に検出済みだったカラムは次のスキャンでタグが付き、それ以降の検出には検出と同時にタグが付く 既存テーブルへのデータ追加がいつスキャンされるかは、ドキュメントに記述がない スキャンにはサーバレスコンピュートが必要 ABACのcolumn maskポリシーとは ABAC(属性ベースのアクセス制御)は、governed tagを条件にしてrow filterやcolumn maskをカタログ、スキーマ、テーブルの単位で一括適用する仕組みです。 https://docs.databricks.com/aws/ja/data-governance/unity-catalog/abac/ ポリシーは CREATE POLICY で作成し、 MATCH COLUMNS has_tag('class.email_address') のようにタグで対象カラムを指定します。 カラムごとにマスク関数を紐付ける方式と違い、ポリシーを一度書けばあとから追加されたテーブルにも適用されます。 Data Classificationが付ける class.* のタグをそのまま条件に書けるため、検出からマスクまでを自動化できます。 検証の流れ 検証は以下の流れで行いました。 合成データ Fakerで生成した架空の会員データやイベントログを、検証専用のカタログにテーブルとして作成する ABACポリシー カラムの型ごとにマスク関数とポリシーを用意し、手動でタグを付けたカラムで動作を確認する Data Classification カタログで有効化してスキャンし、自動で付いたタグでポリシーが有効になることを確認する ドキュメント上はスキャンまで最大24時間かかるため、ポリシー側を先に手動タグで確認する順序にしています。 テストデータ 以下の9テーブルを合成データで作成しました。 値はすべて Faker('ja_JP') とseed固定の乱数で生成しています。 テーブル レコード数 確かめたいこと 値の例 users_plain 200 漢字やかなの氏名、全角の電話番号、都道府県から始まる住所、郵便番号といった日本語の値が検出されるか。ハッシュ化済みのメールカラムを誤検出しないか name : 坂本 加奈、 name_kana : タナカ ユウタ、 phone_zenkaku : 090-0000-3891、 address : 千葉県葛飾区芝浦14丁目11番7号、 email_sha256 : a56e5ab9…(64桁) events_nested 200 map や struct 、 array の中に入った機密データが検出されるか event_params : {"email": {"string_value": "rikamatsumoto@example.com", "int_value": null}, "count": {"string_value": null, "int_value": 3}} 、 geo : {"country": "日本", "region": "神奈川県", "city": "横浜市磯子区"} 、 items : [{"item_name": "サラダ", "buyer_email": "rikamatsumoto@example.com"}] misleading 200 カラム名が email で中身が整数のカラムや、自由記述のメモに紛れたメールアドレスをどう扱うか email : 142188、 memo : 配送先変更の依頼。担当は鈴木 拓真さん、連絡先pyamada@example.org。 users_sparse 500 8割がNULLのカラムでも検出されるか email : 8割がNULL、 birthday : 6割がNULLで、 0001-01-01 のような異常値も5%混ぜる jp_numbers 200 マイナンバーと基礎年金番号の形式が日本向けクラスとして検出されるか。マイナンバーは検査用数字の正誤で検出が変わるか my_number_valid : 207016914928、 my_number_invalid : 検査用数字だけ誤った12桁、 my_number_mixed : 半分のレコードだけ検査用数字が正しい12桁、 pension_number : 2419-269849 ratio_test 1,000 メールの混入率が1レコード、1%、5%、20%と低いカラムでも検出されるか one_in_1000 : 999レコードが「問題なし」などの普通の文字列で1レコードだけメール、 one_in_1000_null : 999レコードがNULLで1レコードだけメール late_arrival 100 有効化したあとに追加したテーブルがいつスキャンされるか。スキャン後にカラムを追加した場合に再評価されるか contact_email 、 full_name 、あとから追加した phone ratio_1m 1,000,000 実データ相当のレコード数で、混入率が1レコード、10レコード、0.01%、0.1%、1%のカラムが検出されるか p0001 : 100レコードだけ user979104@example.com のようなメールで、残りは普通の文字列 names_test 200 氏名の表記(よくある名前の「姓 名」、スペース無し、姓のみ、名のみ、ローマ字、「様」付き、ひらがな)で検出が変わるか common_space : 山田 舞、 romaji : Mikako Sasaki、 hiragana : はせがわ まあや メールアドレスは example.com などの予約ドメイン、電話番号は 090-0000-XXXX の帯に固定し、実在の値と衝突しないようにしています。 Fakerは項目ごとに独立した値を返すため、かなは漢字の氏名と対応せず、住所も実在しない都道府県と市区町村の組み合わせになります。 マスク関数とポリシーの作成 まず、 users_plain の email と birthday 、 jp_numbers の my_number_valid に手動でタグを付け、ポリシーの動作を確認します。 ALTER TABLE poc_data_classification.synthetic.users_plain ALTER COLUMN email SET TAGS ( ' class.email_address ' ); ポリシーが呼ぶ関数の引数の型は、対象カラムの型と一致している必要があるため、マスク関数はカラムの型ごとに用意します。 CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_string(v STRING) RETURNS STRING RETURN CASE WHEN v IS NULL THEN NULL WHEN v LIKE ' %@% ' THEN concat ( ' ***@ ' , substring_index(v, ' @ ' , -1 )) ELSE ' *** ' END ; ポリシーもクラスごとではなく、カラムの型ごとに作ります。 同じユーザーの同じカラムに異なるcolumn maskが解決されると、クエリがエラーになるためです。 同じ関数を同じ引数で呼ぶポリシーであれば、クラスごとに分けて1つのカラムに複数当たっても問題なく読めました。 それでも、自由記述のカラムには複数のクラスが付くことがあるので、STRINGカラム向けのクラスは OR でまとめて1本のポリシーにし、1つのカラムに解決されるマスクを常に1つにしています。 CREATE OR REPLACE POLICY mask_pii_string ON SCHEMA poc_data_classification.synthetic COLUMN MASK poc_data_classification.synthetic.mask_string TO `account users` FOR TABLES MATCH COLUMNS ( has_tag( ' class.email_address ' ) OR has_tag( ' class.phone_number ' ) OR has_tag( ' class.name ' ) OR has_tag( ' class.location ' ) OR has_tag( ' class.jp_my_number ' ) OR has_tag( ' class.jp_pension_number ' ) ) AS c ON COLUMN c; マスクの確認 ポリシーを作成した直後にSELECTすると、反映を待つことなくタグを付けたカラムだけがマスクされていました。 user_id | email | name | birthday --------+-----------------+-----------+----------- 1 | ***@example.net | 坂本 加奈 | 1900-01-01 2 | ***@example.com | 石井 陽一 | 1900-01-01 ポリシーはスキーマの所有者である自分自身にも適用されます。 次に、同じポリシーに EXCEPT で管理者グループを追加しました。 TO `account users` EXCEPT `unity-catalog-admins` 置き換えた直後に同じクエリを流すと、管理者グループに属する自分には素の値が返りました。 user_id | email | name | birthday --------+-----------------------------+-----------+----------- 1 | miturukobayashi@example.net | 坂本 加奈 | 1981-04-27 2 | naoto63@example.com | 石井 陽一 | 1972-05-31 なお、column maskポリシーが付いたテーブルではタイムトラベルが使えません。 VERSION AS OF を指定したクエリは以下のエラーになりました。 [COLUMN_MASKS_FEATURE_NOT_SUPPORTED.TIME_TRAVEL] Column mask policies for `poc_data_classification`.`synthetic`.`users_plain` are not supported: Time travel for table with column mask policies Data Classificationの有効化 手動で付けたタグを外し、Data Classificationを有効化します。 有効化はUIのほか、Databricks CLIの data-classification コマンドからも行えます。 スキャン対象を検証用のスキーマだけに絞るため、CLIで included_schemas を指定しました。 databricks data-classification create-catalog-config catalogs/poc_data_classification \ --json '{"included_schemas": {"names": ["synthetic"]}}' 有効化しただけでは検出は行われますが、タグは自動では付きません。 返ってきた設定の auto_tag_configs は空で、自動タグ付けはクラスごとに有効にする必要がありました。 databricks data-classification update-catalog-config \ catalogs/poc_data_classification/config auto_tag_configs \ --json '{"auto_tag_configs": [ {"classification_tag": "class.email_address", "auto_tagging_mode": "AUTO_TAGGING_ENABLED"}, ... ]}' このとき、governed tagとして存在する class.* のキーをすべて指定すると、 class.de_passport が検出クラスとして見つからないというエラーになりました。 governed tagのキーの一覧と検出できるクラスの一覧は一致しないため、 サポートされる分類タグ をもとに指定する必要があります。 検出結果 検出されたカラムと検出されなかった主なカラムを、確かめたかったことごとに整理します。 確信度はすべてHIGHでした。 確かめたかったこと カラム 検出クラス 一致率 結果 メールアドレス email class.email_address 1.0 検出 漢字の氏名 name class.name 0.04 検出 カナの氏名 name_kana class.name 0.27 検出 電話番号(ハイフン、数字のみ、全角) phone_* class.phone_number 1.0 3カラムとも検出 郵便番号、都道府県から始まる住所 postcode , address class.location なし, 0.04 検出 生年月日 birthday class.date_of_birth なし 検出 メールアドレスのSHA-256 email_sha256 なし 検出されず マスク済みメール( m***@example.net ) email_masked class.email_address 1.0 検出。3回目のスキャンでは検出されず カラム名が email で中身が整数 misleading.email なし 検出されず 自由記述のメモ misleading.memo class.email_address , class.phone_number , class.name 0.35, 0.33, 0.08 3クラスを検出 map の中のメール event_params class.email_address 1.0 検出 struct の中の地域名 geo class.location なし 検出 array<struct> の中のメール items class.email_address 1.0 検出 8割がNULLのメール users_sparse.email class.email_address 0.19 検出 1,000レコード中1レコードだけメール(残りは普通の文字列) ratio_test.one_in_1000 class.email_address 0.001 検出 1,000レコード中1レコードだけメール(残りはNULL) ratio_test.one_in_1000_null class.email_address 0.001 検出 1,000レコード中1%、5%、20%がメール ratio_test.p01 , p05 , p20 class.email_address 0.01, 0.05, 0.2 検出 100万レコード中1%、0.1%、0.01%がメール ratio_1m.p01 , p001 , p0001 class.email_address 0.0103, 0.0012, 0.0001 検出。0.01%のカラムは3回目のスキャンでは検出されず 100万レコード中10レコード、1レコードだけメール ratio_1m.ten_in_1m , one_in_1m なし 検出されず よくある名前だけの「姓 名」 names_test.common_space class.name なし 検出 姓名(スペース無し)、姓のみ、名のみ、「様」付き、ひらがな names_test.* class.name 0.15, 0.01, なし, 0.02, 0.17 すべて検出 ローマ字の氏名 names_test.romaji class.name 0.99 検出 スキャン後に追加したカラムの電話番号 late_arrival.phone class.phone_number 1.0 次の日次スキャンで検出 スキャン後に追加したレコードのメール misleading.status , jp_numbers.memo class.email_address 0.5, 0.33 日次スキャンでは再評価されず、フルスキャンで検出 マイナンバー(検査用数字がすべて正しい) jp_numbers.my_number_valid class.jp_my_number 1.0 検出 マイナンバー(半分だけ正しい) jp_numbers.my_number_mixed class.jp_my_number 0.5 検出 マイナンバー(すべて誤り) jp_numbers.my_number_invalid なし 検出されず 基礎年金番号形式 pension_number class.jp_pension_number 1.0 検出 会員IDや整数のコードカラム、作成日時には何も付きませんでした。 タグはネストの中のフィールドではなく、カラムそのものに付きます 100万レコードの表では、全レコードではなくサンプルを見ています 一致率が混入率からずれ、100レコードのカラムで保存されたサンプル値は1件でした。1万レコード前後を抜き出して判定していそうです 検出結果はスキャンのたびに変わることがあります 100万レコード中0.01%のカラムとマスク済みメールのカラムは、3回のスキャンのうち最後のフルスキャンで検出から外れました 検出から外れても、付いていたタグはそのまま残りました。誤検出で付いたタグも自然には消えず、外すのは人の作業になりそうです 一致率は、見たレコードのうち検出器が一致したレコードの割合です 日付や struct のカラム、よくある名前だけの氏名のカラムではNULLでした マイナンバーは、検査用数字(12桁目のチェックデジット)まで検証されています 検査用数字が正しいカラムは一致率1.0、半分だけ正しいカラムは0.5で、すべて誤ったカラムは検出されませんでした 12桁の数字が並んでいるだけでは class.jp_my_number は付きません 自動タグでのマスクの確認 自動で付いたタグで、もう一度各テーブルをSELECTしました。 users_plain では検出された10カラムがすべてマスクされ、ハッシュカラムだけが残りました。 misleading.memo には3つのクラスが付きましたが、STRINGカラム向けのクラスを1本にまとめてあるので衝突せずにマスクされました。 一方、 events_nested は、タグの付いていないカラムだけを選ぶクエリでも失敗しました。 [DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION] Cannot resolve "poc_data_classification.synthetic.mask_string(event_params)" due to data type mismatch: cannot cast "STRING" to "MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>>". スキーマに置いたSTRING向けのポリシーが map 型の event_params にも解決され、関数の引数の型が合わないためです。 クエリで選んだカラムに関係なく、テーブル全体が読めなくなります。 MATCH COLUMNS に使える関数はカラムのタグを見る has_tag と has_tag_value だけで、カラムの型で振り分ける方法はありません。 そこで、ネスト型のカラムを持つテーブルを別扱いにしました。 振り分け用のgoverned tag( abac_profile )を作り、 events_nested に付ける スキーマのポリシーに WHEN NOT has_tag('abac_profile') を足し、このテーブルを除外する events_nested にはテーブル単位で、 map 型と struct 型それぞれに合う関数のポリシーをつける CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_params( v MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>> ) RETURNS MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>> RETURN transform_values( v, (k, x) -> named_struct( ' string_value ' , CASE WHEN x.string_value IS NULL THEN NULL ELSE ' *** ' END , ' int_value ' , x.int_value ) ); CREATE OR REPLACE POLICY mask_nested_params ON TABLE poc_data_classification.synthetic.events_nested COLUMN MASK poc_data_classification.synthetic.mask_params TO `account users` FOR TABLES MATCH COLUMNS has_tag( ' class.email_address ' ) AS c ON COLUMN c; この構成で events_nested が読めるようになり、 event_params の文字列値と geo の地域名がマスクされました。 event_params: {"email":{"string_value":"***","int_value":null},"page":{"string_value":"***","int_value":null},"count":{"string_value":null,"int_value":"3"}} geo: {"country":"日本","region":"***","city":"***"} ただし、 items ( array<struct> )のカラムは守れませんでした。 このカラムにも class.email_address が付いていましたが、 map 型の event_params と同じクラスなので、テーブル単位のポリシーでも1本の関数では型が合いません。 同じクラスのタグが型の違うカラムに付いたテーブルでは、そのクラスのポリシーを書けないことになります。 今回は items のタグを外しましたが、本番でこの形のテーブルを扱うなら、ネストを展開したテーブルを別に用意するか、テーブルごとアクセスを絞る必要がありそうです。 なお、スキーマのポリシーとテーブルのポリシーが同じカラムに重なった状態では以下のエラーになります。 どのポリシーがどのカラムで衝突したかがすべて書かれているため、切り分けは容易でした。 [COLUMN_MASKS_FEATURE_NOT_SUPPORTED.MULTIPLE_MASKS] ... resulting in multiple column masks mask_params(event_params), mask_string(event_params), mask_geo(geo), mask_string(geo) applying to the same column(s) event_params, geo. 現時点での使用感 日本語の氏名や住所、電話番号などは問題なく検出され、ネストした型の中身まで見てカラムにタグが付きました。 大きなテーブルではサンプルで判定するため、混入がごく少ないカラムは見落とされ、スキャンのたびに結果が変わることもありますが、それ以外で検出の精度に困る場面はありませんでした。 しかし、テーブルやカラム、レコード追加後からスキャンされるまでの間に一定の時間がかかるため、機密データが一定期間露出する可能性があります。 そのため任意タイミングでの再スキャンを行いたいですが、現時点ではUI経由でしか実行できないため、運用上の不便な点となります。 コストに関してはサーバレスジョブコンピュートのDBUがかかります。 Data Classification固有のコストはありませんが、スキャン対象のテーブル数やレコード量によってコストが増加すると思います。 まとめ Data Classificationで検出したカラムにABACのポリシーが適用されるところまで、人手でタグを付けることなく処理できました。 日本語の値やネストした型の中身も検出され、大きなテーブルでのサンプリングによる見落としを除けば、検出の精度で困ることはなさそうだと感じました。 設計で考えることが残るのはポリシーの側で、カラムの型ごとにポリシーを分けることと、ネスト型のカラムを持つテーブルの扱いが必要になります。