🔐

Snowflake Secret を用いたソルト付きハッシュ化 UDF

こんにちは!シンプルフォームの山岸です。

データ基盤上の特定のテーブルカラムに対してロール毎にアクセスを制御する、いわゆる RBAC (Role-based Access Control) の仕組みを導入されているという方は多いのではないでしょうか。
今回、RBAC で使用する「ソルト付きハッシュ化」を Snowflake で実装する方法について検討してみたので、本記事ではその内容についてご紹介できればと思います。

課題感とアプローチ

単純なハッシュ化に対する課題感

RBAC を実装するにあたり、「特定のカラムに対しては固定値に置換して一切参照させない」ことも可能ですが、要件によっては「生の値は参照させたくないが、集計クエリなどのために値の区別はできるようにしたい」ということもあると思います。このような加工処理において、MD5 や SHA-1 といったハッシュ化関数の適用は有効な手段です。

しかし、ハッシュ化に何の関数を使用しているかを把握しており、生の値についても取りうる値が推測可能である場合、(生の値, ハッシュ化された値)のマッピングテーブルを簡単に作成できてしまいます。これでは折角ハッシュ化している意義が薄れてしまいます。

ソルト付きハッシュ化 (Salted Hash) とは

上記の課題感を踏まえ「ソルト付きハッシュ化」では、ハッシュ化関数適用前の生の値に、管理者のみがアクセス可能な「ソルト」と呼ばれるランダムな文字列を付与します。ソルト値を知らない限りマッピングテーブルを作成できないため、満たすべきポリシーを遵守できます。

逆にソルト値が漏洩してしまえば再び同じ問題が発生するため、前述の「管理者のみがアクセス可能」という状態を作る必要があります。

Snowflake Secret について

ではこのソルト値をどこで管理すべきかという話ですが、今回 Snowflake Secret [1] を利用することにしました。Secret を利用することで、統合リソースや外部関数のような許可されたコンポーネントのみがアクセス可能な機密情報を管理できます。

最終的に実現したいことは、特定ロールから特定テーブルカラムへのアクセス時にソルト付きハッシュ化を施すことなので、Masking Policy [2] から呼び出されることを想定したソルト付きハッシュ化用のユーザー定義関数 (UDF) を実装します。ソルト値は UDF のハンドラスクリプトにハードコードせず、ソルト値を格納した Secret を呼び出す形で参照します。

実装

それではソルト付きハッシュ化 UDF を作成するところまでの実装について、以下に説明します。
(Masking Policy の実装については割愛します)

Secret の作成

ソルト値を格納する Secret リソース (Schema-level object) を作成していきます。

まず Secret 値を決定します。以下は 32 Bytes のランダムなバイナリ値を Base64 エンコードし、UTF-8 でデコードした文字列を生成するためのサンプルコードです。

generate_secret_value.py
import os
import base64

NUM_BYTES = 32
binary_value = os.urandom(NUM_BYTES)
decoded_value = base64.b64encode(binary_value).decode('utf-8')
print(len(decoded_value), "chars,", decoded_value)

# 44 chars, fa3hmjYSiPaxDg0t8E+VexT4WgvKF8z9ia13w9jy8Mo=

生成した値を用いて、GENERIC_STRING TYPE の Secret として SALT1 を作成します。

create_secret.sql
USE DATABASE TEST_DB;
USE SCHEMA TEST_DB.TEST_SCHEMA;

CREATE OR REPLACE SECRET SALT1
    TYPE = GENERIC_STRING
    SECRET_STRING = 'fa3hmjYSiPaxDg0t8E+VexT4WgvKF8z9ia13w9jy8Mo=';

ここで作成した Secret の中身の値は、SHOW SECRETSDESCRIBE SECRET のコマンドを実行しても参照できないことを確認できます。

SHOW SECRETS;
DESCRIBE SECRET SALT1;

依存リソースの作成

UDF から Secret を使用する際に必要となる、以下の依存リソースを作成します。

Network Rule

ダミーの Network Rule リソース (Schema-level object) を作成します。

USE DATABASE TEST_DB;
USE SCHEMA TEST_DB.TEST_SCHEMA;

CREATE OR REPLACE NETWORK RULE DUMMY_RULE
  MODE = EGRESS
  TYPE = HOST_PORT
  VALUE_LIST = ('example.com');

External Access Integration

ダミーの External Access Integration リソース (Account-level object) を作成します。

CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION DUMMY_EXTERNAL_ACCESS_INTEGRATION 
  -- 作成した Network Rule を指定
  ALLOWED_NETWORK_RULES = (DUMMY_RULE)
  -- 紐付ける Secret を指定
  ALLOWED_AUTHENTICATION_SECRETS = (
    TEST_DB.TEST_SCHEMA.SALT1,
    TEST_DB.TEST_SCHEMA.SALT2
  )
  ENABLED = true;

ソルト付きハッシュ化 UDF の作成

ハンドラスクリプト

作成する UDF のハンドラスクリプトは例えば以下のようになります。
get_generic_secret_string(generic_string_secret_name) [5] 関数を使用して Secret 値を取得し、ハッシュ化対象の文字列と結合して SHA-1 ハッシュ化処理を施しています。

salted_hash_udf.py
import _snowflake
import hashlib

def handler(value: str) -> str:
    salt = _snowflake.get_generic_secret_string('salt')
    salted_value = salt + value
    encoded_value = salted_value.encode('utf-8')
    return hashlib.sha1(encoded_value).hexdigest()

Terraform による UDF 作成

上記のハンドラスクリプトを用いて、UDF を Terraform で作成します。
Terraform モジュールの実装については以下の記事で詳しく取り扱っているので、よろしければ併せてご覧ください。

https://zenn.dev/dataheroes/articles/20240908-snowflake-udf-udtf-with-terraform

SQL による設定の追加

Terraform で設定できなかった属性について、ALTER FUNCTION を実行して設定します。

alter_function.sql
ALTER FUNCTION IF EXISTS SALTED_HASH_UDF(VARCHAR) SET 
    EXTERNAL_ACCESS_INTEGRATIONS = (DUMMY_EXTERNAL_ACCESS_INTEGRATION)
    SECRETS = ('salt' = SALT1);

-- 設定の確認
DESC FUNCTION SALTED_HASH_UDF(VARCHAR);

動作確認

作成したソルト付きハッシュ化 UDF を呼び出してみます。

SELECT SALTED_HASH_UDF('シンプルフォーム株式会社') AS hashed;

複数回実行しても同一の入力に対しては同一の出力結果を返すことを確認します。

実装に関する説明は以上です。

落穂拾い

ソルト付き暗号化 UDF

ハッシュ化の場合、一方通行の処理になりますが、復号化可能な暗号化処理として実装することも可能です。ハンドラスクリプトは例えば以下のようになります。

salted_enctyption_udf.py
import _snowflake
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

SEPARATOR = ":::"

def _build_aesgcm() -> AESGCM:
    encryption_key = _snowflake.get_generic_secret_string('encryption_key')
    assert len(encryption_key) == 44, "Encryption Key must be 44 characters long"
    decoded_key = base64.b64decode(encryption_key)
    aesgcm = AESGCM(decoded_key)
    return aesgcm

def encrypt(entity: str, value: str) -> str:
    aesgcm = _build_aesgcm()
    iv = entity.strip().encode("utf-8")
    encrypted = aesgcm.encrypt(iv, value.encode("utf-8"), None)
    return (entity + SEPARATOR + encrypted.hex())

def decrypt(encrypted_string: str) -> str:
    aesgcm = _build_aesgcm()
    iv_base64, encrypted_hex = encrypted_string.strip().split(SEPARATOR)
    iv = iv_base64.strip().encode('utf-8')
    encrypted = bytes.fromhex(encrypted_hex.strip())
    decrypted = aesgcm.decrypt(iv, encrypted, None)
    return decrypted.decode('utf-8')

暗号化された値を復号化してみると、正常に復号化できていることを確認できます。

SELECT 
    SALTED_ENCRYPTION_UDF_ENCRYPT('WBrjGnvk3HU6Ol3xiqbZ9w==', 'シンプルフォーム株式会社') AS encrypted, 
    SALTED_ENCRYPTION_UDF_DECRYPT(SALTED_ENCRYPTION_UDF_ENCRYPT('WBrjGnvk3HU6Ol3xiqbZ9w==', 'シンプルフォーム株式会社')) as decrypted;

さいごに

Snowflake 上でソルト付きハッシュ化を用いた RBAC を実現する方法について書いてみました。

本記事の内容を通して機能的な検証はできましたが、UDF を使用しているのでデータ量が本番規模になったときに処理時間がどの程度に膨らむかは気になるところです。この辺りについては今後別途検証してみたいと思います。

また、記事内容の検証にあたり Snowflake のエンジニアの方々に多大なるご助力を賜りました。この場をお借りして感謝を申し上げたいと思います。: @masayay (Zenn), @tomowk1 (X)

最後まで読んで頂き、ありがとうございました。

参考

脚注
  1. シークレットの管理 - Snowflake Documentation ↩︎

  2. ダイナミックデータマスキングについて - Snowflake Documentation ↩︎

  3. ネットワークルール - Snowflake Documentation ↩︎

  4. 外部ネットワークアクセスの概要 - Snowflake Documentation ↩︎

  5. Python API for Secret Access - Snowflake Documentation ↩︎

Snowflake Data Heroes

Discussion