0 0
Read Time:7 Minute, 17 Second

Persona-Driven Partial Decryption in Snowflake: An insurance carrier maintains a single CUSTOMER_360 table holding policyholder PII — NAME, EMAIL, PHONE, SSN, DOB. The same table is read every day by very different people across the business, and each one legitimately needs a different slice of it:

Requirement

Most masking conversations stop at one question: “Is this user allowed to see PII or not?” .

The claims agent on the phone needs the customer’s name and number to do her job. Nothing else. The fraud investigator chasing a suspicious claim needs SSN and date of birth — but has zero reason to see email. The underwriter pricing a renewal needs email and DOB, never SSN. Each of them is a “PII user” in the broad sense, and yet none of them needs the same columns as the others.

The Obvious Solutions — and Why They All Hit a Wall

The Obvious Solutions — and Why They All Hit a Wall

  • Attempt 1 — One masking policy for everyone.  PII-access role sees clear text, everyone else sees masked. Two weeks in, the claims agent can’t see phone numbers and can’t do their job — so IT grants them the PII role. Now that agent also sees SSN and DOB they never needed.
  • Attempt 2 — A separate view per persona. Build CUSTOMER_360_CLAIMS_VW, CUSTOMER_360_UNDERWRITER_VW, and so on. It works — until someone adds a column. Now you update five views, retest five views, re-grant on five views. BI users see five near-identical tables and pick the wrong one. A sixth persona arrives, you build a sixth view. The maintenance tax never ends.
  • Multiple tags, each with its own masking policy → Create CLAIMS_ACCESS, UNDERWRITER_ACCESS, FRAUD_ACCESS tags, attach a policy to each, tag the NAME column with all three. The second attachment fails — Snowflake allows only one masking policy per column for a given data type.

What we actually want: one physical column, one masking policy, persona-level decryption driven by metadata that any tag-admin can edit.

The Approach

Two tags do all the work. Only one carries the policy.

Approach

The classification tag is the trigger. Whenever Snowflake sees a column tagged with PII_CLASS, it runs the masking policy. Inside that policy, we ask a second question by reading the column’s own DECRYPT_PERSONAS tag at evaluation time — that’s what makes the same policy behave differently for every column.

The decision tree inside the policy:

Tree

Use of Tags and Masking Policy

A quick mental model before the code:

  • PII_CLASS is the classification — it answers “is this column sensitive?” It carries the masking policy.
  • DECRYPT_PERSONAS is the access control list — it answers “who is allowed to see this column in clear text?” It carries no policy. It’s just metadata that the policy reads.
  • The masking policy is the brain. It checks CURRENT_ROLE() against the allow-list and decides whether to call the decrypt function.

This separation matters: classification rarely changes, but access rules change all the time. Keeping the two concerns on two tags lets your access management team modify allow-lists without ever touching policy logic.

The Solution — Complete Working Code

Step 1 — Setup: database, schema, and encryption helpers

CREATE DATABASE IF NOT EXISTS CY_GOV_DEMO;
CREATE SCHEMA   IF NOT EXISTS CY_GOV_DEMO.PRIVACY;
CREATE SCHEMA   IF NOT EXISTS CY_GOV_DEMO.ANALYTICS;

Two SQL UDFs handle encryption. We prefix the ciphertext with ENC:: so the masking policy can cheaply detect whether a value is encrypted or already plaintext.

CREATE OR REPLACE FUNCTION CY_GOV_DEMO.PRIVACY.FN_ENC(PLAIN STRING)
RETURNS STRING
LANGUAGE SQL
AS
$$
CASE
WHEN PLAIN IS NULL THEN NULL
ELSE 'ENC::' || HEX_ENCODE(
ENCRYPT(TO_BINARY(PLAIN, 'UTF-8'), 'cy_demo_passphrase_2026')
)
END
$$;

CREATE OR REPLACE FUNCTION CY_GOV_DEMO.PRIVACY.FN_DEC(CIPHER STRING)
RETURNS STRING
LANGUAGE SQL
AS
$$
CASE
WHEN CIPHER IS NULL              THEN NULL
WHEN CIPHER NOT LIKE 'ENC::%'    THEN CIPHER
ELSE TO_VARCHAR(
DECRYPT(HEX_DECODE_BINARY(SUBSTR(CIPHER, 6)), 'cy_demo_passphrase_2026'),
'UTF-8'
)
END
$$;

Step 2 — Create the customer table and load plaintext.

Customer_360

Step 3 — Encrypt the PII columns at rest

UPDATE CY_GOV_DEMO.ANALYTICS.CUSTOMER_360
SET NAME  = CY_GOV_DEMO.PRIVACY.FN_ENC(NAME),
EMAIL = CY_GOV_DEMO.PRIVACY.FN_ENC(EMAIL),
PHONE = CY_GOV_DEMO.PRIVACY.FN_ENC(PHONE),
SSN   = CY_GOV_DEMO.PRIVACY.FN_ENC(SSN),
DOB   = CY_GOV_DEMO.PRIVACY.FN_ENC(DOB);

SELECT * FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360;

Encryption Table

At this point the data is encrypted at rest. Without the masking policy and a permitted role, nobody can read the PII columns in clear text — not even data engineers querying the table directly.

Step 4 — Create the two tags

— Tag 1: classification — will carry the masking policy
CREATE OR REPLACE TAG CY_GOV_DEMO.PRIVACY.PII_CLASS
ALLOWED_VALUES 'IDENTIFIER', 'SENSITIVE', 'QUASI'
COMMENT = 'PII classification — masking policy attached here';

— Tag 2: metadata-only persona allow-list per column
CREATE OR REPLACE TAG CY_GOV_DEMO.PRIVACY.DECRYPT_PERSONAS
COMMENT = 'Comma-separated list of persona roles allowed to decrypt this column';

Step 5 — Define the persona-aware masking policy

Masking Policy

Step 6 — Apply the tags to every PII column

The classification tag drives policy invocation. The persona allow-list tag drives the decision inside the policy.

Tag Apply

Step 7 — Attach the masking policy to the classification tag

ALTER TAG CY_GOV_DEMO.PRIVACY.PII_CLASS SET MASKING POLICY CY_GOV_DEMO.PRIVACY.MP_PERSONA_DECRYPT;

Step 8 — Create roles and grant table access

USE ROLE SECURITYADMIN;

1.CREATE ROLE IF NOT EXISTS R_CUSTOMER_READ;
2.CREATE ROLE IF NOT EXISTS R_CLAIMS_AGENT_PII;
3.CREATE ROLE IF NOT EXISTS R_UNDERWRITER_PII;
4.CREATE ROLE IF NOT EXISTS R_FRAUD_OPS_PII;
5.CREATE ROLE IF NOT EXISTS R_PII_ADMIN;

We have granted all required privileges  to these roles so that they can access the DB,Schema,Tables. Grant all roles to a single test user so we can switch contexts easily.

GRANT ROLE R_CUSTOMER_READ    TO USER SACHINMITTAL;
GRANT ROLE R_CLAIMS_AGENT_PII TO USER SACHINMITTAL;
GRANT ROLE R_UNDERWRITER_PII  TO USER SACHINMITTAL;
GRANT ROLE R_FRAUD_OPS_PII    TO USER SACHINMITTAL;
GRANT ROLE R_PII_ADMIN        TO USER SACHINMITTAL;

Step 9 — Test by switching active roles

USE ROLE R_CUSTOMER_READ;
SELECT CUSTOMER_ID, NAME, EMAIL, PHONE, SSN, DOB
FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360 WHERE CUSTOMER_ID = 1001;

R_CUSTOMER_READ

USE ROLE R_CLAIMS_AGENT_PII;
SELECT CUSTOMER_ID, NAME, EMAIL, PHONE, SSN, DOB
FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360 WHERE CUSTOMER_ID = 1001;

R_CLAIMS_AGENT_PII

USE ROLE R_UNDERWRITER_PII;
SELECT CUSTOMER_ID, NAME, EMAIL, PHONE, SSN, DOB
FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360 WHERE CUSTOMER_ID = 1001;

R_Underwriter_PII

USE ROLE R_FRAUD_OPS_PII;
SELECT CUSTOMER_ID, NAME, EMAIL, PHONE, SSN, DOB
FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360
WHERE CUSTOMER_ID = 1001;

R_FRAUD_OPS_PII

USE ROLE R_PII_ADMIN;
SELECT CUSTOMER_ID, NAME, EMAIL, PHONE, SSN, DOB
FROM CY_GOV_DEMO.ANALYTICS.CUSTOMER_360
WHERE CUSTOMER_ID = 1001;

R_PII_ADMIN

The Role × Column Matrix

This is the part that tells the whole story at a glance. Same table, same query, the only thing that changes is USE ROLE.

Role Matrix
Role Details

Why This Design Holds Up

  • One policy, not five. No policy conflicts on any column. New persona = ALTER TABLE … MODIFY COLUMN … SET TAG … to update an allow-list. That’s it.
  • Metadata-driven access control. Anyone with tag-admin rights can change who sees what — without touching policy code or being a Snowflake DDL expert.
  • No table copies, no view sprawl. One physical table serves every persona. BI tools, dbt models, downstream consumers all point at the same object.
  • Tier-1 escape hatch built in. The R_PII_ADMIN branch ensures compliance and audit workflows can always see everything, regardless of per-column allow-lists.

Average Rating

5 Star
0%
4 Star
0%
3 Star
0%
2 Star
0%
1 Star
0%

Leave a Reply

Your email address will not be published. Required fields are marked *