J'ai hérité d'une plateforme Snowflake chez un client où tout avait été créé à la main : databases, schemas, warehouses, roles, grants, pipes, integrations. Le SHOW GRANTS renvoyait 340 lignes. Personne ne savait qui avait créé quoi, quand, ni pourquoi. Le jour où il a fallu recréer l'environnement de recette à l'identique, l'équipe a passé trois semaines à reverse-engineerer la prod. Trois semaines. Depuis, je ne crée plus rien à la main sur Snowflake. Tout passe par Terraform. Voici comment.

Cet article utilise le provider Terraform Snowflake v2.18.0. Le provider v1 (sorti en janvier 2025) introduisait plusieurs limitations : authentification par mot de passe, ressources RBAC monolithiques, pas de gestion des utilisateurs. Le provider v2, disponible depuis début 2026, corrige tout ça avec des ressources renommées, l'authentification JWT par clé RSA, et un nouveau provider USERADMIN. Cet article reflète la pratique que j'utilise en production aujourd'hui.
Pourquoi Terraform sur Snowflake
La question légitime : "Snowflake a déjà un SQL pour tout faire, pourquoi ajouter Terraform ?" Parce que le SQL n'est pas versionné, pas reviewable, pas reproductible, et pas auditable.
| ACTION | SQL À LA MAIN | TERRAFORM |
|---|---|---|
| Créer un warehouse | CREATE WAREHOUSE dans un onglet |
terraform apply |
| Savoir qui a modifié le RBAC | Chercher dans ACCESS_HISTORY | git log |
| Recréer la recette à l'identique | Reverse-engineerer la prod | terraform apply |
| Ajouter un schéma en CI/CD | Script SQL manuel | Pull request → merge → apply |
| Audit conformité | SHOW GRANTS (340 lignes) |
terraform plan (lisible) |
| Détecter un drift | Impossible sans script | terraform plan le signale |
Le provider Terraform Snowflake v2
Le provider officiel snowflakedb/snowflake a atteint la version 2.0 début 2026, avec des changements majeurs que je détaillerai au fil de l'article. La version 2.18.0 couvre désormais : databases, schemas, warehouses, account roles (nouveau nom), grants, users, pipes, stages, integrations, masks, tags, policies.
Installation
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
snowflake = {
source = "snowflakedb/snowflake"
version = "~> 2.18.0"
}
}
}
Authentification : fini le mot de passe, place à la clé RSA
Le plus gros changement du provider v2 côté authentification : l'authentification par mot de passe est dépréciée. Le mode recommandé est désormais SNOWFLAKE_JWT avec une clé RSA. C'est plus sécurisé, plus reproductible, et indispensable pour la CI/CD.
Générer la clé RSA
# Générer une clé RSA 2048 bits
openssl genrsa -out snowflake_key.p8 2048
# Extraire la clé publique
openssl rsa -in snowflake_key.p8 -pubout -out snowflake_key.pub
# Récupérer la clé publique au format one-line (sans header/footer)
grep -v -- '----' snowflake_key.pub | tr -d '\n'
Configurer l'utilisateur Terraform dans Snowflake
-- Une seule fois, à la main (ou via un script d'initialisation)
CREATE USER TF_ADMIN
TYPE = SERVICE
RSA_PUBLIC_KEY = 'MIIBIjANBgkqh...'; -- la clé publique one-line
-- Le rôle dédié à Terraform (pas ACCOUNTADMIN !)
CREATE ROLE TF_ADMIN;
GRANT ROLE TF_ADMIN TO USER TF_ADMIN;
-- TF_ADMIN doit pouvoir assumer les rôles qu'il gère
GRANT ROLE SYSADMIN TO ROLE TF_ADMIN;
GRANT ROLE SECURITYADMIN TO ROLE TF_ADMIN;
GRANT ROLE USERADMIN TO ROLE TF_ADMIN;
-- Privilège CREATE INTEGRATION pour les storage et notification integrations
-- (CREATE INTEGRATION est détenu par défaut par ACCOUNTADMIN mais peut être délégué)
GRANT CREATE INTEGRATION ON ACCOUNT TO ROLE TF_ADMIN;
⚠️ Le rôle du provider : Terraform doit utiliser un rôle avec les privilèges suffisants pour créer les ressources. En production, je crée un rôle TF_ADMIN dédié (pas ACCOUNTADMIN), avec CREATE sur databases, warehouses, roles, et CREATE INTEGRATION sur le compte pour les storage et notification integrations. Ce rôle n'existe que pour Terraform — aucun humain ne l'utilise.
Le pattern des 3 providers
C'est le pattern que je pose sur tous mes projets avec le provider v2. Terraform utilise trois providers avec des rôles différents, parce que le modèle RBAC Snowflake sépare rigoureusement les responsabilités :
# providers.tf
# SYSADMIN : databases, schemas, warehouses, pipes, stages, integrations
provider "snowflake" {
alias = "sysadmin"
organization_name = var.snowflake_org
account_name = var.snowflake_account
user = "TF_ADMIN"
authenticator = "SNOWFLAKE_JWT"
private_key = file("snowflake_key.p8")
role = "SYSADMIN"
}
# SECURITYADMIN : grants de privilèges et héritage de rôles
provider "snowflake" {
alias = "securityadmin"
organization_name = var.snowflake_org
account_name = var.snowflake_account
user = "TF_ADMIN"
authenticator = "SNOWFLAKE_JWT"
private_key = file("snowflake_key.p8")
role = "SECURITYADMIN"
}
# USERADMIN : création des rôles et des utilisateurs
provider "snowflake" {
alias = "useradmin"
organization_name = var.snowflake_org
account_name = var.snowflake_account
user = "TF_ADMIN"
authenticator = "SNOWFLAKE_JWT"
private_key = file("snowflake_key.p8")
role = "USERADMIN"
}
Pourquoi pas de provider ACCOUNTADMIN ? Le privilège
CREATE INTEGRATION— bien que détenu par défaut uniquement parACCOUNTADMIN— peut être délégué à un autre rôle. En accordantGRANT CREATE INTEGRATION ON ACCOUNT TO ROLE TF_ADMIN, le rôleSYSADMIN(queTF_ADMINhérite) peut créer les storage et notification integrations directement. Quant aux resource monitors, la doc Snowflake est claire : seuls les account administrators peuvent les créer, et ce privilège ne peut pas être délégué. Je préfère donc les laisser hors Terraform et les créer manuellement via SQL — c'est un objet compte-level qui change rarement.Nouveau en v2 : USERADMIN. Dans le provider v1, SECURITYADMIN faisait tout (création de rôles + grants). En v2, on sépare : USERADMIN crée les rôles et les users, SECURITYADMIN accorde les privilèges et l'héritage. C'est aligné avec le modèle de gouvernance Snowflake, où USERADMIN est le seul à pouvoir créer des rôles.
Dans les modules, tu spécifies quel provider gère quelles ressources :
# Databases et warehouses → SYSADMIN
module "platform" {
source = "./modules/platform"
providers = {
snowflake = snowflake.sysadmin
}
}
# Création des rôles → USERADMIN
module "rbac_roles" {
source = "./modules/rbac_roles"
providers = {
snowflake = snowflake.useradmin
}
}
# Grants et héritage → SECURITYADMIN
module "rbac_grants" {
source = "./modules/rbac_grants"
providers = {
snowflake = snowflake.securityadmin
}
depends_on = [
module.platform,
module.rbac_roles,
]
}
# Users → USERADMIN
module "users" {
source = "./modules/users"
providers = {
snowflake = snowflake.useradmin
}
}
# Ingestion (pipes, stages, integrations) → SYSADMIN
module "ingestion" {
source = "./modules/ingestion"
providers = {
snowflake = snowflake.sysadmin
}
depends_on = [module.platform]
}
Le depends_on sur rbac_grants est crucial : on ne peut pas accorder des privilèges sur des objets qui n'existent pas encore, ni accorder un héritage sur des rôles pas encore créés.
Sans cette séparation, Terraform tente de créer une database avec SECURITYADMIN et échoue. Cette erreur, tout le monde la fait au début.
La structure de projet que j'utilise
terraform-snowflake/
├── environments/
│ ├── dev/
│ │ ├── main.tf # compose les modules
│ │ ├── variables.tf
│ │ ├── terraform.tfvars # valeurs spécifiques dev
│ │ └── backend.tf # remote state (S3 / Azure Storage)
│ ├── recette/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── modules/
│ ├── platform/ # databases, schemas, warehouses
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── rbac_roles/ # création des rôles (USERADMIN)
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── rbac_grants/ # grants, héritage, future grants (SECURITYADMIN)
│ │ ├── main.tf
│ │ └── variables.tf
│ ├── users/ # création des users + attribution (USERADMIN)
│ │ ├── main.tf
│ │ └── variables.tf
│ ├── ingestion/ # pipes, stages, integrations
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── security/ # masks, tags, policies
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── versions.tf
└── providers.tf
Évolution depuis le v1 : le module
rbacunique est split en trois modules (rbac_roles,rbac_grants,users), chacun avec son provider. C'est plus propre, plus maintenable, et ça évite les conflits de permissions.
Un environnement = un dossier = un state. Le state dev ne peut pas toucher la prod. Et chaque environnement a ses propres tfvars (taille de warehouse, rétention, nombre de clusters, liste des utilisateurs).
Le module platform : databases, schemas, warehouses
C'est le squelette de la plateforme. Je le pose identique sur chaque projet, avec les conventions de nommage :
# modules/platform/main.tf
# --- Databases (architecture médaillon) ---
resource "snowflake_database" "bronze" {
name = "BRONZE_${var.env_suffix}"
comment = "Raw layer - ingestion without transformation"
}
resource "snowflake_database" "silver" {
name = "SILVER_${var.env_suffix}"
comment = "Cleaned + conformed layer"
}
resource "snowflake_database" "gold" {
name = "GOLD_${var.env_suffix}"
comment = "Business marts - ready for BI"
}
# --- Schemas par domaine ---
resource "snowflake_schema" "bronze_raw" {
database = snowflake_database.bronze.name
name = "RAW"
comment = "All raw ingested data"
}
resource "snowflake_schema" "silver_sales" {
database = snowflake_database.silver.name
name = "SALES"
comment = "Cleaned sales data"
}
resource "snowflake_schema" "gold_reporting" {
database = snowflake_database.gold.name
name = "REPORTING"
comment = "Business marts for BI"
}
# --- Warehouses ---
resource "snowflake_warehouse" "ingestion" {
name = "INGEST_WH_${var.env_suffix}"
warehouse_size = "X-SMALL"
auto_suspend = 60
initially_suspended = true
comment = "Snowpipe + Airbyte ingestion"
}
resource "snowflake_warehouse" "transformation" {
name = "TRANSFORM_WH_${var.env_suffix}"
warehouse_size = "SMALL"
auto_suspend = 60
initially_suspended = true
comment = "dbt transformations"
}
resource "snowflake_warehouse" "bi" {
name = "BI_WH_${var.env_suffix}"
warehouse_size = "X-SMALL"
auto_suspend = 60
initially_suspended = true
comment = "Power BI / Looker queries"
}
Le piège du auto_suspend
# ⛔ Mauvais : warehouse qui tourne 24/7
resource "snowflake_warehouse" "bi" {
name = "BI_WH"
warehouse_size = "SMALL"
# auto_suspend absent = warehouse jamais suspendu = facture qui explose
}
# ✅ Bon : auto_suspend à 60s + initially_suspended
resource "snowflake_warehouse" "bi" {
name = "BI_WH_${var.env_suffix}"
warehouse_size = "SMALL"
auto_suspend = 60
initially_suspended = true
}
Si tu as lu mon article sur les 5 erreurs de costing Snowflake, tu sais que l'absence d'auto-suspend est l'erreur n°1. En Terraform, il n'y a pas d'excuse : c'est un paramètre obligatoire.
Le module rbac_roles : création des rôles (USERADMIN)
Si tu as lu mon article sur le RBAC 3 couches, tu connais le modèle. Voici comment le traduire en Terraform v2 :
Breaking change v2 :
snowflake_roledevientsnowflake_account_role. La ressource v1snowflake_roleest dépréciée et sera supprimée dans une future version.
# modules/rbac_roles/main.tf
# --- Access Roles (couche technique) ---
resource "snowflake_account_role" "ar_brz_raw_read" {
name = "AR_BRZ_RAW_READ_${var.env_suffix}"
comment = "Read access on Bronze RAW schema"
}
resource "snowflake_account_role" "ar_slv_sales_write" {
name = "AR_SLV_SALES_WRITE_${var.env_suffix}"
comment = "Write access on Silver SALES schema"
}
resource "snowflake_account_role" "ar_gld_report_read" {
name = "AR_GLD_REPORT_READ_${var.env_suffix}"
comment = "Read access on Gold REPORTING schema"
}
# --- Functional Roles (couche métier) ---
resource "snowflake_account_role" "fr_data_engineer" {
name = "FR_DATA_ENGINEER_${var.env_suffix}"
comment = "Data Engineer — bronze read + silver write + gold read"
}
resource "snowflake_account_role" "fr_data_analyst" {
name = "FR_DATA_ANALYST_${var.env_suffix}"
comment = "Data Analyst — gold read only"
}
Simple, lisible. Chaque rôle est une ressource indépendante, créée par USERADMIN.
Le module rbac_grants : héritage et privilèges (SECURITYADMIN)
C'est ici que la v2 apporte le plus de changements. Deux ressources nouvelles remplacent les anciennes :
| Provider v1 | Provider v2 | Usage |
|---|---|---|
snowflake_role_grants |
snowflake_grant_account_role |
Héritage (role → parent) et attribution (role → user) |
snowflake_grant_privileges_to_role |
snowflake_grant_privileges_to_account_role |
Privilèges sur objets |
Héritage : Access Roles → Functional Roles
Breaking change v2 :
snowflake_role_grantsacceptait une liste de rôles dansroles = [...]. En v2,snowflake_grant_account_rolefait un grant par ressource. C'est plus verbeux mais plus précis — et ça évite les race conditions quand Terraform tente de modifier plusieurs grants simultanément.
# modules/rbac_grants/main.tf
# --- Héritage: Access Roles → Functional Roles ---
resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_brz" {
role_name = snowflake_account_role.ar_brz_raw_read.name
parent_role_name = snowflake_account_role.fr_data_engineer.name
}
resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_slv" {
role_name = snowflake_account_role.ar_slv_sales_write.name
parent_role_name = snowflake_account_role.fr_data_engineer.name
}
resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_gld" {
role_name = snowflake_account_role.ar_gld_report_read.name
parent_role_name = snowflake_account_role.fr_data_engineer.name
}
resource "snowflake_grant_account_role" "fr_data_analyst_inherits_ar_gld" {
role_name = snowflake_account_role.ar_gld_report_read.name
parent_role_name = snowflake_account_role.fr_data_analyst.name
}
Future Grants : automatiser les nouveaux objets
# Toute nouvelle table du bronze sera lisible par AR_BRZ_RAW_READ
resource "snowflake_grant_privileges_to_account_role" "ar_brz_future_tables" {
account_role_name = snowflake_account_role.ar_brz_raw_read.name
privileges = ["SELECT"]
on_schema_object {
future {
object_type_plural = "TABLES"
in_schema = "\"BRONZE_${var.env_suffix}\".\"RAW\""
}
}
}
# Toute nouvelle table du silver sera insérable par AR_SLV_SALES_WRITE
resource "snowflake_grant_privileges_to_account_role" "ar_slv_future_tables" {
account_role_name = snowflake_account_role.ar_slv_sales_write.name
privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
on_schema_object {
future {
object_type_plural = "TABLES"
in_schema = "\"SILVER_${var.env_suffix}\".\"SALES\""
}
}
}
# Toute nouvelle table du gold sera lisible par AR_GLD_REPORT_READ
resource "snowflake_grant_privileges_to_account_role" "ar_gld_future_tables" {
account_role_name = snowflake_account_role.ar_gld_report_read.name
privileges = ["SELECT"]
on_schema_object {
future {
object_type_plural = "TABLES"
in_schema = "\"GOLD_${var.env_suffix}\".\"REPORTING\""
}
}
}
USAGE grants sur databases et schemas
# --- USAGE sur databases ---
resource "snowflake_grant_privileges_to_account_role" "ar_brz_usage_db" {
account_role_name = snowflake_account_role.ar_brz_raw_read.name
privileges = ["USAGE"]
on_account_object {
object_type = "DATABASE"
object_name = "BRONZE_${var.env_suffix}"
}
}
resource "snowflake_grant_privileges_to_account_role" "ar_slv_usage_db" {
account_role_name = snowflake_account_role.ar_slv_sales_write.name
privileges = ["USAGE"]
on_account_object {
object_type = "DATABASE"
object_name = "SILVER_${var.env_suffix}"
}
}
resource "snowflake_grant_privileges_to_account_role" "ar_gld_usage_db" {
account_role_name = snowflake_account_role.ar_gld_report_read.name
privileges = ["USAGE"]
on_account_object {
object_type = "DATABASE"
object_name = "GOLD_${var.env_suffix}"
}
}
# --- USAGE sur schemas ---
resource "snowflake_grant_privileges_to_account_role" "ar_brz_usage_schema" {
account_role_name = snowflake_account_role.ar_brz_raw_read.name
privileges = ["USAGE"]
on_schema {
schema_name = "\"BRONZE_${var.env_suffix}\".\"RAW\""
}
}
resource "snowflake_grant_privileges_to_account_role" "ar_slv_usage_schema" {
account_role_name = snowflake_account_role.ar_slv_sales_write.name
privileges = ["USAGE"]
on_schema {
schema_name = "\"SILVER_${var.env_suffix}\".\"SALES\""
}
}
resource "snowflake_grant_privileges_to_account_role" "ar_gld_usage_schema" {
account_role_name = snowflake_account_role.ar_gld_report_read.name
privileges = ["USAGE"]
on_schema {
schema_name = "\"GOLD_${var.env_suffix}\".\"REPORTING\""
}
}
Grants sur warehouses
# --- Warehouse grants ---
resource "snowflake_grant_privileges_to_account_role" "fr_data_engineer_wh" {
account_role_name = snowflake_account_role.fr_data_engineer.name
privileges = ["USAGE", "OPERATE"]
on_account_object {
object_type = "WAREHOUSE"
object_name = "TRANSFORM_WH_${var.env_suffix}"
}
}
resource "snowflake_grant_privileges_to_account_role" "fr_data_analyst_wh" {
account_role_name = snowflake_account_role.fr_data_analyst.name
privileges = ["USAGE"]
on_account_object {
object_type = "WAREHOUSE"
object_name = "BI_WH_${var.env_suffix}"
}
}
Pourquoi c'est puissant
Un terraform plan te donne l'audit complet de qui a accès à quoi :
Plan: 24 to add, 0 to change, 0 to destroy.
# snowflake_account_role.ar_brz_raw_read will be created
# snowflake_account_role.fr_data_engineer will be created
# snowflake_grant_account_role.fr_data_engineer_inherits_ar_brz will be created
# snowflake_grant_privileges_to_account_role.ar_brz_future_tables will be created
...
Pas besoin de SHOW GRANTS qui renvoie 340 lignes. Le plan Terraform est lisible, reviewable en pull request, et versionné dans Git.
Le module users : gérer les utilisateurs via Terraform (USERADMIN)
Nouveau en v2 : le provider v1 ne permettait pas de gérer les utilisateurs proprement. Le provider v2 introduit
snowflake_useravec un support complet (login, RSA key, rôles, warehouse par défaut). C'est un game changer : tout est dans Terraform, y compris les comptes utilisateurs.
# modules/users/main.tf
# --- Création des utilisateurs ---
resource "snowflake_user" "users" {
count = length(var.users)
name = var.users[count.index].login_name
login_name = var.users[count.index].login_name
display_name = var.users[count.index].display_name
first_name = var.users[count.index].firstname
last_name = var.users[count.index].lastname
email = var.users[count.index].email
default_warehouse = var.users[count.index].default_warehouse
default_role = var.users[count.index].default_role
comment = "User managed by Terraform"
}
# --- Attribution des Functional Roles à chaque utilisateur ---
# Aplatir la liste (user, role) en une liste de maps pour le for_each
locals {
user_role_pairs = flatten([
for u in var.users : [
for role in u.roles : {
login_name = u.login_name
role_name = role
}
]
])
}
resource "snowflake_grant_account_role" "user_roles" {
for_each = {
for pair in local.user_role_pairs :
"${pair.login_name}-${pair.role_name}" => pair
}
role_name = each.value.role_name
user_name = each.value.login_name
depends_on = [snowflake_user.users]
}
La variable users est une liste d'objets :
# terraform.tfvars
users = [
{
display_name = "Alice Martin"
firstname = "Alice"
lastname = "Martin"
login_name = "ALICE.MARTIN"
email = "alice@company.com"
default_warehouse = "TRANSFORM_WH_DEV"
roles = ["FR_DATA_ENGINEER_DEV"]
default_role = "FR_DATA_ENGINEER_DEV"
},
{
display_name = "Bob Durand"
firstname = "Bob"
lastname = "Durand"
login_name = "BOB.DURAND"
email = "bob@company.com"
default_warehouse = "BI_WH_DEV"
roles = ["FR_DATA_ANALYST_DEV"]
default_role = "FR_DATA_ANALYST_DEV"
}
]
Le pattern for_each sur les paires user/rôle évite la duplication et gère proprement le cas où un utilisateur a plusieurs rôles. Le depends_on sur snowflake_user.users garantit que les utilisateurs existent avant d'attribuer les rôles.
Le module ingestion : pipes, stages, integrations
Ce module n'a pas changé entre v1 et v2 — les ressources snowflake_storage_integration, snowflake_stage, snowflake_pipe, snowflake_notification_integration sont identiques.
# modules/ingestion/main.tf
# --- Storage Integration (AWS S3) ---
resource "snowflake_storage_integration" "s3_integration" {
name = "S3_INTEGRATION_${var.env_suffix}"
storage_provider = "S3"
storage_aws_iam_role_arn = var.s3_role_arn
storage_allowed_locations = ["s3://${var.bucket_name}/"]
enabled = true
}
# --- Stage ---
resource "snowflake_stage" "s3_raw" {
name = "S3_RAW_STAGE"
database = "BRONZE_${var.env_suffix}"
schema = "RAW"
url = "s3://${var.bucket_name}/raw/"
storage_integration = snowflake_storage_integration.s3_integration.name
}
# --- Notification Integration (SQS pour auto-ingest) ---
resource "snowflake_notification_integration" "sqs_notification" {
name = "SQS_NOTIFICATION_${var.env_suffix}"
notification_provider = "AWS_SQS"
notification_aws_sqs_arn = var.sqs_queue_arn
enabled = true
}
# --- Snowpipe ---
resource "snowflake_pipe" "csv_ingestion" {
name = "CSV_INGESTION"
database = "BRONZE_${var.env_suffix}"
schema = "RAW"
copy_statement = <<-SQL
COPY INTO BRONZE_${var.env_suffix}.RAW.CSV_FILES (file_name, raw_data, loaded_at)
FROM @${snowflake_stage.s3_raw.fully_qualified_name}
FILE_FORMAT = (TYPE = CSV SKIP_HEADER = 1)
SQL
auto_ingest = true
notification_integration_name = snowflake_notification_integration.sqs_notification.name
}
Sans Terraform, créer un Snowpipe avec auto-ingest demande 4 étapes manuelles (storage integration, stage, notification integration, pipe) avec des dépendances entre elles. En Terraform, c'est terraform apply.
Le module security : masks et tags
# modules/security/main.tf
# --- Tag pour classification RGPD ---
resource "snowflake_tag" "rgpd_sensitive" {
name = "RGPD_SENSITIVE"
database = "SILVER_${var.env_suffix}"
schema = "SALES"
comment = "Tag for columns containing personal data"
}
# --- Masking Policy pour les emails ---
resource "snowflake_masking_policy" "email_mask" {
name = "EMAIL_MASK"
database = "SILVER_${var.env_suffix}"
schema = "SALES"
signature = "(val string) returns string"
masking_expression = <<-SQL
CASE
WHEN CURRENT_ROLE() IN ('FR_DATA_ANALYST_${var.env_suffix}')
THEN REGEXP_REPLACE(val, '(.{2}).*(@.*)', '\\1***\\2')
ELSE val
END
SQL
comment = "Mask emails for analyst role"
}
Différence entre provider v1 et v2 : en v1, le masquage de données devait passer par un contournement — on créait une Dynamic Table avec un
CASE WHEN CURRENT_ROLE()pour simuler le masquage. En v2, on utilise directementsnowflake_masking_policy— c'est la bonne ressource pour le Dynamic Data Masking. La politique est ensuite appliquée sur les colonnes via des grants ou directement dans dbt.
Le masquage conditionnel basé sur CURRENT_ROLE() garantit qu'un analyste voit ma***@gmail.com tandis qu'un data engineer voit l'email complet. Tout est dans Terraform = auditable.
CI/CD : du commit au déploiement
Le workflow que je déploie avec GitLab CI (ou GitHub Actions) :
# .gitlab-ci.yml
stages:
- plan
- apply
variables:
TF_VERSION: "1.12.2"
.terraform_setup: &terraform_setup
image: hashicorp/terraform:${TF_VERSION}
before_script:
# La clé RSA est stockée comme variable CI/CD (file type)
- cp $SNOWFLAKE_KEY_P8 snowflake_key.p8
- chmod 600 snowflake_key.p8
terraform:plan:dev:
stage: plan
<<: *terraform_setup
script:
- cd environments/dev
- terraform init
- terraform plan -out=tfplan
artifacts:
paths:
- environments/dev/tfplan
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
terraform:apply:dev:
stage: apply
<<: *terraform_setup
script:
- cd environments/dev
- terraform init
- terraform apply -auto-approve tfplan
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Le flux :
- Un dev ouvre une pull request →
terraform plantourne en CI - La team review le plan (qui montre exactement ce qui va changer)
- Merge sur
main→terraform applyen dev - Promotion manuelle vers recette, puis prod
Le plan Terraform est reviewable en pull request. C'est le killer feature. Le reviewer voit exactement : "ce merge va créer 2 roles, 3 grants, 1 warehouse". Pas de surprise.
Note sur la clé RSA en CI/CD : la clé
snowflake_key.p8est stockée comme variable de type file dans GitLab CI (ou GitHub Secret). Elle n'est jamais commitée dans le repo. Lebefore_scriptla copie depuis la variable CI vers le filesystem local.
Remote state : ne jamais perdre le .tfstate
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "terraform-state-snowflake"
key = "dev/terraform.tfstate"
region = "eu-west-3"
encrypt = true
}
}
⚠️ Le pire scénario : perdre le fichier .tfstate. Terraform ne sait plus ce qu'il gère. Tu dois tout terraform import à la main (une resource à la fois). En remote backend (S3, Azure Storage, GCS), le state est chiffré, versionné, et partagé entre l'équipe. Sans ça, pas de CI/CD.
Le résultat
| MÉTRIQUE | AVANT (À LA MAIN) | APRÈS (TERRAFORM) |
|---|---|---|
| Création environnement complet | 3 jours | 15 minutes (terraform apply) |
| Recréer la recette | 3 semaines de reverse-engineering | terraform apply |
| Audit d'accès | SHOW GRANTS (340 lignes) |
terraform plan (lisible) |
| Modification RBAC | SQL manuel, non tracé | Pull request + review |
| Détection de drift | Impossible | terraform plan le signale |
| Onboarding nouveau data engineer | "Va voir le wiki" | Clone le repo, terraform init |
| Désastre recovery | Recréer depuis zéro | terraform apply depuis Git |
| Création d'un utilisateur | SQL manuel + GRANT ROLE |
Une ligne dans terraform.tfvars |
Les erreurs que j'ai faites pour vous
| ERREUR | CONSÉQUENCE | CORRECTION |
|---|---|---|
Utiliser ACCOUNTADMIN dans le provider |
Terraform peut tout casser | Créer un rôle TF_ADMIN dédié |
| Authentification par mot de passe | Rotations manuelles, pas de CI/CD | Clé RSA + SNOWFLAKE_JWT |
| Pas de remote backend | State perdu = tout est perdu | S3/Azure Storage + encryption |
| Tout dans un seul provider | Erreurs de permissions | 3 providers (SYSADMIN, SECURITYADMIN, USERADMIN) + CREATE INTEGRATION délégué |
snowflake_role (v1) au lieu de snowflake_account_role (v2) |
Deprecation warnings, futur breaking | Migrer vers snowflake_account_role |
Grants en liste (roles = [...]) (v1) |
Race conditions, plans illisibles | Un snowflake_grant_account_role par relation |
Pas de depends_on entre modules |
Grants sur objets inexistants | depends_on = [module.platform, module.rbac_roles] |
Pas de auto_suspend sur les warehouses |
Facture 24/7 | auto_suspend = 60 obligatoire |
Pas de var.env_suffix |
Dev et prod dans le même state | Variables par environnement |
terraform apply sans plan en prod |
Changements non reviewés | CI/CD avec plan en MR |
| Pas de modules | Code dupliqué par environnement | Modules réutilisables + tfvars |
| Créer les users en SQL | Drift permanent entre SQL et Terraform | snowflake_user + snowflake_grant_account_role |
Migration v1 → v2 : ce qui change
Si tu as déjà un projet en provider v1, voici la checklist de migration :
1. Version du provider
# Avant
version = "~> 1.0"
# Après
version = "~> 2.18.0"
2. Authentification
# Avant (v1)
provider "snowflake" {
account = var.snowflake_account
user = var.snowflake_user
password = var.snowflake_password
}
# Après (v2)
provider "snowflake" {
organization_name = var.snowflake_org
account_name = var.snowflake_account
user = "TF_ADMIN"
authenticator = "SNOWFLAKE_JWT"
private_key = file("snowflake_key.p8")
}
3. Ressources RBAC renommées
| v1 (déprécié) | v2 (recommandé) |
|---|---|
snowflake_role |
snowflake_account_role |
snowflake_role_grants |
snowflake_grant_account_role |
snowflake_grant_privileges_to_role |
snowflake_grant_privileges_to_account_role |
role_name = ... (dans grants) |
account_role_name = ... |
roles = [...] (liste dans role_grants) |
parent_role_name = ... (un par ressource) |
4. Nouveau provider USERADMIN
Ajoute un 3ème provider pour la création des rôles et users. Split ton module rbac en rbac_roles (USERADMIN) + rbac_grants (SECURITYADMIN). Délègue CREATE INTEGRATION à TF_ADMIN pour gérer les integrations sans provider ACCOUNTADMIN.
5. State migration
Après avoir mis à jour le code, fais un terraform plan — le provider v2 détectera les ressources renommées et proposera un plan de migration. Accepte-le, vérifie que tout est en ordre, puis commit.
⚠️ Ne fais jamais une migration v1→v2 directement en prod. Teste d'abord en dev, vérifie que
terraform planne propose pas de destructions inattendues, et procède environnement par environnement.
Ce que Terraform ne gère pas (encore)
Même en v2, certains objets Snowflake ne sont pas couverts par le provider — ou ne doivent pas l'être :
- Resource monitors — la doc Snowflake réserve la création aux account administrators et ce privilège ne peut pas être délégué. Pas de
CREATE RESOURCE MONITORgranulé. Je les crée manuellement en SQL, c'est un objet compte-level qui change rarement - Streams (change data capture) — pas de ressource native
- Dynamic Tables — support partiel, mais la syntaxe SQL dans
statementest complexe - Certaines policies avancées (row access, aggregation) — en preview
- Tasks avec schedules complexes — le provider gère les tasks simples mais pas les DAGs
- Snowpark — pas encore
- Alerts — pas encore
La règle que j'applique : Terraform gère l'infrastructure (databases, warehouses, roles, grants, users, integrations). dbt gère les transformations (tables, views, tests). Airflow orchestre les tasks et les pipelines. Trois outils, trois responsabilités, zéro chevauchement. Pour aller plus loin sur l'orchestration, voir mon article sur Airflow + DBT + Snowflake.