
Snowflakeのタグベースマスキングで部分マスクを検証してみた
かわばたです。
本記事では、Snowflake のタグベースマスキングポリシーで SYSTEM$GET_TAG_ON_CURRENT_COLUMN を使い、タグの値によって部分マスクの粒度を切り替える構成を検証します。
注意: 本記事は2026年8月21日時点の検証結果です。
【追記】
Snowflake Community Awards の「RISING COMMUNITY LEADER OF THE YEAR」部門・APJ枠のファイナリストに選ばれました。
下記より詳細ご確認ください。
部分マスクの概要
粒度を落として見せるという考え方
全体を隠すか全体を見せるかの二択ではなく、粒度を落として見せるのが部分マスクです。誕生日の場合は次の段階に分かれます。
| 粒度 | 出力 | 分析上の利用 | 再識別リスク |
|---|---|---|---|
| 素の値 | 2001-03-26 |
正確な年齢計算 | 高い |
| 年月まで | 2001-03-01 |
年齢の概算・月別分析 | 低減できる |
| 年まで | 2001-01-01 |
年代・年齢帯の分析 | さらに低減 |
| 年代まで | 2000-01-01 |
年代別分析 | 低い |
| 完全マスク | 1900-01-01 |
分析不可 | 最も低い |
日を失うため満年齢の厳密な算出はできなくなりますが、日単位の正確な年齢計算が不要で、年代別・年齢帯別の分析が要件であれば、日を潰すだけで満たせます。実装は DATE_TRUNC で済みます。
DATE_TRUNC('MONTH', VAL) -- 2001-03-26 -> 2001-03-01
DATE_TRUNC('YEAR', VAL) -- 2001-03-26 -> 2001-01-01
タグの値で粒度を切り替える
粒度ごとにポリシーを分けると、列が増えるたびに貼り替え作業が発生します。そこで SYSTEM$GET_TAG_ON_CURRENT_COLUMN を使います。これは、評価中の列に付与されたタグの値をマスキングポリシーの中から読み取る関数です。ポリシー1本の CASE 式でタグの値によって分岐できます。
CASE
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('...DATA_CLASS') = 'PII_YEARMONTH'
THEN DATE_TRUNC('MONTH', VAL)
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('...DATA_CLASS') = 'PII_YEAR'
THEN DATE_TRUNC('YEAR', VAL)
ELSE DATE '1900-01-01'
END
この構成であれば、運用側の作業は列へのタグ付与だけになります。粒度を変更する場合もタグの値を書き換えるだけで、ポリシーの中身を触る必要はありません。
タグの値が粒度のカタログになる
1つのタグに設定できるマスキングポリシーはデータ型ごとに1本です。そのため VARCHAR 列は電話番号もメールアドレスも住所も同じ MASK_TEXT で処理することになり、ポリシー側では列の属性を判別できません。タグの値の名前で属性の種類まで表現しておきます。
今回は次のように定義しました。
| タグの値 | 対象型 | 挙動 |
|---|---|---|
PUBLIC |
すべて | マスクしない |
UNCLASSIFIED |
すべて | 完全マスク (未分類のため安全側に倒す) |
PII |
すべて | 完全マスク |
PII_YEARMONTH |
DATE | 年月まで見せる |
PII_YEAR |
DATE | 年まで見せる |
PII_PHONE_LAST4 |
VARCHAR | 末尾4桁だけ見せる |
PII_EMAIL_DOMAIN |
VARCHAR | ドメインだけ見せる |
PII_ADDR_CITY |
VARCHAR | 最初の「市」まで残す (「市」を含まない住所は完全マスク) |
PII_AMOUNT_BAND |
NUMBER | 10万円単位に丸める |
タグの ALLOWED_VALUES が、組織で認めた粒度のカタログとして機能します。ここにない値は付与できないため、現場で独自の粒度が増えることも防げます。
前提条件
- Snowflake Enterprise Edition 以上が必要です
- タグとポリシーの作成、ロール付与のため
ACCOUNTADMIN相当のロールを使用します - 検証は AWS 東京リージョンのトライアルアカウント (Enterprise Edition) で実施しました
事前準備
専用のデータベース BLOG_MASK_DB を作成し、既存環境に影響しない形で完結させます。
1. タグとポリシーの置き場所を作る
USE ROLE ACCOUNTADMIN;
USE WAREHOUSE COMPUTE_WH;
CREATE OR REPLACE DATABASE BLOG_MASK_DB;
CREATE OR REPLACE SCHEMA BLOG_MASK_DB.GOV; -- タグとポリシー
CREATE OR REPLACE SCHEMA BLOG_MASK_DB.MART; -- 検証データ
CREATE OR REPLACE TAG BLOG_MASK_DB.GOV.DATA_CLASS
ALLOWED_VALUES
'PUBLIC',
'UNCLASSIFIED',
'PII',
'PII_YEARMONTH',
'PII_YEAR',
'PII_PHONE_LAST4',
'PII_EMAIL_DOMAIN',
'PII_ADDR_CITY',
'PII_AMOUNT_BAND'
COMMENT = '部分マスクの粒度を表すタグ';
ALLOWED_VALUES を指定しておくと、タイポした値を付与しようとした時点でエラーになります。
2. 検証用のロールを2つ作る
CREATE OR REPLACE ROLE BLOG_PII_FULL_ROLE COMMENT = '素の値が見える特権ロール';
CREATE OR REPLACE ROLE BLOG_ANALYST_ROLE COMMENT = '部分マスクが掛かる一般ロール';
GRANT USAGE ON WAREHOUSE COMPUTE_WH TO ROLE BLOG_PII_FULL_ROLE;
GRANT USAGE ON WAREHOUSE COMPUTE_WH TO ROLE BLOG_ANALYST_ROLE;
GRANT USAGE ON DATABASE BLOG_MASK_DB TO ROLE BLOG_PII_FULL_ROLE;
GRANT USAGE ON DATABASE BLOG_MASK_DB TO ROLE BLOG_ANALYST_ROLE;
GRANT USAGE ON SCHEMA BLOG_MASK_DB.MART TO ROLE BLOG_PII_FULL_ROLE;
GRANT USAGE ON SCHEMA BLOG_MASK_DB.MART TO ROLE BLOG_ANALYST_ROLE;
GRANT ROLE BLOG_PII_FULL_ROLE TO USER <検証ユーザー名>;
GRANT ROLE BLOG_ANALYST_ROLE TO USER <検証ユーザー名>;
注意: 本記事では
CURRENT_ROLE()で判定しています。CURRENT_ROLE()は現在のプライマリロールのみを返し、ロール階層やアクティブなセカンダリロールは考慮しません。ロール階層・セカンダリロールを含めて判定する場合はIS_ROLE_IN_SESSION('BLOG_PII_FULL_ROLE')を使用します。
マスキングポリシーの作成
型ごとに1本ずつ作成します。分岐は「ロール判定 → PUBLIC 判定 → 粒度ごとの分岐 → ELSE」の順に書き、ELSE を完全マスクにします。こうしておくことで、タグの付与漏れが「素の値が出る」ではなく「完全マスクになる」側に倒れます。
-- DATE
CREATE OR REPLACE MASKING POLICY BLOG_MASK_DB.GOV.MASK_DATE AS (VAL DATE)
RETURNS DATE ->
CASE
WHEN CURRENT_ROLE() = 'BLOG_PII_FULL_ROLE' THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PUBLIC'
THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_YEARMONTH'
THEN DATE_TRUNC('MONTH', VAL)
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_YEAR'
THEN DATE_TRUNC('YEAR', VAL)
ELSE DATE '1900-01-01'
END;
-- VARCHAR
CREATE OR REPLACE MASKING POLICY BLOG_MASK_DB.GOV.MASK_TEXT AS (VAL VARCHAR)
RETURNS VARCHAR ->
CASE
WHEN CURRENT_ROLE() = 'BLOG_PII_FULL_ROLE' THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PUBLIC'
THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_PHONE_LAST4'
THEN IFF(LENGTH(VAL) >= 4, '****-****-' || RIGHT(VAL, 4), '***MASKED***')
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_EMAIL_DOMAIN'
THEN IFF(POSITION('@' IN VAL) > 0, '***@' || SPLIT_PART(VAL, '@', 2), '***MASKED***')
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_ADDR_CITY'
THEN IFF(POSITION('市' IN VAL) > 0, LEFT(VAL, POSITION('市' IN VAL)), '***MASKED***')
ELSE '***MASKED***'
END;
-- NUMBER
CREATE OR REPLACE MASKING POLICY BLOG_MASK_DB.GOV.MASK_NUMBER AS (VAL NUMBER(38,2))
RETURNS NUMBER(38,2) ->
CASE
WHEN CURRENT_ROLE() = 'BLOG_PII_FULL_ROLE' THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PUBLIC'
THEN VAL
WHEN COALESCE(SYSTEM$GET_TAG_ON_CURRENT_COLUMN('BLOG_MASK_DB.GOV.DATA_CLASS'), 'UNCLASSIFIED') = 'PII_AMOUNT_BAND'
THEN SIGN(VAL) * FLOOR(ABS(VAL) / 100000) * 100000
ELSE 0
END;
なお NULL の扱いは型・分岐ごとに異なります。DATE の DATE_TRUNC と NUMBER の SIGN/FLOOR は NULL を伝播して NULL のまま返しますが、VARCHAR の部分マスクは IFF の条件が NULL になると else 側に進むため ***MASKED*** に置き換わります。NULL の有無自体が属性情報になり得るため、NULL を保持するか固定値に寄せるかは、型ごとに成り行き任せにせずデータ分類要件として統一してください。
3本を同じタグに紐付けます。
ALTER TAG BLOG_MASK_DB.GOV.DATA_CLASS SET MASKING POLICY BLOG_MASK_DB.GOV.MASK_DATE;
ALTER TAG BLOG_MASK_DB.GOV.DATA_CLASS SET MASKING POLICY BLOG_MASK_DB.GOV.MASK_TEXT;
ALTER TAG BLOG_MASK_DB.GOV.DATA_CLASS SET MASKING POLICY BLOG_MASK_DB.GOV.MASK_NUMBER;
検証データの作成とタグの付与
検証テーブルの作成
CREATE OR REPLACE TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER (
CUSTOMER_ID NUMBER(10,0),
CUSTOMER_NAME VARCHAR(100),
BIRTH_EXACT DATE, -- 比較用に PUBLIC を付ける列 (実運用では付けない)
BIRTH_YM DATE,
BIRTH_Y DATE,
BIRTH_FULL DATE,
PHONE VARCHAR(20),
EMAIL VARCHAR(100),
ADDRESS VARCHAR(200),
BALANCE NUMBER(38,2),
REGION VARCHAR(50) -- 意図的にタグを貼らない列
);
INSERT INTO BLOG_MASK_DB.MART.DIM_CUSTOMER VALUES
(1, '川端 太郎',
'2001-03-26', '2001-03-26', '2001-03-26', '2001-03-26',
'090-1234-5678', 'taro.kawabata@example.co.jp', '東京都渋谷区神南1-1-1',
1234567.89, 'KANTO'),
(2, '佐藤 花子',
'1987-11-04', '1987-11-04', '1987-11-04', '1987-11-04',
'080-9876-5432', 'hanako@sample.org', '神奈川県横浜市西区みなとみらい2-2-2',
-5000.00, 'KANTO');
タグベースのマスキングは、タグが付与され、かつ列のデータ型に対応するマスキングポリシーがタグに設定されている列に適用されます。付与漏れの列は素の値が返るため、データベースに UNCLASSIFIED を付与して既定値を完全マスク側に寄せます。本記事では DATE・VARCHAR・NUMBER 向けのポリシーを設定しているため、この既定値が効くのはこれらの型の列に限られます。
ALTER DATABASE BLOG_MASK_DB SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'UNCLASSIFIED';
そのうえで、列ごとに粒度を宣言します。タグは列レベルの付与がデータベースからの継承より優先されます。
ALTER TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER MODIFY
COLUMN CUSTOMER_ID SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC',
COLUMN CUSTOMER_NAME SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII',
COLUMN BIRTH_EXACT SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC',
COLUMN BIRTH_YM SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_YEARMONTH',
COLUMN BIRTH_Y SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_YEAR',
COLUMN BIRTH_FULL SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII',
COLUMN PHONE SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_PHONE_LAST4',
COLUMN EMAIL SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_EMAIL_DOMAIN',
COLUMN ADDRESS SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_ADDR_CITY',
COLUMN BALANCE SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_AMOUNT_BAND';
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER TO ROLE BLOG_PII_FULL_ROLE;
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER TO ROLE BLOG_ANALYST_ROLE;
REGION にはタグを付与していません。ポリシーの適用状況を POLICY_REFERENCES で確認します。
SELECT REF_COLUMN_NAME AS "列", POLICY_NAME AS "ポリシー", TAG_NAME AS "経由タグ"
FROM TABLE(BLOG_MASK_DB.INFORMATION_SCHEMA.POLICY_REFERENCES(
REF_ENTITY_NAME => 'BLOG_MASK_DB.MART.DIM_CUSTOMER',
REF_ENTITY_DOMAIN => 'TABLE'))
ORDER BY 1;
11列すべてにポリシーが適用されました。REGION はデータベースからの継承のみで対象になっています。

なお CUSTOMER_ID は NUMBER(10,0) ですが、NUMBER(38,2) で定義した MASK_NUMBER が適用されました。少なくとも NUMBER 型どうしでは、精度・スケールの完全一致は要求されないことを確認できました。
列ごとのタグが直接付与か継承かは TAG_REFERENCES_ALL_COLUMNS で確認できます。APPLY_METHOD 列に MANUAL (直接付与) / INHERITED (継承) が表示されます。
SELECT COLUMN_NAME, TAG_VALUE, APPLY_METHOD, LEVEL
FROM TABLE(BLOG_MASK_DB.INFORMATION_SCHEMA.TAG_REFERENCES_ALL_COLUMNS(
'BLOG_MASK_DB.MART.DIM_CUSTOMER', 'TABLE'))
WHERE TAG_NAME = 'DATA_CLASS'
ORDER BY COLUMN_NAME;
試してみた
特権ロールでの表示
USE ROLE BLOG_PII_FULL_ROLE;
SELECT CURRENT_ROLE() AS "ロール", CUSTOMER_ID, CUSTOMER_NAME, BIRTH_EXACT,
PHONE, EMAIL, ADDRESS, BALANCE, REGION
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER
ORDER BY CUSTOMER_ID;

想定どおり全列が素の値です。
誕生日の粒度を4段階で比較する
USE ROLE BLOG_ANALYST_ROLE;
SELECT CUSTOMER_ID,
BIRTH_EXACT AS "素の値",
BIRTH_YM AS "年月まで",
BIRTH_Y AS "年まで",
BIRTH_FULL AS "完全マスク"
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER
ORDER BY CUSTOMER_ID;
2001-03-26 が 2001-03-01 になり、日だけが潰れて年月が残りました。同じテーブル・同じクエリ・同じロールで、列に付与したタグの値だけが異なる結果です。

文字列と数値の部分マスク
SELECT CUSTOMER_ID,
CUSTOMER_NAME AS "氏名(PII)",
PHONE, EMAIL, ADDRESS, BALANCE,
REGION AS "REGION(タグ未付与)"
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER
ORDER BY CUSTOMER_ID;

確認できる点は3つです。
- 1行目の住所が完全マスクに倒れています。
東京都渋谷区神南1-1-1に「市」が含まれないため、「市」まで残す処理ができず安全側に落ちました (詳細は確認ポイント1) REGIONはタグを付与していませんが、データベースに付与したUNCLASSIFIEDの継承でマスクされました- 2行目の残高は素の値
-5000.00に対して0.00です
ACCOUNTADMIN での挙動
USE ROLE ACCOUNTADMIN;
SELECT CURRENT_ROLE() AS "ロール", CUSTOMER_NAME, BIRTH_YM, PHONE, BALANCE
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER
ORDER BY CUSTOMER_ID;

ACCOUNTADMIN でもマスクされます。マスキングポリシーは権限の強さで免除されるものではなく、ポリシーの CASE に記述したロールだけが素の値を参照できます。
タグの貼り替えによる粒度変更
ポリシーには手を加えず、タグの値だけを変更します。
USE ROLE ACCOUNTADMIN;
ALTER TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER MODIFY
COLUMN BIRTH_YM SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_YEAR';
USE ROLE BLOG_ANALYST_ROLE;
SELECT CUSTOMER_ID, BIRTH_YM FROM BLOG_MASK_DB.MART.DIM_CUSTOMER ORDER BY CUSTOMER_ID;

2001-03-01 だった列が 2001-01-01 になりました。ALTER TABLE ... SET TAG の1文だけで粒度が変わるため、粒度の変更依頼にポリシーのレビューが不要になります。
確認できたら、以降の検証で年月粒度を使うため PII_YEARMONTH に戻しておきます。
USE ROLE ACCOUNTADMIN;
ALTER TABLE BLOG_MASK_DB.MART.DIM_CUSTOMER MODIFY
COLUMN BIRTH_YM SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_YEARMONTH';
確認ポイント1: 区切り文字がない入力では前方を残す実装で全文が露出する
住所を「市」まで丸める処理として、まず思い付くのは「市」で分割して前半を取り出す実装です。
SPLIT_PART(ADDR, '市', 1) || '市'
境界値を通して確認します。
WITH T AS (
SELECT '神奈川県横浜市西区みなとみらい2-2-2' AS ADDR UNION ALL
SELECT '東京都渋谷区神南1-1-1' UNION ALL
SELECT '大阪府大阪市北区梅田3-3-3' UNION ALL
SELECT '' UNION ALL
SELECT NULL
)
SELECT
COALESCE(ADDR, '(NULL)') AS "元の値",
SPLIT_PART(ADDR, '市', 1) || '市' AS "危険な実装",
IFF(POSITION('市' IN ADDR) > 0,
LEFT(ADDR, POSITION('市' IN ADDR)),
'***MASKED***') AS "安全な実装"
FROM T;

2行目で、番地まで含んだ住所が丸ごと出力されました。SPLIT_PART は区切り文字が見つからない場合に入力全体を1番目の要素として返すためです。東京23区の住所に「市」は含まれないため、切り出せなかった全文に「市」が連結された値になります。
注意:
SPLIT_PART/SUBSTR/REGEXP_SUBSTRは、区切り文字やパターンが見つからない場合でもエラーを返しません。部分マスクの実装では、区切り文字が存在しない入力に対する出力を必ず確認してください。安全側に寄せる場合は、POSITIONで区切り文字の存在を確認し、見つからなければ完全マスクに倒します。
なお「安全な実装」も最初の「市」までを残す簡易版であり、政令指定都市の区・郡・町村・表記揺れには対応していません。市区町村単位を正確に扱う場合は、都道府県・市区町村を正規化した別列を用意し、その列に粒度を設定する構成が現実的です。
電話番号とメールアドレスの境界値
住所と同じく SPLIT_PART を使っているメールアドレスの挙動も確認します。
WITH T AS (
SELECT '090-1234-5678' AS PHONE, 'taro.kawabata@example.co.jp' AS EMAIL UNION ALL
SELECT '123', 'no-at-sign-here' UNION ALL
SELECT '', '' UNION ALL
SELECT NULL, NULL
)
SELECT
COALESCE(PHONE, '(NULL)') AS "電話_元",
IFF(LENGTH(PHONE) >= 4, '****-****-' || RIGHT(PHONE, 4), '***MASKED***') AS "電話_マスク後",
COALESCE(EMAIL, '(NULL)') AS "メール_元",
'***@' || SPLIT_PART(EMAIL, '@', 2) AS "メール_危険",
IFF(POSITION('@' IN EMAIL) > 0, '***@' || SPLIT_PART(EMAIL, '@', 2), '***MASKED***') AS "メール_安全"
FROM T;

メールアドレスの「危険な実装」では全文が出力されず、***@ という値になりました。住所との違いは、区切り文字の何番目を残すかにあります。
- 住所は
SPLIT_PART(..., 1)で1番目を残しており、区切りがない場合は1番目が全文になる - メールアドレスは
SPLIT_PART(..., 2)で2番目を残しており、区切りがない場合は2番目が空文字になる
SPLIT_PART 自体ではなく、前方を残す実装が問題です。住所・氏名・郵便番号・社員番号のプレフィックスなど、前方を残す部分マスクは同じ挙動になるため、重点的にレビューする対象と考えられます。
マスク後の列で GROUP BY・JOIN できるか
マスキングポリシーはクエリ実行時に、マスク対象列が参照される箇所へ適用されます。SELECT の結果だけでなく JOIN 条件・WHERE・GROUP BY・ORDER BY でも、一般ロールはマスク後の値を評価することになります。テーブルを2つ追加して確認します。
結合検証用テーブルの追加
USE ROLE ACCOUNTADMIN;
-- 購買ファクト (結合キーと金額は非PIIとして PUBLIC)
CREATE OR REPLACE TABLE BLOG_MASK_DB.MART.FCT_PURCHASE (
PURCHASE_ID NUMBER(10,0),
CUSTOMER_ID NUMBER(10,0),
PURCHASE_AMOUNT NUMBER(38,2)
);
INSERT INTO BLOG_MASK_DB.MART.FCT_PURCHASE VALUES
(101, 1, 12000.00),
(102, 1, 3500.00),
(103, 2, 8000.00),
(104, 2, 1500.00),
(105, 2, 700.00);
ALTER TABLE BLOG_MASK_DB.MART.FCT_PURCHASE MODIFY
COLUMN PURCHASE_ID SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC',
COLUMN CUSTOMER_ID SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC',
COLUMN PURCHASE_AMOUNT SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC';
-- 連絡先リスト (example.co.jp ドメインに別人のアドレスを混ぜておく)
CREATE OR REPLACE TABLE BLOG_MASK_DB.MART.MKT_CONTACT (
CONTACT_ID NUMBER(10,0),
EMAIL VARCHAR(100)
);
INSERT INTO BLOG_MASK_DB.MART.MKT_CONTACT VALUES
(901, 'taro.kawabata@example.co.jp'), -- 顧客1と完全一致
(902, 'another-user@example.co.jp'), -- 別人だが同一ドメイン
(903, 'hanako@sample.org'); -- 顧客2と完全一致
ALTER TABLE BLOG_MASK_DB.MART.MKT_CONTACT MODIFY
COLUMN CONTACT_ID SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PUBLIC',
COLUMN EMAIL SET TAG BLOG_MASK_DB.GOV.DATA_CLASS = 'PII_EMAIL_DOMAIN';
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.FCT_PURCHASE TO ROLE BLOG_PII_FULL_ROLE;
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.FCT_PURCHASE TO ROLE BLOG_ANALYST_ROLE;
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.MKT_CONTACT TO ROLE BLOG_PII_FULL_ROLE;
GRANT SELECT ON TABLE BLOG_MASK_DB.MART.MKT_CONTACT TO ROLE BLOG_ANALYST_ROLE;
データベースレベルの UNCLASSIFIED 継承があるため、ポリシーを設定した DATE・VARCHAR・NUMBER 型の列は、タグを付与しなければ完全マスクに倒れます。結合キーと集計対象の列には明示的に PUBLIC を宣言する必要があります。
まず GROUP BY です。MKT_CONTACT は素の値が3件とも異なり、うち2件が同一ドメインというデータです。マスク後の値で集約されるなら、この2件は1グループに合流するはずです。
USE ROLE BLOG_ANALYST_ROLE;
SELECT EMAIL AS "メール(マスク後)", COUNT(*) AS "件数"
FROM BLOG_MASK_DB.MART.MKT_CONTACT
GROUP BY EMAIL
ORDER BY 1;

素の値が異なる2件が ***@example.co.jp の1グループに合流しました。GROUP BY がマスク後の値で評価されている証拠です。特権ロールで同じクエリを実行すると、素の値のまま3グループ×1件になります。

次に JOIN です。結合キー (CUSTOMER_ID) が PUBLIC であれば結合は素の値どおり成立し、マスクされたディメンション属性での集計と組み合わせられます。
SELECT YEAR(D.BIRTH_Y) - MOD(YEAR(D.BIRTH_Y), 10) AS "年代",
COUNT(DISTINCT D.CUSTOMER_ID) AS "顧客数",
COUNT(P.PURCHASE_ID) AS "購買件数",
SUM(P.PURCHASE_AMOUNT) AS "購買金額合計"
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER D
JOIN BLOG_MASK_DB.MART.FCT_PURCHASE P
ON D.CUSTOMER_ID = P.CUSTOMER_ID
GROUP BY 1
ORDER BY 1;

「誕生日は見せずに、年代別の購買分析は成立させる」という部分マスクの本命ユースケースが、JOIN を挟んでも機能します。
一方、マスクされた列同士を結合キーにすると誤マッチが起きます。顧客と連絡先を EMAIL で結合してみます。
SELECT D.CUSTOMER_ID, D.EMAIL AS "顧客EMAIL(マスク後)",
C.CONTACT_ID, C.EMAIL AS "連絡先EMAIL(マスク後)"
FROM BLOG_MASK_DB.MART.DIM_CUSTOMER D
JOIN BLOG_MASK_DB.MART.MKT_CONTACT C
ON D.EMAIL = C.EMAIL
ORDER BY D.CUSTOMER_ID, C.CONTACT_ID;

顧客1が、別人のアドレスである 902 とも結合しました。両側の値が ***@example.co.jp に潰れているため、ドメインが同じというだけでマッチしています。特権ロールで同じクエリを実行すると、素の値の完全一致である 901 と 903 の2行だけが返ります。

完全マスクの列同士であれば、全行が ***MASKED*** に潰れて総当たりの結合になります。マスク対象の列は JOIN キーや COUNT(DISTINCT ...) の基準に使わず、PUBLIC にできるサロゲートキーで結合する設計にしてください。
制限事項・注意点
検証および公式ドキュメントから確認できた制限事項・注意点をまとめます。
1. 制限事項・注意点
- タグベースマスキングポリシーの利用には Enterprise Edition 以上が必要です
SYSTEM$GET_TAG_ON_CURRENT_COLUMNはマスキングポリシーとプロジェクションポリシーの中でのみ呼び出せますSYSTEM$GET_TAG_ON_CURRENT_COLUMNの引数で指定するタグは、ポリシー評価時に存在している必要があります。タグ名はデータベース名・スキーマ名を含む完全修飾名で指定してください- データベースやスキーマに
UNCLASSIFIEDを継承させても、タグにそのデータ型用のマスキングポリシーが設定されていない列 (今回の構成では TIMESTAMP・BOOLEAN・VARIANT 等) は保護されません。既定で完全マスクに倒す運用には、利用を許可する全データ型に対応するポリシーを定義するか、未対応型の列を持ち込ませない DDL レビューを併用します - 1つのタグに設定できるマスキングポリシーはデータ型ごとに1本です。属性の種類はタグの値の名前で表現します
- タグに紐付いたポリシーは
CREATE OR REPLACEで置き換えられません。本体の修正はALTER MASKING POLICY ... SET BODY、別ポリシーへの差し替えはALTER TAG ... SET MASKING POLICY ... FORCEを使用します (UNSETとSETの2文に分けると、その間は列が無保護になります) - タグベースマスキングポリシーが適用されたテーブルではマテリアライズドビューを作成できません。既存のマテリアライズドビューは無効化されます
- 同一列にはマスキングポリシーとプロジェクションポリシーを併用できます。クエリ実行時はプロジェクション可否の判定が先で、その後にマスキングが適用されます。ただし、プロジェクションポリシーの本体からマスキングポリシー保護列を参照できない、プロジェクション制約された列を条件付きマスキングポリシーの引数に使えないといった制約があります
- 数値を丸める部分マスクでは合計値が変わります。マスクした列の集計を確定値として扱うことはできないため、丸め幅と許容誤差を事前に合意してください
- 日付の粒度落としで分析が成立するかは、粒度と使用する関数の組み合わせで決まります。列単位で決め打ちせず、実際のクエリで確認してください
- 部分マスクは値を残すため、他の列との組み合わせによる再識別リスクは残ります。必要に応じて集計ポリシーやプロジェクションポリシーの併用を検討してください
最後に
タグの値で粒度を切り替える構成にすると、ポリシーは型ごとに1本で済み、運用は列へのタグ付与だけになります。年代・年齢帯の分析を成立させたまま誕生日の直接的な露出を避けられるため、マスク解除の申請そのものを減らせます。
一方、部分マスクは値を残す処理です。区切り文字がない入力での全文露出や、マスク列同士の JOIN の誤マッチのように、残し方を誤ると結果の意味が変わるため、実装前に境界値を確認しておくのが確実です。
この記事が何かの参考になれば幸いです!






