01 · Access Control: Roles, Privileges & Hierarchies¶
In Snowflake, privileges are granted to roles, and roles are granted to users (and to other roles). Nothing is accessible unless a grant allows it. That model is simple, and in a real account it grows into hundreds of roles and thousands of grants that nobody can hold in their head. This lesson covers the built-in roles, the design pattern that keeps custom roles manageable, the features that keep grants from rotting, and a small offline tool for answering "who can read this table?" from a grant script.
The system roles¶
From Snowflake's documentation:
| Role | Purpose |
|---|---|
ACCOUNTADMIN |
Top-level role. Encapsulates SYSADMIN and SECURITYADMIN; account parameters, billing. Grant to very few users. |
SECURITYADMIN |
Manages any grant globally (MANAGE GRANTS); creates and manages users and roles. Inherits USERADMIN. |
USERADMIN |
Creates users and roles (CREATE USER, CREATE ROLE). |
SYSADMIN |
Creates warehouses, databases and the objects in them. Custom roles should roll up to it. |
PUBLIC |
Granted automatically to every user and role. Anything it owns is available to everyone. |
ORGADMIN / GLOBALORGADMIN |
Organization-level tasks (accounts, org-wide usage). GLOBALORGADMIN exists only in the organization account. |
Snowflake's own recommendations for ACCOUNTADMIN: require MFA for its users, assign it to at least two users
(resetting a lone administrator's lost credentials goes through support procedures that can take up to two business
days), and don't use it to create objects. Day-to-day work happens in custom roles under SYSADMIN.
Designing roles: access roles and functional roles¶
The pattern that scales is two layers:
- Access roles hold privileges on objects, one per (container, level):
sales_mart_read,sales_mart_write,sales_raw_read. They are never granted to users directly. - Functional roles describe what people do (
analyst,data_engineer,finance_viewer) and are composed from access roles. Users get functional roles.
USE ROLE USERADMIN;
CREATE ROLE IF NOT EXISTS sales_mart_read;
CREATE ROLE IF NOT EXISTS sales_mart_write;
CREATE ROLE IF NOT EXISTS sales_raw_read;
CREATE ROLE IF NOT EXISTS analyst;
CREATE ROLE IF NOT EXISTS data_engineer;
USE ROLE SECURITYADMIN;
GRANT USAGE ON DATABASE sales TO ROLE sales_mart_read;
GRANT USAGE ON SCHEMA sales.mart TO ROLE sales_mart_read;
GRANT SELECT ON ALL TABLES IN SCHEMA sales.mart TO ROLE sales_mart_read;
GRANT SELECT ON FUTURE TABLES IN SCHEMA sales.mart TO ROLE sales_mart_read;
GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA sales.mart TO ROLE sales_mart_write;
GRANT INSERT, UPDATE, DELETE ON FUTURE TABLES IN SCHEMA sales.mart TO ROLE sales_mart_write;
GRANT ROLE sales_mart_read TO ROLE sales_mart_write;
GRANT USAGE ON DATABASE sales TO ROLE sales_raw_read;
GRANT USAGE ON SCHEMA sales.raw TO ROLE sales_raw_read;
GRANT SELECT ON ALL TABLES IN SCHEMA sales.raw TO ROLE sales_raw_read;
GRANT SELECT ON FUTURE TABLES IN SCHEMA sales.raw TO ROLE sales_raw_read;
GRANT ROLE sales_mart_read TO ROLE analyst;
GRANT USAGE ON WAREHOUSE bi_wh TO ROLE analyst;
GRANT ROLE analyst TO ROLE data_engineer;
GRANT ROLE sales_mart_write TO ROLE data_engineer;
GRANT ROLE sales_raw_read TO ROLE data_engineer;
GRANT USAGE ON WAREHOUSE etl_wh TO ROLE data_engineer;
-- every functional role rolls up to SYSADMIN, so administrators can manage what these roles own
GRANT ROLE analyst TO ROLE SYSADMIN;
GRANT ROLE data_engineer TO ROLE SYSADMIN;
GRANT ROLE analyst TO USER ana;
GRANT ROLE data_engineer TO USER ben;
Three details make the difference between grants that work and grants that rot:
- Reading a table needs
USAGEon its database and schema as well asSELECTon the table. MissingUSAGEis the most common "it says the table doesn't exist" problem. ON FUTURE TABLESgrants privileges on tables created later.ON ALL TABLEScovers only those that exist now. You almost always want both.- A role inherits everything granted to roles granted to it.
data_engineergets everythinganalystcan do.
Inspecting grants¶
SHOW GRANTS TO ROLE analyst; -- what this role has been granted
SHOW GRANTS OF ROLE analyst; -- who (users, roles) has this role
SHOW GRANTS ON TABLE sales.mart.daily_revenue;
SHOW FUTURE GRANTS IN SCHEMA sales.mart;
SHOW GRANTS TO USER ana;
These show direct grants. Effective access through the hierarchy has to be computed, and that's the job of the next
section's tool (or of the GRANTS_TO_ROLES and GRANTS_TO_USERS views in ACCOUNT_USAGE, Level 3 · 09).
Who can read this table? An offline analyser¶
Grant scripts belong in version control, and reviewing them is much easier with a tool that computes effective access. Here is a deliberately small model of Snowflake's RBAC in Python. It ignores ownership, secondary roles and database roles, and covers the grant forms above:
"""A small model of Snowflake role-based access control, for reasoning about grant scripts offline.
Understands: GRANT ROLE r TO ROLE p | GRANT ROLE r TO USER u |
GRANT <privs> ON <TYPE> <name> TO ROLE r (privs comma-separated; TYPE = WAREHOUSE, DATABASE, SCHEMA, TABLE, VIEW)
GRANT <privs> ON ALL|FUTURE TABLES IN SCHEMA <schema> TO ROLE r
It computes effective privileges through the role hierarchy (a role has the privileges of every role granted to it).
It is a teaching model: it ignores ownership, secondary roles, database roles and managed-access rules."""
import re
from collections import defaultdict
class Account:
def __init__(self):
self.parents = defaultdict(set) # role -> roles it is granted to (its "parents")
self.children = defaultdict(set) # role -> roles granted to it (it inherits these)
self.user_roles = defaultdict(set)
self.privs = defaultdict(set) # role -> {(PRIV, TYPE, NAME)}
self.roles = {"ACCOUNTADMIN", "SECURITYADMIN", "USERADMIN", "SYSADMIN", "PUBLIC"}
for child, parent in [("SYSADMIN", "ACCOUNTADMIN"), ("SECURITYADMIN", "ACCOUNTADMIN"), ("USERADMIN", "SECURITYADMIN")]:
self._role_to_role(child, parent)
def _role_to_role(self, child, parent):
self.children[parent].add(child); self.parents[child].add(parent); self.roles |= {child, parent}
def run(self, script):
script = re.sub(r"--[^\n]*", "", script) # drop comments first, then split statements
for stmt in [s.strip() for s in script.split(";") if s.strip()]:
s = re.sub(r"\s+", " ", stmt).upper()
if s.startswith("USE ROLE "):
continue # session context only
if m := re.fullmatch(r"CREATE ROLE (IF NOT EXISTS )?(\S+)", s):
self.roles.add(m[2])
elif m := re.fullmatch(r"GRANT ROLE (\S+) TO ROLE (\S+)", s):
self._role_to_role(m[1], m[2])
elif m := re.fullmatch(r"GRANT ROLE (\S+) TO USER (\S+)", s):
self.user_roles[m[2]].add(m[1])
elif m := re.fullmatch(r"GRANT (.+?) ON (ALL|FUTURE) TABLES IN SCHEMA (\S+) TO ROLE (\S+)", s):
for p in m[1].split(","):
self.privs[m[4]].add((p.strip(), "TABLE", m[3] + ".*"))
elif m := re.fullmatch(r"GRANT (.+?) ON (WAREHOUSE|DATABASE|SCHEMA|TABLE|VIEW) (\S+) TO ROLE (\S+)", s):
for p in m[1].split(","):
self.privs[m[4]].add((p.strip(), m[2], m[3]))
else:
raise ValueError(f"not understood: {stmt}")
def inherited(self, role):
seen, stack = set(), [role]
while stack:
r = stack.pop()
if r not in seen:
seen.add(r); stack.extend(self.children[r])
return seen
def effective(self, role):
return {p for r in self.inherited(role) for p in self.privs[r]}
def can(self, role, priv, typ, name):
for p, t, n in self.effective(role):
if p in (priv, "ALL") and t == typ and (n == name or (n.endswith(".*") and name.startswith(n[:-1]))):
return True
return False
def who_can(self, priv, typ, name):
return sorted(u for u, rs in self.user_roles.items() if any(self.can(r, priv, typ, name) for r in rs))
def orphans(self):
"""custom roles that SYSADMIN does not inherit: objects they own escape SYSADMIN's oversight"""
return sorted(r for r in self.roles if r not in self.inherited("SYSADMIN") and r not in
{"ACCOUNTADMIN", "SECURITYADMIN", "USERADMIN", "PUBLIC"} and r not in self.inherited("SECURITYADMIN"))
Run against the grant script above, plus an intern role that someone granted directly on a PII table and forgot to
attach to the hierarchy:
acct = Account()
acct.run(open("grants.sql").read()) # the script above, plus the intern's three statements
for q in [("SELECT", "TABLE", "SALES.MART.DAILY_REVENUE"), ("SELECT", "TABLE", "SALES.RAW.CUSTOMERS_PII"),
("UPDATE", "TABLE", "SALES.MART.DAILY_REVENUE")]:
print(f"{q[0]:6} on {q[2]:26} -> users: {acct.who_can(*q)}")
print("data_engineer inherits:", sorted(acct.inherited("DATA_ENGINEER")))
print("roles SYSADMIN cannot see into:", acct.orphans())
SELECT on SALES.MART.DAILY_REVENUE -> users: ['ANA', 'BEN']
SELECT on SALES.RAW.CUSTOMERS_PII -> users: ['BEN', 'CY']
UPDATE on SALES.MART.DAILY_REVENUE -> users: ['BEN']
data_engineer inherits: ['ANALYST', 'DATA_ENGINEER', 'SALES_MART_READ', 'SALES_MART_WRITE', 'SALES_RAW_READ']
roles SYSADMIN cannot see into: ['INTERN']
The report surfaces two things a reviewer wants to know: the intern can read the PII table, through a direct grant
nobody would find by looking at the access roles, and INTERN is an orphan outside the SYSADMIN hierarchy, so
objects it creates would be invisible to the administrators who are supposed to manage them.
The first version of this tool had its own bug, and it's instructive. It dropped any statement that started with a
comment line, so the -- users heading silently swallowed GRANT ROLE analyst TO USER ana, and the report said Ana
couldn't read the mart. A security tool that silently misreads its input is worse than none. Test it on cases where you
know the answer.
Keeping grants manageable¶
Managed access schemas. In a normal schema, each object's owner can grant access to it. In a schema created
WITH MANAGED ACCESS, only the schema owner (or a role with MANAGE GRANTS) can grant privileges on objects inside,
which centralizes control:
Database roles are roles that live inside a database. They're the unit for granting access to shared databases (Level 3 · 07), and they travel with a database when it's cloned or replicated:
CREATE DATABASE ROLE sales.reader;
GRANT USAGE ON SCHEMA sales.mart TO DATABASE ROLE sales.reader;
GRANT SELECT ON ALL TABLES IN SCHEMA sales.mart TO DATABASE ROLE sales.reader;
GRANT DATABASE ROLE sales.reader TO ROLE analyst;
Secondary roles. A session has one primary role, which determines what owns newly created objects, and can also activate secondary roles whose privileges are combined for reads:
USE SECONDARY ROLES ALL; -- every role granted to the user contributes privileges
USE SECONDARY ROLES NONE; -- only the primary role
Per Snowflake's docs, privileges granted directly to users (rather than to roles) are only considered when secondary
roles are set to ALL. Prefer roles anyway.
How It Actually Works¶
Every securable object has exactly one owner role, which has full control, including granting privileges on it (unless
the object is in a managed access schema). When a statement runs, Snowflake checks the privileges of the session's
primary role and its inherited roles (and secondary roles, if active) for each object involved. The role hierarchy is
a directed graph. Granting role A to role B makes B a parent of A, and B inherits A's privileges, transitively. The
system roles are already wired into it (USERADMIN → SECURITYADMIN → ACCOUNTADMIN, SYSADMIN → ACCOUNTADMIN),
which is why custom roles should be granted to SYSADMIN: otherwise objects they own fall outside the hierarchy that
administrators control.
Future grants are stored on the container (schema or database) and applied automatically when a matching object is created there. Schema-level future grants take precedence over database-level ones for the same object type.
Common mistakes¶
- Granting privileges to users directly, or functional roles that hold object privileges directly. Use access roles.
- Forgetting
USAGEon the database and schema. - Only
ON ALL TABLES, so tomorrow's table is inaccessible. AddON FUTURE TABLES. - Custom roles not rolled up to
SYSADMIN. Orphaned ownership. - Using
ACCOUNTADMINfor daily work, or having only oneACCOUNTADMINuser. - Objects owned by
PUBLIC. Everyone can access them. - Reviewing grant scripts by eye. Compute effective access, and test the tool that computes it.
Exercise¶
- Build the access-role and functional-role structure for two domains (
sales,hr) with read and write levels, in a trial account. Then answer withSHOW GRANTSand your own reasoning: can ananalystreadhr.mart.salaries? - Extend
rbac.pyto understandGRANT DATABASE ROLE … TO ROLE …andGRANT OWNERSHIP ON TABLE …, and add a report of every user who can write to any table insales.mart. - Create a managed access schema and show that a table owner who isn't the schema owner can't grant
SELECTon their own table. - Find every custom role in your account that isn't granted (directly or indirectly) to
SYSADMIN, usingSHOW ROLESandSHOW GRANTS OF ROLEwithRESULT_SCAN.