<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sai krishna kumboju</title>
    <description>The latest articles on DEV Community by sai krishna kumboju (@krish0502).</description>
    <link>https://dev.to/krish0502</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4113666%2Fa55bcfb9-c8bf-4fbd-af02-5250a764cedf.png</url>
      <title>DEV Community: sai krishna kumboju</title>
      <link>https://dev.to/krish0502</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krish0502"/>
    <language>en</language>
    <item>
      <title>Snowflake Multi-Snowflake Multi-Environment Account Architecture: RBAC, Cost Governance, Identity Isolation &amp; PII Masking Design</title>
      <dc:creator>sai krishna kumboju</dc:creator>
      <pubDate>Mon, 07 Sep 2026 10:17:16 +0000</pubDate>
      <link>https://dev.to/krish0502/blast-radius-2bhj</link>
      <guid>https://dev.to/krish0502/blast-radius-2bhj</guid>
      <description>&lt;p&gt;&lt;em&gt;Designing account-level isolation for a multi-environment Snowflake account: one environment, one identity, one grant at a time. Nine databases, fifty-five roles, and a handful of controls I built the shape for before I had the infrastructure to turn them on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Snowflake account provisioning · 15-section script, annotated&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;At a glance, Dev · Stg · Prod (same shape in each):&lt;/strong&gt; 3 databases, 5 schemas, 17 roles, 1 warehouse per environment. Plus 4 roles that aren't tied to any one environment (&lt;code&gt;DATA_ENGINEER_TEAM_ROLE&lt;/code&gt;, &lt;code&gt;HUMAN_ENGINEER_USER_ROLE&lt;/code&gt;, and two PII classification roles) and the account-wide &lt;code&gt;GOVERNANCE&lt;/code&gt; database, for 55 roles and 9 environment databases in total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fked4jv2s93r1hux0btjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fked4jv2s93r1hux0btjd.png" alt=" " width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;p&gt;Layers × environments · Compute isolation · Cost governance · RBAC · Identity isolation · Humans vs. machines · Hardening identities · Network policy · PII masking · Honesty as architecture&lt;/p&gt;




&lt;p&gt;One Snowflake account. Three environments: &lt;strong&gt;dev&lt;/strong&gt;, &lt;strong&gt;stg&lt;/strong&gt;, and &lt;strong&gt;prod&lt;/strong&gt;. Three layers: raw, refine, and curate. Put those together and you get a real design problem before a single query runs. How do you stop a test load in &lt;strong&gt;dev&lt;/strong&gt; from ever touching &lt;strong&gt;prod&lt;/strong&gt;? How do you make sure a leaked &lt;strong&gt;dev&lt;/strong&gt; credential can't be used to reach &lt;strong&gt;prod&lt;/strong&gt;? How do you let an analyst read data without also letting them run the kind of DDL only an engineer should touch? And how do you do all of that without a permissions spreadsheet that goes stale the week after you write it?&lt;/p&gt;

&lt;p&gt;This document walks through fine-grained account provisioning, access control, and cost isolation for a multi-environment Snowflake account, where everyone in the organization needs some kind of access, and the real problem is controlling, precisely, who can touch what. Nothing here depends on a specific tool: every role and user name below is a placeholder for whatever actually connects to your Snowflake account, so swap the names and keep the pattern. Each section follows the same structure: why I made a decision, then the SQL that implements it. Where a control isn't switched on yet, I explain what it's waiting on and how to turn it on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll take away:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A naming convention that makes &lt;strong&gt;"which environment, which layer"&lt;/strong&gt; a property of an object's path, not a lookup table someone has to maintain.&lt;/li&gt;
&lt;li&gt;Why &lt;strong&gt;compute isolation&lt;/strong&gt; matters even when every workload reads the same data, and how to size a warehouse per consumer instead of per layer.&lt;/li&gt;
&lt;li&gt;How &lt;strong&gt;schema-level least privilege&lt;/strong&gt; actually pays for itself, and where it's overkill for a small team.&lt;/li&gt;
&lt;li&gt;The single highest-leverage identity decision: &lt;strong&gt;one service account per environment&lt;/strong&gt;, never one shared account with role-switching.&lt;/li&gt;
&lt;li&gt;How to design different access, and different auth, for machines versus humans, and how to build &lt;strong&gt;governance controls you can't enforce yet&lt;/strong&gt; so turning them on later is a one-line change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layers × environments
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; I put raw, refine, and curate in &lt;em&gt;separate databases&lt;/em&gt; per environment, not separate schemas inside one shared database. A database boundary is where Snowflake's grant model and its cost/usage accounting both naturally fall out for free: I get "who can touch curated data" and "how much did the curate layer cost this month" as properties of the object hierarchy, instead of a discipline everyone has to remember (query tags, naming conventions) to reconstruct later. It also means the environment axis and the layer axis compose cleanly into a grid, instead of one dimension being a database and the other an afterthought inside it.&lt;/p&gt;

&lt;p&gt;The naming convention does the actual work. Every database follows &lt;code&gt;&amp;lt;LAYER&amp;gt;_&amp;lt;ENV&amp;gt;&lt;/code&gt;; every schema underneath it groups by source system or business domain. Nothing needs a lookup table to know where an object belongs: the name says it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;      &lt;span class="c1"&gt;-- raw layer&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;REFINED_DEV&lt;/span&gt;  &lt;span class="c1"&gt;-- refine layer&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;CURATED_DEV&lt;/span&gt;  &lt;span class="c1"&gt;-- curate layer&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per environment: swap _DEV for _STG / _PROD&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;USAGE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;REFINED_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;REFINED_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;USAGE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;CURATED_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAAS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per environment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A table's full path (&lt;code&gt;RAW_DEV.BILLING.CUSTOMERS&lt;/code&gt;, say) tells you its environment, its layer, and its source system before you've run a single query against it. Whatever builds inside these schemas never has to compute or guess a name; it just connects to the database that matches its own environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute isolation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; A warehouse is pure compute; it has no bearing on which database a query touches. So isolating warehouses by &lt;em&gt;workload&lt;/em&gt;, not by layer, stops an analyst's ad hoc query, an engineer's interactive debugging session, and a scheduled automated build from contending for the same cluster or landing on the same bill, even when all three are reading the exact same prod tables at the exact same moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_WH&lt;/span&gt;
    &lt;span class="n"&gt;WAREHOUSE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'XSMALL'&lt;/span&gt; &lt;span class="n"&gt;AUTO_SUSPEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="n"&gt;AUTO_RESUME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt; &lt;span class="n"&gt;INITIALLY_SUSPENDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
    &lt;span class="c1"&gt;-- automated build workloads only, never a human query;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_WH&lt;/span&gt;
    &lt;span class="n"&gt;WAREHOUSE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'XSMALL'&lt;/span&gt; &lt;span class="n"&gt;AUTO_SUSPEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="n"&gt;AUTO_RESUME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt; &lt;span class="n"&gt;INITIALLY_SUSPENDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
    &lt;span class="c1"&gt;-- DATA_ENGINEER_TEAM_ROLE only, never shared with the transformer service;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four warehouses in total: one per environment (dev/stg/prod), plus one for the human role below. &lt;code&gt;AUTO_SUSPEND&lt;/code&gt; at 60–120s and &lt;code&gt;INITIALLY_SUSPENDED&lt;/code&gt; keep every one of them billing at zero until something actually queries it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost governance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; &lt;code&gt;AUTO_SUSPEND&lt;/code&gt; only stops a warehouse from billing while it's idle; it does nothing once a warehouse is actually busy. A runaway query, a bad retry loop, or a misconfigured job firing every minute all bill at full rate indefinitely, and &lt;code&gt;AUTO_SUSPEND&lt;/code&gt; never sees a reason to intervene because the warehouse is never idle. A resource monitor is the actual spending cap: attached to one warehouse, it tracks credit consumption against a quota and fires as usage approaches it, instead of a human noticing the bill days later. One monitor per warehouse, not one shared account-wide monitor across all four, for the same reason every other control in this document is scoped narrow: a shared monitor tells you the account overspent, a per-warehouse one tells you which one did it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;RESOURCE&lt;/span&gt; &lt;span class="n"&gt;MONITOR&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_RM&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;CREDIT_QUOTA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
    &lt;span class="n"&gt;FREQUENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DAILY&lt;/span&gt;
    &lt;span class="n"&gt;START_TIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IMMEDIATELY&lt;/span&gt;
    &lt;span class="n"&gt;TRIGGERS&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;  &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt; &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_WH&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;RESOURCE_MONITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_RM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per environment, sized to that environment's expected load&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;RESOURCE&lt;/span&gt; &lt;span class="n"&gt;MONITOR&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_RM&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;CREDIT_QUOTA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="n"&gt;FREQUENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DAILY&lt;/span&gt;
    &lt;span class="n"&gt;START_TIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IMMEDIATELY&lt;/span&gt;
    &lt;span class="n"&gt;TRIGGERS&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;  &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt; &lt;span class="n"&gt;PERCENT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTIFY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_WH&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;RESOURCE_MONITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_RM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three thresholds do the same thing here (&lt;code&gt;NOTIFY&lt;/code&gt;, never &lt;code&gt;SUSPEND&lt;/code&gt; or &lt;code&gt;SUSPEND_IMMEDIATE&lt;/code&gt;), and the quota resets every day instead of every month. That's a deliberate trade: a forced suspend can cut off an in-flight production build or a human's active session, which is its own kind of blast radius, one this document isn't willing to risk automatically. A daily reset keeps any single bad day cheap even without an automatic kill switch, so three notifications (heads up, this is getting expensive, this is now past what a normal day should cost) are enough to get a person to look, with a hard 24-hour ceiling on how long it can go unnoticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  RBAC
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Schema-level roles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; "Can restructure this table," "can only add and change rows in it," and "can only read it" are three genuinely different blast radii, and a single blanket role collapses them into one grant. So every one of the 15 schemas (5 schemas × 3 environments) gets its own triad (&lt;code&gt;_DDL_ROLE&lt;/code&gt;, &lt;code&gt;_WRITER_ROLE&lt;/code&gt;, &lt;code&gt;_READER_ROLE&lt;/code&gt;) instead of one role per environment doing everything.&lt;/p&gt;

&lt;p&gt;I'll be straight about the cost of this: it's 45 roles for what is conceptually 5 schemas. WRITER and READER sit empty today, provisioned for a BI tool or an external loader that doesn't exist in this project; only the DDL tier actually gets handed to a consumer, in the next two subsections. That's a real bet: it's cheaper to create the seat now, while the schema list is small and stable, than to retrofit a new privilege tier under a live dependency graph later. For a smaller, faster-moving team I'd cut the unused tiers until a second consumer actually shows up. Least privilege is a discipline you can always add more of, not a rule that says provision every conceivable role on day one.&lt;/p&gt;

&lt;p&gt;A tool like dbt, for example, issues DDL (&lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE ... ADD COLUMN&lt;/code&gt;) as part of running its own transformations, not just DML. That's why a consumer needs the DDL tier at all, not just insert/update/delete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_WRITER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_READER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- every custom role rolls up to SYSADMIN, so it stays manageable from&lt;/span&gt;
&lt;span class="c1"&gt;-- one place without ever granting a role directly to a person:&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt;    &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_WRITER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_READER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUTURE&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUTURE&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt;
    &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_WRITER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUTURE&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BILLING&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_READER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each tier can actually do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_DDL_ROLE&lt;/code&gt;&lt;/strong&gt;: create/alter/drop: yes · insert/update/delete: yes · select: yes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_WRITER_ROLE&lt;/code&gt;&lt;/strong&gt;: create/alter/drop: no · insert/update/delete: yes · select: yes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_READER_ROLE&lt;/code&gt;&lt;/strong&gt;: create/alter/drop: no · insert/update/delete: no · select: yes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these roles do anything by themselves yet; a role with no member is just a named bundle of privileges sitting unused. The next few subsections are where each one actually gets handed to a consumer: the tool role, the human team roles, the PII classification roles, and finally the user roles that tie all of it to an actual identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool role
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; The transformer service needs an identity of its own: one role per environment, standing in for "whatever tool is doing the building here." It's scoped to exactly one warehouse and USAGE on that environment's three databases, then handed the schema-level DDL roles from above. Nothing about which specific tables it can touch gets decided here; that was already decided one subsection up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_WH&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;OPERATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMING_DEV_WH&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;     &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;REFINED_DEV&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;CURATED_DEV&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per environment&lt;/span&gt;

&lt;span class="c1"&gt;-- the transformer service itself only ever needs the DDL tier, already created above:&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per schema, in every environment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USAGE&lt;/code&gt; on a database has to be granted directly to this role, in its own right; inheriting privileges on the schemas underneath isn't enough by itself to let a role traverse into the database that contains them. The last &lt;code&gt;GRANT ROLE&lt;/code&gt; line is the one that actually matters: it's the only place in this role's setup where it gets handed real table access, and it only ever needed the DDL tier that already existed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Team roles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Human roles don't get their own privilege model; they compose from the exact same schema-level roles the transformer service does, just a different mix of them. &lt;code&gt;DATA_ENGINEER_TEAM_ROLE&lt;/code&gt; is DDL in dev, read-only in stg and prod. Nothing new gets built for a human; they're just handed a different subset of what already exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_WH&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV&lt;/span&gt;     &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;REFINED_DEV&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;CURATED_DEV&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated for every database in stg and prod too -- this role spans all three environments&lt;/span&gt;

&lt;span class="c1"&gt;-- full DDL in dev, all 5 schemas:&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;RAW_DEV_BILLING_DDL_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated for the other 4 dev schemas&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same mechanism repeats once more for the read-only grants in stg and prod (all 5 schemas' &lt;code&gt;_READER_ROLE&lt;/code&gt;s, in both environments). What this role is actually scoped to see, alongside the transformer service, is laid out next in Humans vs. machines.&lt;/p&gt;

&lt;h3&gt;
  
  
  PII roles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; A masking policy needs something to check against, and that check has to already be threaded through the grant graph before the policy itself exists, otherwise "turn on masking" also means "figure out who should still see the real value," under pressure, at the same time. So the classification roles get created and granted now, well ahead of the Enterprise Edition upgrade the masking policy itself depends on: &lt;code&gt;DSC_CUSTOMER_EMAIL_ROLE&lt;/code&gt; goes to &lt;code&gt;TRANSFORMER_DEV_ROLE&lt;/code&gt; (the transformer service genuinely needs to read real emails while moving them from raw into the refined layer) and nowhere else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_EMAIL_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_NAME_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_EMAIL_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_NAME_ROLE&lt;/span&gt;  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_EMAIL_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DSC_CUSTOMER_NAME_ROLE&lt;/span&gt;  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once per environment; deliberately never granted to any read-only role&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Standard Edition this grant has no visible effect at all; there's no masking policy yet to check it. The day this account moves to Enterprise Edition and the masking policy in PII masking actually gets applied, every role that was never added here starts seeing a masked value automatically, with no other change required.&lt;/p&gt;

&lt;h3&gt;
  
  
  User roles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Every role built so far is an &lt;em&gt;access&lt;/em&gt; role: it exists to describe a set of privileges, not a person or a service. Granting those directly to a user works, but it means a user's real privilege footprint is whatever the union of every &lt;code&gt;GRANT ROLE ... TO USER&lt;/code&gt; statement ever run against them adds up to, with nowhere to look but the account's grant history to answer "what can this identity actually do." So one more role sits between the access roles and the identity: a &lt;em&gt;user role&lt;/em&gt;, one per user, that exists only to collect every access role that user needs and hand them to the user as a single grant. Auditing a user becomes "what's granted to their one user role," and revoking a user's access is one &lt;code&gt;REVOKE&lt;/code&gt;, not a hunt through every role they were ever added to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;TRANSFORMER_DEV_ROLE&lt;/span&gt;  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once each for SERVICE_STG_USER_ROLE / TRANSFORMER_STG_ROLE,&lt;/span&gt;
&lt;span class="c1"&gt;-- SERVICE_PROD_USER_ROLE / TRANSFORMER_PROD_ROLE&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SYSADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the only role that ever gets granted to a user directly, next in Identity isolation: every access role above reaches a person or a service exclusively through the one user role built for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity isolation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Each environment gets its own service account (&lt;code&gt;SERVICE_DEV_USER&lt;/code&gt;, &lt;code&gt;SERVICE_STG_USER&lt;/code&gt;, &lt;code&gt;SERVICE_PROD_USER&lt;/code&gt;) and each one is granted &lt;em&gt;only&lt;/em&gt; its own environment's user role, built back in RBAC to hold exactly one environment's worth of access and nothing else. Not one shared identity that can request any role by name: a separate account per environment, scoped so it structurally cannot reach beyond the one it belongs to. If &lt;code&gt;SERVICE_DEV_USER&lt;/code&gt;'s key ever leaks, there's no &lt;code&gt;USE ROLE TRANSFORMER_PROD_ROLE&lt;/code&gt; for it to fall back on; that grant was never made, not to the user directly and not to its user role either. The isolation doesn't depend on a connection asking for the right role and getting it right every time; it depends on the wrong role not existing for that user at all.&lt;/p&gt;

&lt;p&gt;Every identity in this account is one of two kinds: a &lt;strong&gt;service account&lt;/strong&gt;, authenticating unattended with a key pair, or a &lt;strong&gt;human account&lt;/strong&gt;, authenticating interactively with a password and MFA (the next section, Humans vs. machines, covers those). This section is specifically about the service accounts: the ones a leaked credential actually threatens, because nothing has to go wrong except a file ending up somewhere it shouldn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER&lt;/span&gt;
    &lt;span class="n"&gt;LOGIN_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SERVICE_DEV_USER'&lt;/span&gt;
    &lt;span class="n"&gt;DISPLAY_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Transformer service - dev'&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SERVICE&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_ROLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SERVICE_DEV_USER_ROLE'&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_WAREHOUSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'TRANSFORMING_DEV_WH'&lt;/span&gt;
    &lt;span class="n"&gt;MUST_CHANGE_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;
    &lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Key-pair auth only. Scoped to SERVICE_DEV_USER_ROLE -- cannot assume any other environment&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;s role.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once each for SERVICE_STG_USER, SERVICE_PROD_USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TYPE = SERVICE&lt;/code&gt; is doing real work, not just documenting intent: it tells Snowflake this identity is structurally incapable of password or MFA login at all, only key-pair or OAuth. It's the same idea as the rest of this section applied one level deeper: don't rely on nobody ever trying a password login for this account, remove the option entirely. &lt;code&gt;MUST_CHANGE_PASSWORD = FALSE&lt;/code&gt; matters for the same reason: there's no interactive session that could ever act on a change-password prompt.&lt;/p&gt;

&lt;p&gt;That's the entire access mechanism: one &lt;code&gt;CREATE USER&lt;/code&gt; and one &lt;code&gt;GRANT ROLE&lt;/code&gt; per environment, repeated three times. No cross-environment grant is ever created in the first place, so there's nothing to remember to revoke, and nothing a compromised credential can escalate into later. But a user with no key attached can't authenticate at all yet: that's the other half:&lt;/p&gt;

&lt;p&gt;Generate the key pair on whatever machine will hold the private half; it never leaves that machine, or a secrets manager, or gets pasted anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# private key (keep this one secret, never commit it)&lt;/span&gt;
openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; tmp_dev.pem 2048
openssl pkcs8 &lt;span class="nt"&gt;-topk8&lt;/span&gt; &lt;span class="nt"&gt;-inform&lt;/span&gt; PEM &lt;span class="nt"&gt;-in&lt;/span&gt; tmp_dev.pem &lt;span class="nt"&gt;-out&lt;/span&gt; rsa_key_dev.p8 &lt;span class="nt"&gt;-nocrypt&lt;/span&gt;

&lt;span class="c"&gt;# public key (the only half that ever goes near Snowflake)&lt;/span&gt;
openssl rsa &lt;span class="nt"&gt;-in&lt;/span&gt; rsa_key_dev.p8 &lt;span class="nt"&gt;-pubout&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt; rsa_key_dev.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then attach the public half to the account: paste in the contents of &lt;code&gt;rsa_key_dev.pub&lt;/code&gt;, minus the &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;END&lt;/code&gt; lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;RSA_PUBLIC_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;contents of rsa_key_dev.pub, no BEGIN/END lines&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection config then points at the &lt;em&gt;private&lt;/em&gt; key file's path, not the key itself; &lt;code&gt;SERVICE_DEV_USER&lt;/code&gt; authenticates by proving it holds the private half that matches what Snowflake has on file.&lt;/p&gt;

&lt;p&gt;For contrast, here's the human equivalent: a real person, not a service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER&lt;/span&gt;
    &lt;span class="n"&gt;LOGIN_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'HUMAN_ENGINEER_USER'&lt;/span&gt;
    &lt;span class="n"&gt;DISPLAY_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Jane Doe - Data Engineer'&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PERSON&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_ROLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'HUMAN_ENGINEER_USER_ROLE'&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_WAREHOUSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DATA_ENGINEER_TEAM_WH'&lt;/span&gt;
    &lt;span class="n"&gt;MUST_CHANGE_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
    &lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Real person -- password + MFA only, never key-pair auth.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER_ROLE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape, opposite substance: &lt;code&gt;TYPE = PERSON&lt;/code&gt; instead of &lt;code&gt;SERVICE&lt;/code&gt;; a password instead of a key pair, set once through Snowsight's reset-password flow, never as a literal in committed SQL; and &lt;code&gt;MUST_CHANGE_PASSWORD = TRUE&lt;/code&gt;, so that first password gets replaced the moment the person logs in. Enforcing MFA on top of that password is covered next, in Hardening identities; it needs an authentication policy that doesn't exist yet. None of this applies to a service account: there's no one present to type a code or choose a new password, so none of these controls would do anything for one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Humans vs. machines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; An automated transformer service is the only machine consumer in this account, and the human engineer working alongside it needs a genuinely different shape of access, not just a smaller version of the service's own role. The engineer needs to iterate: same DDL surface the transformer service gets, but only in dev, so they can verify a deploy landed in stg/prod by reading it without also being able to write over what the transformer service just built.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TRANSFORMER_DEV_ROLE&lt;/code&gt;&lt;/strong&gt;: dev: DDL · stg: none · prod: none · warehouse: &lt;code&gt;TRANSFORMING_DEV_WH&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DATA_ENGINEER_TEAM_ROLE&lt;/code&gt;&lt;/strong&gt;: dev: DDL · stg: read-only · prod: read-only · warehouse: &lt;code&gt;DATA_ENGINEER_TEAM_WH&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each gets its own dedicated warehouse (never a &lt;code&gt;TRANSFORMING_&amp;lt;ENV&amp;gt;_WH&lt;/code&gt;, never each other's), so a human's ad hoc query can't contend for capacity with an automated build, or with the other role's workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening identities
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Machines and humans need opposite controls on authentication, but the same short idle timeout works for both: a single automated build finishes well inside 15 minutes, so it never gets cut off mid-run, and it keeps a stale session from lingering either way. A service account authenticates unattended (there's no one present to approve an MFA prompt), so it skips that control entirely. A human's interactive login is exactly where a second factor earns its keep against a leaked or guessed password, so it gets enforced MFA on top of the same timeout, since an unattended, unlocked laptop shouldn't stay authenticated indefinitely either.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;AUTHENTICATION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HUMAN_MFA_REQUIRED_POLICY&lt;/span&gt;
    &lt;span class="n"&gt;MFA_ENROLLMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;AUTHENTICATION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HUMAN_MFA_REQUIRED_POLICY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HUMAN_SESSION_POLICY&lt;/span&gt;
    &lt;span class="n"&gt;SESSION_IDLE_TIMEOUT_MINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- interactive humans&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HUMAN_SESSION_POLICY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SERVICE_ACCOUNT_SESSION_POLICY&lt;/span&gt;
    &lt;span class="n"&gt;SESSION_IDLE_TIMEOUT_MINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- a real build finishes well inside this&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SERVICE_ACCOUNT_SESSION_POLICY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeated once each for SERVICE_STG_USER, SERVICE_PROD_USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the three service accounts, rotation is a two-slot mechanism (&lt;code&gt;RSA_PUBLIC_KEY&lt;/code&gt; and &lt;code&gt;RSA_PUBLIC_KEY_2&lt;/code&gt;) so a key can be replaced without downtime:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a new key pair; never overwrite the old file yet.&lt;/li&gt;
&lt;li&gt;Load the new &lt;strong&gt;public&lt;/strong&gt; key into whichever slot is unused; both keys now work.&lt;/li&gt;
&lt;li&gt;Point the connection config at the new &lt;strong&gt;private&lt;/strong&gt; key, and confirm a real run succeeds against it.&lt;/li&gt;
&lt;li&gt;Only once that's confirmed, retire the old key from its slot.&lt;/li&gt;
&lt;li&gt;Optionally consolidate the new key back into slot 1 before the next rotation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skipping the live check in step 3 before retiring the old key in step 4 is exactly how a routine rotation turns into an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network policy
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Status: commented out, fill in first&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; A network policy restricts which IP addresses can even attempt to authenticate as a given user; without one, a leaked credential can be used from anywhere.&lt;/p&gt;

&lt;p&gt;What it takes: a real, fixed IP or CIDR range to allowlist for the account or user connecting. One policy per user, not one shared account-wide policy, so a bad IP list only ever locks out that one user instead of everyone, ACCOUNTADMIN included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;NETWORK&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_NETWORK_POLICY&lt;/span&gt;
    &lt;span class="n"&gt;ALLOWED_IP_LIST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;dev runner IP or CIDR&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_USER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;NETWORK_POLICY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SERVICE_DEV_NETWORK_POLICY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PII masking
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Status: Enterprise Edition and above&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; PII masking hides sensitive columns from anyone without the right classification role; without it, every reader with &lt;code&gt;SELECT&lt;/code&gt; access sees raw values, no matter how carefully everything else in this account is scoped.&lt;/p&gt;

&lt;p&gt;What it takes: Snowflake Enterprise Edition. &lt;code&gt;CREATE MASKING POLICY&lt;/code&gt; doesn't exist on Standard Edition; every statement below fails outright until the account is upgraded. Once it is, a masking policy checks which role is active in the session and decides whether to reveal the real value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PII&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MASKING&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;GOVERNANCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PII&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MASK_CUSTOMER_EMAIL&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;IS_ROLE_IN_SESSION&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DSC_CUSTOMER_EMAIL_ROLE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'***MASKED***'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classification roles the policy keys off of aren't new here: &lt;code&gt;DSC_CUSTOMER_EMAIL_ROLE&lt;/code&gt; and &lt;code&gt;DSC_CUSTOMER_NAME_ROLE&lt;/code&gt; were already created and granted back in RBAC → PII roles, well ahead of this edition upgrade. On Standard Edition that grant has no technical effect yet; the day this account moves to Enterprise Edition and the masking policy above actually gets applied, every role that was never added to that grant starts seeing &lt;code&gt;***MASKED***&lt;/code&gt; with no other change required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honesty as architecture
&lt;/h2&gt;

&lt;p&gt;The last risk this account carries isn't a bad grant; it's false confidence in a script that was never actually checked. A file full of &lt;code&gt;CREATE ROLE&lt;/code&gt; and &lt;code&gt;GRANT&lt;/code&gt; statements looks like proof the access model works, but SQL that reads correctly isn't the same as SQL that ran correctly against a real account. So the script shouldn't just assert it works; it should come with a way to check that claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;GRANTS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DESCRIBE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;HUMAN_ENGINEER_USER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- then log in as the role itself, not as an admin who can see everything:&lt;/span&gt;
&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;DATA_ENGINEER_TEAM_ROLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_ROLE&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;CURRENT_WAREHOUSE&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SHOW GRANTS TO ROLE&lt;/code&gt; lists exactly what that role can do right now, not what a &lt;code&gt;GRANT&lt;/code&gt; statement further up claimed it would do. &lt;code&gt;DESCRIBE USER&lt;/code&gt; confirms a user's real default role and warehouse actually took. And connecting as the role, not as an admin, is the only way to catch a grant that looks right on paper but doesn't actually work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A design that tells you how to check it is more trustworthy than one that only asserts it works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That same honesty applies to what's already been laid out above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✕ &lt;code&gt;WRITER_ROLE&lt;/code&gt; and &lt;code&gt;READER_ROLE&lt;/code&gt; exist on every schema but are granted to no one yet: not an oversight, a seat held open for a BI tool or external loader that doesn't exist in this project yet. Nothing to grant them to means nothing to grant.&lt;/li&gt;
&lt;li&gt;✕ No network policy is active yet: the SQL is ready, but &lt;code&gt;&amp;lt;dev runner IP or CIDR&amp;gt;&lt;/code&gt; is a placeholder, not a real value. Whoever runs this has to fill in the actual fixed IP or CIDR range for each environment before it does anything.&lt;/li&gt;
&lt;li&gt;✕ Masking isn't live yet because this account is still on Standard Edition, which doesn't support &lt;code&gt;CREATE MASKING POLICY&lt;/code&gt; at all: an edition gap, not a design gap. Anyone with &lt;code&gt;SELECT&lt;/code&gt; access but no classification role sees real, unmasked customer emails today, exactly the exposure the classification roles were built ahead of.&lt;/li&gt;
&lt;li&gt;✕ No SSO or SCIM: a scope decision, not a technical one. Federating through an external identity provider is an account-wide choice made once, not once per role, and this document deliberately stops at the native primitives (password + MFA) rather than assume a specific IdP the reader may not have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that undermines the design; it's the other half of it. Isolation at the credential layer, the role layer, and the compute layer only means something if you're also honest about which layers are still unverified or not yet switched on. That list is as load-bearing as any &lt;code&gt;GRANT&lt;/code&gt; statement above it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Snowflake account architecture · 15-section provisioning script&lt;/em&gt;&lt;/p&gt;

</description>
      <category>snowflake</category>
      <category>dataengineering</category>
      <category>rbac</category>
      <category>accesscontrol</category>
    </item>
  </channel>
</rss>
