マスキングは「全部伏せれば安全」というだけではなく、業務で必要な最小限の可視性も同時に満たす必要があります。そこで重要になるのが、マスキング強度(レベル)を用意し、閲覧者のロール(役割)や用途(開発・分析・CSなど)に応じて出し分ける設計です。
マスキング強度レベルの設計例
| レベル | 用途 | 表示例(email / phone / name) |
|---|---|---|
| L0: 無加工 | 管理者・厳格な監査下 | taro.yamada@example.com / 090-1234-5678 / 山田太郎 |
| L1: 部分表示(低) | CS・運用(照合したい) | ta@example.com / 090--5678 / 山田*郎 |
| L2: 強マスキング | 分析・一般運用 | **@example.com / **** / *** |
| L3: 匿名化(不可逆) | 外部共有・開発 | hash(email) / hash(phone) / hash(name) |
現場では「L1(部分表示)」が特に便利で、問い合わせ対応などで照合に必要な一部だけ見せる設計が多いです。
(PostgreSQL)マスキング関数を用意して再利用性を上げる
ビューごとにCASEをベタ書きすると運用が破綻しがちなので、マスキング用UDF(関数)を作って統一します。
1) メールのマスキング関数(L1/L2/L3)
CREATE OR REPLACE FUNCTION mask_email(val text, level int) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT CASE WHEN val IS NULL THEN NULL WHEN level = 0 THEN val WHEN level = 1 THEN -- 先頭2文字 + *** + @以降は保持 regexp_replace(val, '^(.{2}).*(@.+)$', '\1***\2') WHEN level = 2 THEN -- ドメインだけ残して完全に伏せる regexp_replace(val, '^.*(@.+)$', '***\1') ELSE -- level=3: 匿名化(不可逆) md5(val) END; $$;
2) 電話番号のマスキング関数(L1/L2/L3)
CREATE OR REPLACE FUNCTION mask_phone(val text, level int) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT CASE WHEN val IS NULL THEN NULL WHEN level = 0 THEN val WHEN level = 1 THEN -- 末尾4桁だけ残す(数字以外は除去してから) regexp_replace(regexp_replace(val, '[^0-9]', '', 'g'), '^(.*)(.{4})$', '****\2') WHEN level = 2 THEN -- 全伏せ(桁数に依存させるならrepeatで調整) '************' ELSE md5(regexp_replace(val, '[^0-9]', '', 'g')) END; $$;
3) 氏名のマスキング関数(L1/L2/L3)
CREATE OR REPLACE FUNCTION mask_name(val text, level int) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT CASE WHEN val IS NULL THEN NULL WHEN level = 0 THEN val WHEN level = 1 THEN -- 先頭1文字と末尾1文字だけ残す(2文字未満は全伏せ寄りに) CASE WHEN char_length(val) <= 2 THEN left(val, 1) || '*' ELSE left(val, 1) || repeat('*', greatest(char_length(val)-2, 1)) || right(val, 1) END WHEN level = 2 THEN '***' ELSE md5(val) END; $$;
ロール(役割)でマスキング強度を切り替える設計
やり方は大きく2つです。
- ビューを強度別に分ける(users_masked_l1 / l2 / l3)
- 1つのビューでロールを見て切り替える(current_user / pg_has_roleを利用)
運用の安定性を重視するなら、まずはビューを強度別に分けるのが扱いやすいです。
強度別ビューの例(L1 / L2 / L3)
例として public.users(id, name, email, phone, created_at)を想定します。
L1:部分表示(CS・運用向け)
CREATE OR REPLACE VIEW users_masked_l1 AS SELECT id, mask_name(name, 1) AS name, mask_email(email, 1) AS email, mask_phone(phone, 1) AS phone, created_at FROM public.users;
L2:強マスキング(分析・一般向け)
CREATE OR REPLACE VIEW users_masked_l2 AS SELECT id, mask_name(name, 2) AS name, mask_email(email, 2) AS email, mask_phone(phone, 2) AS phone, created_at FROM public.users;
L3:匿名化(開発・外部共有向け)
CREATE OR REPLACE VIEW users_masked_l3 AS SELECT id, mask_name(name, 3) AS name, mask_email(email, 3) AS email, mask_phone(phone, 3) AS phone, created_at FROM public.users;
ビューごとに権限を付与して「見える強度」を固定する
「誰がどの強度を見れるか」をDB権限で担保すると、アプリ改修が少なくて済みます。
-- 例:ロール CREATE ROLE cs_role; CREATE ROLE analyst_role; CREATE ROLE dev_role; -- 権限付与(L1はCS、L2は分析、L3は開発) GRANT SELECT ON users_masked_l1 TO cs_role; GRANT SELECT ON users_masked_l2 TO analyst_role; GRANT SELECT ON users_masked_l3 TO dev_role; -- 元テーブルへの直接SELECTは原則禁止(最重要) REVOKE ALL ON public.users FROM PUBLIC;
(発展)1つのビューでロールにより強度を自動切替
ビューが増えすぎるのが嫌な場合は、ロール判定でlevelを切り替えられます。
CREATE OR REPLACE VIEW users_masked AS SELECT id, mask_name(name, CASE WHEN pg_has_role(current_user, 'cs_role', 'member') THEN 1 WHEN pg_has_role(current_user, 'analyst_role', 'member') THEN 2 WHEN pg_has_role(current_user, 'dev_role', 'member') THEN 3 ELSE 2 END ) AS name, mask_email(email, CASE WHEN pg_has_role(current_user, 'cs_role', 'member') THEN 1 WHEN pg_has_role(current_user, 'analyst_role', 'member') THEN 2 WHEN pg_has_role(current_user, 'dev_role', 'member') THEN 3 ELSE 2 END ) AS email, mask_phone(phone, CASE WHEN pg_has_role(current_user, 'cs_role', 'member') THEN 1 WHEN pg_has_role(current_user, 'analyst_role', 'member') THEN 2 WHEN pg_has_role(current_user, 'dev_role', 'member') THEN 3 ELSE 2 END ) AS phone, created_at FROM public.users;
注意: 仕組みは便利ですが、ロール設計ミスがあると想定外の強度で見えてしまうので、最初は強度別ビュー方式がおすすめです。
まとめ
- マスキングは強度レベルを設計し、用途別に出し分けると運用が安定
- 関数(UDF)でマスキングロジックを統一すると、変更に強くなる
- ビュー+権限付与で「見える強度」をDB側で担保できる
- 発展形として、ロール判定で1ビュー自動切替も可能