05 · Object Tagging & Data Classification¶
Attaching a masking policy to each column (lesson 04) works for ten columns. It doesn't work for ten thousand across
hundreds of tables that teams create every week. Tags let you describe data once (pii = 'email', cost_center =
'marketing') and drive governance from those descriptions: masking that follows the tag, cost reports by tag, audits of
where sensitive data lives. Sensitive data classification goes a step further and proposes the tags for you. This lesson
covers both, including why proposed tags still need a human.
Creating and setting tags¶
USE ROLE SECURITYADMIN;
CREATE SCHEMA IF NOT EXISTS security_db.tags;
CREATE TAG IF NOT EXISTS security_db.tags.pii
ALLOWED_VALUES 'email', 'phone', 'name', 'address', 'national_id'
COMMENT = 'Kind of personal data in a column';
CREATE TAG IF NOT EXISTS security_db.tags.cost_center
COMMENT = 'Who pays for this object';
ALTER TABLE sales.mart.customers MODIFY COLUMN email SET TAG security_db.tags.pii = 'email';
ALTER TABLE sales.mart.customers MODIFY COLUMN phone SET TAG security_db.tags.pii = 'phone';
ALTER WAREHOUSE bi_wh SET TAG security_db.tags.cost_center = 'analytics';
ALTER DATABASE sales SET TAG security_db.tags.cost_center = 'sales';
From Snowflake's documentation:
ALLOWED_VALUESrestricts a tag to a fixed vocabulary, so nobody can setpii = 'emial'.- Tags are inherited down the object hierarchy. A tag set on a database applies to its schemas, tables and columns,
unless a lower level sets its own value.
cost_center = 'sales'on the database tags every table in it. - Tags can be configured to propagate automatically from source objects to targets (for example, from a table to tables created from it).
- Quotas: at most 50 tags per object, and at most 50 different tags across the columns of one table.
Finding tags:
SELECT * FROM TABLE(sales.INFORMATION_SCHEMA.TAG_REFERENCES('sales.mart.customers', 'TABLE'));
SELECT * FROM TABLE(sales.INFORMATION_SCHEMA.TAG_REFERENCES_ALL_COLUMNS('sales.mart.customers', 'TABLE'));
SELECT object_database, object_schema, object_name, column_name, tag_value
FROM SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES
WHERE tag_name = 'PII'
ORDER BY 1, 2, 3, 4;
The apply_method column in these results shows whether a tag was set manually, inherited or propagated.
Tag-based masking: protect every tagged column¶
Set masking policies on the tag, and every column carrying that tag is masked by the policy matching the column's data
type, with no per-column ALTER:
CREATE OR REPLACE MASKING POLICY security_db.policies.pii_string
AS (val VARCHAR) RETURNS VARCHAR ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('security_db.tags.pii') = 'email' THEN '***@' || SPLIT_PART(val, '@', 2)
ELSE '*** masked ***'
END;
CREATE OR REPLACE MASKING POLICY security_db.policies.pii_number
AS (val NUMBER) RETURNS NUMBER ->
CASE WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val ELSE NULL END;
ALTER TAG security_db.tags.pii SET MASKING POLICY security_db.policies.pii_string,
MASKING POLICY security_db.policies.pii_number;
- A tag can carry one masking policy per data type. A
VARCHARcolumn taggedpiiusespii_string, and aNUMBERcolumn usespii_number. SYSTEM$GET_TAG_ON_CURRENT_COLUMNlets one policy behave differently per tag value: partial masking for emails, full masking for everything else.- A masking policy assigned directly to a column takes precedence over a tag-based one, which is how you make exceptions.
- Because tags can be set at the table, schema or database level, tagging a whole schema
piimasks every string column in it. That's powerful, and too broad for most schemas. Tag columns.
The effect is a governance workflow where new tables are protected as soon as their columns are tagged correctly, which moves the problem to getting the tags right.
Sensitive data classification¶
Sensitive data classification (Enterprise Edition) samples column values, assigns each sensitive column a semantic
category (such as EMAIL, NAME, PHONE_NUMBER) and a privacy category (identifier, quasi-identifier, sensitive),
and records them as system tags (SNOWFLAKE.CORE.SEMANTIC_CATEGORY, SNOWFLAKE.CORE.PRIVACY_CATEGORY). A classification
profile associated with a database classifies its tables and views automatically, and can map those system tags onto your
own tags, so a column classified as EMAIL gets security_db.tags.pii = 'email' and therefore the masking policy:
CREATE OR REPLACE SNOWFLAKE.DATA_PRIVACY.CLASSIFICATION_PROFILE security_db.tags.default_profile({
'minimum_object_age_for_classification_days': 0,
'auto_tag': true
});
ALTER DATABASE sales SET CLASSIFICATION_PROFILE = 'security_db.tags.default_profile';
The current documentation also describes custom categories (your own detectors) and an optional AI mode. Check it for the profile options available in your account. This feature wasn't run for this course.
Why classification still needs review¶
Classification is sample-based and probabilistic. To see why that matters, here is a toy classifier (written for this lesson, not Snowflake's) that samples 1,000 customers from the course data and tests each column against simple detectors:
"""A toy column classifier (NOT Snowflake's): sample values, test each column against simple detectors,
and report the share of non-null sampled values that match. Illustrates why classification is
sample-based and probabilistic."""
import json, random, re
random.seed(1)
rows = [json.loads(l) for l in open("customers.json")]
flat = [{"customer_id": r["customer_id"], "name": r["name"], "country": r["country"], "signup": r["signup"],
"email": r["contact"]["email"], "phone": r["contact"]["phone"]} for r in rows]
DETECTORS = {
"EMAIL": re.compile(r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$"),
"PHONE_NUMBER": re.compile(r"^\+?[0-9][0-9\- ]{6,}$"),
"COUNTRY": re.compile(r"^(PT|ES|FR|DE|IN|US|BR|GB)$"),
"DATE": re.compile(r"^\d{4}-\d{2}-\d{2}$"),
}
sample = random.sample(flat, 1000)
for col in flat[0]:
values = [str(r[col]) for r in sample if r[col] is not None]
best = max(DETECTORS, key=lambda d: sum(bool(DETECTORS[d].match(v)) for v in values))
share = sum(bool(DETECTORS[best].match(v)) for v in values) / len(values)
nulls = sum(r[col] is None for r in sample)
label = best if share >= 0.8 else "-"
print(f"{col:12} nulls {nulls:>3}/1000 best match {best:13} {share:6.1%} -> {label}")
customer_id nulls 0/1000 best match EMAIL 0.0% -> -
name nulls 0/1000 best match EMAIL 0.0% -> -
country nulls 0/1000 best match COUNTRY 100.0% -> COUNTRY
signup nulls 0/1000 best match PHONE_NUMBER 100.0% -> PHONE_NUMBER
email nulls 0/1000 best match EMAIL 100.0% -> EMAIL
phone nulls 120/1000 best match PHONE_NUMBER 100.0% -> PHONE_NUMBER
It found the email and country columns correctly, and found phone numbers despite 120 null values in the sample. It also
labelled signup dates as phone numbers: a value like 2024-11-23 matches a loose phone pattern just as well as a date
pattern, and the tie went to the detector listed first. Snowflake's classifier is far more sophisticated than three regular
expressions, but the general lesson carries over. Automated classification produces proposals with false positives and
false negatives (a free-text "notes" column full of phone numbers that only sometimes matches, an ID that looks like a
national identifier). Review the proposed categories, especially before they drive masking automatically, and audit the
results periodically.
Tags for cost allocation¶
Tags aren't only for security. Tagging warehouses, databases and users with cost_center lets you attribute credits:
SELECT t.tag_value AS cost_center, SUM(m.credits_used) AS credits_30d
FROM SNOWFLAKE.ACCOUNT_USAGE.WAREHOUSE_METERING_HISTORY m
JOIN SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES t
ON t.domain = 'WAREHOUSE' AND t.object_name = m.warehouse_name AND t.tag_name = 'COST_CENTER'
WHERE m.start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
GROUP BY 1
ORDER BY 2 DESC;
Level 4 · 05 builds on this for chargeback.
How It Actually Works¶
A tag is a schema-level object, and a tag assignment is metadata linking (tag, value) to an object or column. When a query
touches a column, the compiler resolves its effective tags: the column's own assignments, plus those inherited from its
table, schema and database, with the most specific level winning. If any effective tag has a masking policy for the
column's data type (and no masking policy is set directly on the column), the compiler applies that policy exactly as in
lesson 04, with SYSTEM$GET_TAG_ON_CURRENT_COLUMN returning the resolved tag value. Classification runs as a background
service that reads samples of column values, evaluates them with its detectors, and writes system tag assignments, which
from then on behave like any other tags.
Common mistakes¶
- Free-form tag values. Use
ALLOWED_VALUES. - Tagging at the schema level for masking. Every column of that type gets masked. Tag the columns.
- One masking policy per tag "for strings" and nothing for numbers or dates, leaving those columns unprotected.
- Trusting automatic classification without review. It proposes, and someone accountable approves.
- Tags that nobody queries. Build the audit and cost reports that make them worth maintaining.
- Forgetting direct policies override tag policies. An old per-column policy silently wins.
Exercise¶
- Create the
piitag with allowed values and tag-based masking policies forVARCHARandNUMBER, then tag the course's customer columns and verify masking withPOLICY_CONTEXTfor a role withoutPII_READER. - Tag the
salesdatabase withcost_center = 'sales'and one table withcost_center = 'marketing'. QueryTAG_REFERENCESand confirm which value each table resolves to. - Fix the toy classifier so dates aren't classified as phone numbers (a stricter phone pattern? a priority order?). Then construct an input that fools your fixed version.
- In a trial account, associate a classification profile with a database containing the course's customers, and compare Snowflake's semantic categories with the toy's.