Data Governance in Snowflake: Row-Level Security, Masking Policies, and Access Controls

Celestinfo Software Solutions Pvt. Ltd. Nov 13, 2025

Last updated: December 2025

Quick answer: Snowflake's governance model centers on RBAC with role hierarchies, row access policies for row-level security, dynamic data masking policies for column-level protection, secure views for hiding query logic, and object tagging for automated classification. Start with a clean role hierarchy (functional roles separate from data access roles), add row access policies and masking where compliance requires it, and use ACCESS_HISTORY for auditing. Gotcha: masking policies can't reference other tables directly, and row access policies add overhead to every query - test with production-scale data before rolling out.

Introduction

Snowflake ships with a solid set of governance features, but most teams don't use them effectively. They'll create a handful of roles, slap a masking policy on the SSN column, and call it governed. Then 6 months later, an auditor asks who accessed customer PII last quarter and nobody can answer the question. This guide covers the practical implementation of Snowflake's governance features - the parts that matter for real compliance requirements, with the syntax and gotchas you'll hit along the way.

Snowflake's RBAC Model: Building a Role Hierarchy That Works

Snowflake uses role-based access control as its foundation. Every privilege - SELECT on a table, USAGE on a warehouse, CREATE TABLE in a schema - gets granted to a role, and roles get granted to users (or other roles). The mistake most teams make is creating one role per person or one role per team. Neither scales.

The pattern that works: separate functional roles from data access roles. Functional roles define what someone can do (run queries, create views, manage pipelines). Data access roles define what data someone can see.

-- Functional roles
CREATE ROLE analyst;
CREATE ROLE data_engineer;
CREATE ROLE data_admin;

-- Data access roles
CREATE ROLE pii_reader;
CREATE ROLE finance_data;
CREATE ROLE marketing_data;

-- Compose: an analyst who needs finance data
GRANT ROLE analyst TO USER jane;
GRANT ROLE finance_data TO USER jane;

-- Role hierarchy: data_admin inherits from data_engineer
GRANT ROLE data_engineer TO ROLE data_admin;

This composable approach means you don't need to create a finance_analyst AND a marketing_analyst AND a finance_engineer role. You mix and match. When a new department comes along, you create one new data access role instead of N new combined roles.

Row-Level Security with Row Access Policies

Row access policies let you filter which rows a user sees based on their role or identity. The policy is a SQL expression that returns a boolean - if it returns TRUE for a row, the user sees it. If FALSE, the row is invisible.

-- Create a row access policy for multi-tenant data
CREATE OR REPLACE ROW ACCESS POLICY department_filter
  AS (department_id VARCHAR) RETURNS BOOLEAN ->
    CURRENT_ROLE() = 'DATA_ADMIN'
    OR department_id = CURRENT_SESSION()::VARIANT:department_id::VARCHAR
    OR IS_ROLE_IN_SESSION('ALL_DEPARTMENTS');

-- Apply it to the table
ALTER TABLE sales.orders
  ADD ROW ACCESS POLICY department_filter ON (department_id);

The policy above lets DATA_ADMIN see everything, users with the ALL_DEPARTMENTS role see everything, and everyone else only sees rows where department_id matches their session variable. This is the multi-tenant pattern - each department sees only their own data from the same table.

Performance gotcha: Row access policies add a predicate to every query against the protected table. Snowflake evaluates the policy condition for every row. On tables with 100M+ rows, this can add 10-30% overhead depending on the policy complexity. If your policy column aligns with the table's clustering key, the overhead is minimal because Snowflake can prune micro-partitions. If it doesn't, you're scanning more data. Test with representative workloads before deploying to production.

Dynamic Data Masking

Masking policies let you hide or partially reveal column values based on the querying user's role. Unlike row access policies (which filter rows), masking policies transform column values in place.

-- Full masking: non-privileged roles see '***'
CREATE OR REPLACE MASKING POLICY mask_ssn
  AS (val STRING) RETURNS STRING ->
    CASE
      WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
      ELSE '***-**-****'
    END;

-- Partial masking: show last 4 digits of phone
CREATE OR REPLACE MASKING POLICY mask_phone
  AS (val STRING) RETURNS STRING ->
    CASE
      WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
      ELSE CONCAT('***-***-', RIGHT(val, 4))
    END;

-- Apply to columns
ALTER TABLE customers.profiles
  MODIFY COLUMN ssn SET MASKING POLICY mask_ssn;
ALTER TABLE customers.profiles
  MODIFY COLUMN phone SET MASKING POLICY mask_phone;

Critical gotcha: Masking policies can't reference other tables directly. You can't write a policy that says "look up this user in an access control table and decide whether to mask." You're limited to context functions: CURRENT_ROLE(), CURRENT_USER(), IS_ROLE_IN_SESSION(), and session variables. If you need lookup-based masking, create a secure UDF that does the lookup and call it from your masking policy - but be aware this adds query latency.

Secure Views vs. Regular Views

Regular views in Snowflake expose the view definition to anyone with SELECT privilege. That's a problem if the view logic itself reveals something about the underlying data structure you don't want users to know. Secure views solve this in two ways: they hide the view definition, and they prevent the optimizer from pushing user predicates into the view query.

CREATE OR REPLACE SECURE VIEW analytics.customer_summary AS
SELECT
  customer_id,
  region,
  total_orders,
  lifetime_value
FROM raw.customers
WHERE is_active = TRUE;

With a regular view, a clever user could add WHERE is_active = FALSE and potentially infer information about inactive customers through error messages or timing differences. With a secure view, the optimizer won't push that predicate down. The tradeoff: secure views can be 10-20% slower because the optimizer has fewer options for query planning. Use secure views for anything involving sensitive data or row filtering logic. Use regular views for everything else.

Object Tagging for Data Classification

Snowflake's object tagging lets you attach metadata to databases, schemas, tables, and columns. This is how you classify data (PII, PHI, financial, public) without maintaining a separate data catalog.

-- Create tag
CREATE OR REPLACE TAG governance.sensitivity
  ALLOWED_VALUES 'PII', 'PHI', 'FINANCIAL', 'INTERNAL', 'PUBLIC';

-- Apply tags
ALTER TABLE customers.profiles
  MODIFY COLUMN email SET TAG governance.sensitivity = 'PII';
ALTER TABLE customers.profiles
  MODIFY COLUMN ssn SET TAG governance.sensitivity = 'PII';

-- Auto-classify using SYSTEM$CLASSIFY (Enterprise+)
SELECT SYSTEM$CLASSIFY('customers.profiles', {'auto_tag': true});

SYSTEM$CLASSIFY uses pattern matching and sampling to automatically detect sensitive columns (emails, phone numbers, SSNs) and tag them. It's not perfect - it misses custom identifiers and sometimes flags non-sensitive columns that happen to look like phone numbers - but it's a good starting point that you then refine manually.

Access History and Auditing

Snowflake's ACCESS_HISTORY view in the ACCOUNT_USAGE schema tracks which columns were accessed by which queries. Combined with QUERY_HISTORY, this gives you a complete audit trail.

-- Who accessed PII columns in the last 30 days?
SELECT
  user_name,
  query_id,
  query_start_time,
  direct_objects_accessed
FROM snowflake.account_usage.access_history
WHERE query_start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
  AND ARRAY_CONTAINS('customers.profiles.ssn'::VARIANT,
    ARRAY_AGG(f.value:columnName::STRING))
ORDER BY query_start_time DESC;

ACCESS_HISTORY retains data for 365 days. For longer retention, export it to a separate table on a schedule. Pair this with your broader governance and cost optimization framework to track not just who accessed data, but what it cost to serve those queries.

Network Policies for IP Whitelisting

Network policies restrict which IP addresses can connect to your Snowflake account. This is a blunt but effective control - even if someone has valid credentials, they can't connect from an unauthorized network.

CREATE NETWORK POLICY office_only
  ALLOWED_IP_LIST = ('203.0.113.0/24', '198.51.100.0/24')
  BLOCKED_IP_LIST = ('203.0.113.99');

-- Apply account-wide
ALTER ACCOUNT SET NETWORK_POLICY = office_only;

-- Or apply to specific users
ALTER USER contractor_jane SET NETWORK_POLICY = office_only;

You can apply network policies at the account level or per-user. Per-user policies override account policies. For organizations using Azure PrivateLink or AWS PrivateLink, network policies work alongside private connectivity to provide defense in depth.

Putting It Together: Multi-Tenant Data Access Model

Here's how these features compose for a real scenario - a multi-tenant analytics platform where each department sees only their data, PII is masked for non-privileged users, and everything is auditable:

Key Takeaways

CelestInfo Engineering Team

Data engineering and cloud consulting. We build governance frameworks, migrate warehouses, and make data platforms that pass audits without surprises. About us

Related Articles

Frequently Asked Questions

Can Snowflake masking policies reference other tables?

No, masking policies can't directly reference other tables. You need to use context functions like CURRENT_ROLE(), CURRENT_USER(), or IS_ROLE_IN_SESSION() to make masking decisions. If you need lookup-based masking, create a secure UDF that performs the lookup and call it from your masking policy.

Do row access policies affect query performance in Snowflake?

Yes. Row access policies add a predicate to every query against the protected table, which means Snowflake evaluates the policy condition for every row. For large tables (100M+ rows), this can add measurable overhead. Always test with representative workloads and consider whether clustering keys align with the columns used in your row access policy.

What is the difference between secure views and regular views in Snowflake?

Secure views hide the view definition (SQL query) from users who don't own the view, and the Snowflake query optimizer won't push predicates from user queries into the view definition. This prevents users from inferring underlying data through query plan analysis. Regular views expose the definition and allow predicate pushdown, which is faster but less secure.

How do I audit who accessed what data in Snowflake?

Snowflake's ACCESS_HISTORY view in the ACCOUNT_USAGE schema tracks which columns were accessed by which queries. Combined with QUERY_HISTORY, you can build a complete audit trail of who queried what data, when, and how. ACCESS_HISTORY has a 365-day retention period.

Ready? Let's Talk!

Get expert insights and answers tailored to your business requirements and transformation.