Manages a single Snowflake PERSON user (
snowflake_user) β identity/profile metadata, credential-reference fields, login-behavior toggles, and session-default references β against thesnowflakedb/snowflakeprovider, pinned~> 2.17.
- βοΈ Manages a single
snowflake_userobject β one keystone resource, no children. Models PERSON (human) users only;snowflake_service_user/snowflake_legacy_service_userare separate, not-yet-built modules for CI/service-account identity. - π Treats
passwordassensitive = true(matching the provider's own marking, plus a house override that applies regardless), and documentsrsa_public_key/rsa_public_key_2as security-sensitive-by-pairing even though the provider does not mark them sensitive. - π Carries an PII-adjacent caution on every identity/profile field (
login_name,display_name,email,first_name,middle_name,last_name) β five of the six are provider-marked Sensitive;display_nameis not, but may still carry personal data. - π§± Defaults
must_change_password = true(a deliberate house override of Snowflake's ownfalsedefault) anddisable_mfa = false/disabled = false(matching Snowflake's own defaults) β see π§± Design Principles. - π§Ύ Emits
fully_qualified_nameas the safe cross-reference fortf-mod-snowflake-role'srole_grantsmap (as auser_nametarget) andtf-mod-snowflake-resource-monitor'snotify_usersset.
π‘ Why it matters: this module is deliberately last in this suite's recommended authoring order. Production user provisioning is meant to land on top of an already-exercised role/warehouse/network-policy/resource-monitor model, not be the first thing tested against a new library β a user with no role granted, no warehouse assigned, and no network policy enforced is a far riskier empty call to hand a caller than any single one of those siblings in isolation.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
warehouse["tf-mod-snowflake-warehouse"]
role["tf-mod-snowflake-role"]
netpolicy["tf-mod-snowflake-network-policy"]
resmon["tf-mod-snowflake-resource-monitor"]
thismod["tf-mod-snowflake-user"]
keystone[["snowflake_user.this"]]
thismod -->|"creates"| keystone
warehouse -->|"fully_qualified_name feeds default_warehouse"| thismod
role -->|"fully_qualified_name feeds default_role"| thismod
netpolicy -->|"fully_qualified_name feeds network_policy"| thismod
keystone -->|"fully_qualified_name feeds role_grants as user_name target"| role
keystone -->|"fully_qualified_name feeds notify_users"| resmon
style thismod fill:#29B5E8,color:#ffffff
style keystone fill:#11567F,color:#ffffff
style warehouse fill:#ECEFF1,color:#000000
style role fill:#ECEFF1,color:#000000
style netpolicy fill:#ECEFF1,color:#000000
style resmon fill:#ECEFF1,color:#000000
This module has a real, already-authored sibling family β the family DAG is not dropped. Four
distinct relationships, verified against each sibling's own outputs.tf/variables.tf:
- Inbound β
tf-mod-snowflake-warehouse'sfully_qualified_nameoutput feeds this module'sdefault_warehouseinput. - Inbound β
tf-mod-snowflake-role'sfully_qualified_nameoutput feeds this module'sdefault_roleinput. - Inbound β
tf-mod-snowflake-network-policy'sfully_qualified_nameoutput feeds this module'snetwork_policyinput. - Outbound, back to the same role sibling, via a different field β this module's own
fully_qualified_nameoutput feedstf-mod-snowflake-role'srole_grantsmap as auser_nametarget (an actualGRANT ROLE... TO USER..., distinct from thedefault_rolerelationship above, which only sets a session default and grants nothing). - Outbound β this module's
fully_qualified_nameoutput feedstf-mod-snowflake-resource-monitor'snotify_usersinput.
flowchart TB
subgraph inputs["Inputs"]
name["name (required)"]
identity["login_name, display_name, email,\nfirst_name, middle_name, last_name"]
secrets["password, rsa_public_key,\nrsa_public_key_2"]
behavior["disabled, disable_mfa,\nmust_change_password"]
drift["days_to_expiry, mins_to_unlock,\nmins_to_bypass_mfa"]
refs["default_role, default_warehouse,\ndefault_namespace, default_secondary_roles_option,\nnetwork_policy"]
comment["comment"]
end
keystone[["snowflake_user.this"]]
subgraph outputs["Outputs"]
fqn["fully_qualified_name"]
outname["name"]
outid["id"]
end
inputs -->|"renders"| keystone
keystone -->|"emits"| outputs
style keystone fill:#11567F,color:#ffffff
style inputs fill:#ECEFF1,color:#000000
style outputs fill:#ECEFF1,color:#000000
Resource inventory:
| Resource | Count | Notes |
|---|---|---|
snowflake_user.this |
1 (keystone) | No for_each children β standalone module |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
snowflakedb/snowflake provider |
~> 2.17 |
| Provider block | Not present β the caller's root module configures the provider (account identifier, authentication) and passes any role alias in |
Schema notes that bite:
disabled,must_change_password,disable_mfaare STRING fields, not Boolean. The live schema accepts"true"/"false"/a"default"sentinel for each β this module models the caller-facing variable as a realbooland converts inmain.tf, identical totf-mod-snowflake-warehouse'sauto_resume/enable_query_accelerationprecedent.default_secondary_roles_optionhas three legal values βDEFAULT|NONE|ALL(case-insensitive) β not two, correcting this module's own seed assumption.email/login_name/first_name/middle_name/last_nameare provider-markedSensitive;display_nameis not. This module matches the provider's own marking rather than guessing.- The
show_output.*read-back copies of those same fields are NOT marked sensitive β a provider-level state-exposure gap this module mitigates only by not emittingshow_outputas a module output; the underlying Terraform state still contains it in the clear. days_to_expiry/mins_to_unlock/mins_to_bypass_mfaexternal changes are never detected. Expect a persistent, non-convergentplandiff on these three fields once populated β documented provider behavior, not a module defect. Remediate viaterraform taintif a real external change must be picked up.- Use
network_policy, neversnowflake_network_policy_attachment. The resource's own doc header explicitly prefers this field; the attachment resource is described as being reworked. - Import does not populate
password. The provider's own docs note aplan+applymay be needed after import to fill in fields likepassword. - Case sensitivity.
nameis passed through as-is; Snowflake case-folds an unquoted identifier to upper case. fully_qualified_nameis the safe cross-reference, notnameβ consume it from sibling modules, never the barename.
CREATE USERβ the global, account-level privilege required to create the keystonesnowflake_user.thisobject. Note: the Terraform provider's ownsnowflake_userresource page does not itself enumerate a required-privileges section (unlike some other provider resource pages) β this requirement is standard Snowflake SQL access-control documentation, not a claim sourced from the Terraform docs page. In Snowflake's default role hierarchy,CREATE USERis held bySECURITYADMIN(and byACCOUNTADMINvia role inheritance). Confirm the executing role's actual grant via the caller's aliased provider configuration β a role without this privilege fails at apply time with a Snowflake authorization error, not atterraform validate.- Subsequent updates (
ALTER USER) and drops (DROP USER) require ownership of the object or an equivalent privileged role β normally satisfied automatically by whichever role's session created it. - Actually granting
default_roleto the user (GRANT ROLE) is a separate privileged operation this module does not perform β seetf-mod-snowflake-role'srole_grantsmap.
- No specific Snowflake account edition requirement documented by the provider β the resource is
marked Stable (GA) in its own doc header, not preview-gated. No
preview_features_enabledflag required. - The executing role must already hold
CREATE USERβ provisioned by the caller's root module/provider alias; this module cannot grant itself the privilege it needs to run. - If populated,
default_role/default_warehouse/default_namespace/network_policyshould reference objects that already exist (typically siblingtf-mod-snowflake-role,tf-mod-snowflake-warehouse,tf-mod-snowflake-database/tf-mod-snowflake-schema, andtf-mod-snowflake-network-policyinstances, respectively) β this module does not create, look up, or validate any of these references; a nonexistent or malformed reference either fails silently (per the provider's own docs, fordefault_role/default_warehouse/default_namespace) or fails at apply time (network_policy), never atterraform validatetime. - If
passwordis populated, the value must be sourced from a secret store by the caller β this module accepts asensitive = truestring variable; it does not perform secret-store retrieval itself.
tf-mod-snowflake-user/
βββ providers.tf # required_providers + required_version β no provider {} block
βββ variables.tf # identity/profile, secrets-shaped, behavior, drift-prone, session-default-reference, comment
βββ main.tf # the keystone snowflake_user.this β no children
βββ outputs.tf # fully_qualified_name, name, id
βββ README.md # this file
βββ SCOPE.md # lightweight standalone spec β privileges, prerequisites, gotchas
βββ examples/ # runnable example call sites
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
}The caller's root module configures the snowflake provider (account identifier, authentication,
and any role alias β see this suite's Authentication model convention). This module never declares
account_name, organization_name, user, role, or any credential-shaped variable β except
password/rsa_public_key/rsa_public_key_2, which are user metadata this resource itself
requires, not provider-authentication concerns (see π§± Design Principles).
Consumes (plain string references β this module does not create, look up, or validate any of
these; a nonexistent or malformed reference fails at apply time, not at terraform validate):
| Input | Type | Source module |
|---|---|---|
default_role (optional) |
string |
tf-mod-snowflake-role's fully_qualified_name output |
default_warehouse (optional) |
string |
tf-mod-snowflake-warehouse's fully_qualified_name output |
default_namespace (optional) |
string |
tf-mod-snowflake-database's or tf-mod-snowflake-schema's fully_qualified_name output |
network_policy (optional) |
string |
tf-mod-snowflake-network-policy's fully_qualified_name output |
Emits:
| Output | Description | Consumed by |
|---|---|---|
fully_qualified_name |
The user's fully-qualified identifier | tf-mod-snowflake-role's role_grants map (as a user_name target), tf-mod-snowflake-resource-monitor's notify_users set |
name |
The user's bare name, as passed to var.name |
Documentation/display only |
id |
The user's Terraform resource id | Import / drift-detection tooling |
1 Β· Minimal call β just name
π‘ The empty call (name only) produces a PERSON user with
disabled = false,disable_mfa = false,must_change_password = true(house override), no password, no key-pair auth, and no session defaults β see π§± Design Principles.
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
}2 Β· π Password sourced from a secret store
π Never a literal.
passwordmust be sourced from a secret store (Azure Key Vault, a CI secret store) at plan/apply time β never hand-typed or committed to.tfvars. The variable below stands in for that secret-store lookup; this example does not show a real value.
variable "finance_analyst_password" {
type = string
sensitive = true
# Sourced from Azure Key Vault by the caller's CI pipeline at apply time β never a literal here.
}
module "finance_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "FINANCE_ANALYST_01"
password = var.finance_analyst_password
}3 Β· π Key-pair authentication via rsa_public_key
π
rsa_public_keyis public key material, not itself secret, but it pairs with a private key that IS secret β source it from the same key-management pipeline that generates/rotates the key pair, never a hand-typed literal.
variable "svc_etl_rsa_public_key" {
type = string
# Sourced from the organization's key-management pipeline β never hand-typed.
}
module "svc_etl_keypair_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "SVC_ETL_KEYPAIR"
rsa_public_key = var.svc_etl_rsa_public_key
}4 Β· House secure defaults made explicit β disabled / disable_mfa / must_change_password
π‘ This is what Example 1's empty call already produces; shown explicitly here for clarity.
must_change_password = trueis a deliberate house override of Snowflake's ownfalsedefault.
module "new_hire_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "NEW_HIRE_01"
disabled = false
disable_mfa = false
must_change_password = true
}5 Β· β οΈ Explicit opt-out of the house secure defaults
β οΈ Settingmust_change_password = falseskips the forced first-login password reset; settingdisable_mfa = trueweakens the account's own MFA posture. Both must be typed explicitly and justified in the PR description β never left as a leftover default.
module "svc_break_glass_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "SVC_BREAK_GLASS"
must_change_password = false # justified in INFRA-5210 β automation account, no interactive login
disable_mfa = true # justified in INFRA-5210 β compensating IP allow-list via network_policy
network_policy = "NP_BREAKGLASS_RESTRICTED"
}6 Β· βΉοΈ Lockout/expiry fields with the non-convergent-plan gotcha
βΉοΈ Expect a persistent, non-convergent
plandiff on these three fields. The provider does not detect external changes todays_to_expiry/mins_to_unlock/mins_to_bypass_mfabecause each value continuously changes on the Snowflake side after being set (e.g.mins_to_unlockcounts down in real time). This is documented provider behavior, not a bug in this module β re-create the resource manually (terraform taint) if a real external change must be applied.
module "temporary_contractor_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "CONTRACTOR_TEMP_01"
days_to_expiry = 30
mins_to_unlock = 15
mins_to_bypass_mfa = 10
}7 Β· Session defaults wired from sibling module outputs
module "reporting_warehouse" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-warehouse.git?ref=v1.0.0"
name = "WH_REPORTING"
}
module "analyst_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-role.git?ref=v1.0.0"
name = "ANALYST_RO"
}
module "reporting_db" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-database.git?ref=v1.0.0"
name = "REPORTING_DB"
}
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
default_warehouse = module.reporting_warehouse.fully_qualified_name
default_role = module.analyst_role.fully_qualified_name
default_namespace = module.reporting_db.fully_qualified_name
}βΉοΈ Setting
default_roledoes not grant the role to the user β see Example 15's end-to-end composition for the actualGRANT ROLEstep viatf-mod-snowflake-role'srole_grantsmap.
8 Β· Network policy wired from tf-mod-snowflake-network-policy
module "analyst_egress_policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-network-policy.git?ref=v1.0.0"
name = "NP_ANALYST_DESKTOPS"
allowed_ip_list = ["203.0.113.0/25"]
}
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
network_policy = module.analyst_egress_policy.fully_qualified_name
}9 Β· default_secondary_roles_option set to ALL
module "power_user_all_secondary_roles" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "POWER_USER_01"
default_secondary_roles_option = "ALL"
}10 Β· π Identity/profile fields populated β PII-adjacent caution
π Do not populate these fields with real personal data in any non-restricted logging/output context.
first_name/middle_name/last_name/login_nameare markedSensitiveby the provider itself;display_nameis not, but the provider's own doc header still warns against entering personal data as metadata for it. The values below are illustrative placeholders, not real personal data.
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
login_name = "jane.analyst"
display_name = "Jane Analyst"
email = "jane.analyst@example.com"
first_name = "Jane"
last_name = "Analyst"
}11 Β· Key-pair rotation β rsa_public_key_2 alongside rsa_public_key
π During a scheduled key-pair rotation window, both keys are populated together so the old key keeps working until every client has switched to the new one.
variable "svc_etl_rsa_public_key_current" {
type = string
}
variable "svc_etl_rsa_public_key_next" {
type = string
}
module "svc_etl_keypair_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "SVC_ETL_KEYPAIR"
rsa_public_key = var.svc_etl_rsa_public_key_current
rsa_public_key_2 = var.svc_etl_rsa_public_key_next
}12 Β· Multiple users at scale via a caller-level for_each
βΉοΈ This module itself has no children (standalone), but a caller can apply
for_eachat themoduleblock to stamp out one user per entry in a map β each instance is independently keyed, so adding or removing a user never disturbs its siblings.
variable "onboarding_users" {
type = map(object({
email = string
}))
default = {
"ANALYST_02" = { email = "analyst02@example.com" }
"ANALYST_03" = { email = "analyst03@example.com" }
}
}
module "onboarding_batch" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
for_each = var.onboarding_users
name = each.key
email = each.value.email
}13 Β· Documented service-adjacent account with an explicit comment
module "svc_reporting_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "SVC_REPORTING_BATCH"
comment = "Batch reporting account. Owner: Data Platform. Change ticket: INFRA-4901."
}14 Β· Day-2 update β disabling a departed employee's user
βΉοΈ Because every field here is a flat argument on the one keystone resource, disabling a user later is a normal in-place update β no
for_eachkey or resource identity changes, since this module has only the one keystone resource.
module "former_employee_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JOHN_FORMER_EMPLOYEE"
disabled = true # flipped from false on 2026-07-11 following offboarding β see HR-2231
comment = "Disabled 2026-07-11 following offboarding. Not dropped β retained for audit trail."
}15 Β· ποΈ End-to-end composition β four sibling modules wired together
Wires four already-authored sibling modules at once: tf-mod-snowflake-warehouse,
tf-mod-snowflake-role, tf-mod-snowflake-network-policy, and this user module β including the
actual GRANT ROLE step via tf-mod-snowflake-role's role_grants map, keyed with this user's
fully_qualified_name output as the user_name target.
module "reporting_warehouse" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-warehouse.git?ref=v1.0.0"
name = "WH_REPORTING"
}
module "analyst_egress_policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-network-policy.git?ref=v1.0.0"
name = "NP_ANALYST_DESKTOPS"
allowed_ip_list = ["203.0.113.0/25"]
}
module "analyst_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-role.git?ref=v1.0.0"
name = "ANALYST_RO"
# Grants this role to Jane's user β see the βΉοΈ callout below on why this is not a circular
# dependency despite referencing a module defined later in this file.
role_grants = {
"grant_to_jane" = { user_name = module.jane_analyst_user.fully_qualified_name }
}
}
module "jane_analyst_user" {
source = "git::https://github.com/microsoftexpert/tf-mod-snowflake-user.git?ref=v1.0.0"
name = "JANE_ANALYST"
default_warehouse = module.reporting_warehouse.fully_qualified_name
default_role = module.analyst_role.fully_qualified_name
network_policy = module.analyst_egress_policy.fully_qualified_name
}βΉοΈ This is not a circular dependency, even though
analyst_role.role_grantsreferencesjane_analyst_user.fully_qualified_nameandjane_analyst_user.default_rolereferencesanalyst_role.fully_qualified_namein the same file. Terraform's dependency graph operates at the resource level, not the whole-module level:default_roleonly depends onanalyst_role's keystonesnowflake_account_role.this, whilerole_grantscreates a separatesnowflake_grant_account_rolechild resource that depends onjane_analyst_user's keystone. Block ordering in the file does not matter β Terraform resolves the real resource-level graph regardless of textual order.π‘ This also demonstrates the schema note from
default_role's own description: settingdefault_roleonly configures the user's session default and grants nothing by itself β therole_grantsentry above is the step that actually runsGRANT ROLE ANALYST_RO TO USER JANE_ANALYST.
| Variable | Type | Default | Required |
|---|---|---|---|
name |
string |
β | Yes |
login_name |
string |
null |
No |
display_name |
string |
null |
No |
email |
string |
null |
No |
first_name |
string |
null |
No |
middle_name |
string |
null |
No |
last_name |
string |
null |
No |
password |
string |
null |
No |
rsa_public_key |
string |
null |
No |
rsa_public_key_2 |
string |
null |
No |
disabled |
bool |
false |
No |
disable_mfa |
bool |
false |
No |
must_change_password |
bool |
true |
No |
days_to_expiry |
number |
null |
No |
mins_to_unlock |
number |
null |
No |
mins_to_bypass_mfa |
number |
null |
No |
default_role |
string |
null |
No |
default_warehouse |
string |
null |
No |
default_namespace |
string |
null |
No |
default_secondary_roles_option |
string |
"DEFAULT" |
No |
network_policy |
string |
null |
No |
comment |
string |
null |
No |
Full variable schemas
variable "name" {
type = string
# Required. Unique within the account. Avoid `|`, `.`, `"`. Passed through as-is β expect
# Snowflake to case-fold an unquoted name to upper case. Used as login_name if that is unset.
}
variable "login_name" {
type = string
default = null
sensitive = true
# Sensitive per the provider's own schema marking. π PII-adjacent.
}
variable "display_name" {
type = string
default = null
# NOT provider-marked sensitive. π PII-adjacent anyway β Snowsight display name.
}
variable "email" {
type = string
default = null
sensitive = true
# Sensitive per the provider's own schema marking. π PII-adjacent.
}
variable "first_name" {
type = string
default = null
sensitive = true
}
variable "middle_name" {
type = string
default = null
sensitive = true
}
variable "last_name" {
type = string
default = null
sensitive = true
}
variable "password" {
type = string
default = null
sensitive = true
# π House override + provider marking. Source from a secret store β never a literal.
}
variable "rsa_public_key" {
type = string
default = null
# NOT provider-marked sensitive (public key material). π Still source from a key-management
# pipeline β pairs with a private key that IS secret.
}
variable "rsa_public_key_2" {
type = string
default = null
# Second key for key-pair rotation. Same sourcing caution as rsa_public_key.
}
variable "disabled" {
type = bool
default = false
# Provider gotcha: underlying field is a STRING ("true"/"false"/"default"); this module always
# sends an explicit "true"/"false".
}
variable "disable_mfa" {
type = bool
default = false
# House secure default: never disable MFA by default. Same STRING-not-bool provider gotcha.
}
variable "must_change_password" {
type = bool
default = true
# Deliberate house override of Snowflake's own `false` default. Same STRING-not-bool gotcha.
}
variable "days_to_expiry" {
type = number
default = null
# βΉοΈ External changes not detected by the provider β expect a non-convergent plan once set.
}
variable "mins_to_unlock" {
type = number
default = null
# βΉοΈ Same external-drift gotcha as days_to_expiry.
}
variable "mins_to_bypass_mfa" {
type = number
default = null
# βΉοΈ Same external-drift gotcha as days_to_expiry.
}
variable "default_role" {
type = string
default = null
# Sourced from tf-mod-snowflake-role's fully_qualified_name output. Setting this does NOT grant
# the role β see tf-mod-snowflake-role's role_grants map.
}
variable "default_warehouse" {
type = string
default = null
# Sourced from tf-mod-snowflake-warehouse's fully_qualified_name output.
}
variable "default_namespace" {
type = string
default = null
# Sourced from tf-mod-snowflake-database's or tf-mod-snowflake-schema's fully_qualified_name.
}
variable "default_secondary_roles_option" {
type = string
default = "DEFAULT"
# One of (case-insensitive): DEFAULT, NONE, ALL. Matches the provider's own documented default.
}
variable "network_policy" {
type = string
default = null
# Sourced from tf-mod-snowflake-network-policy's fully_qualified_name output. Prefer this field
# over snowflake_network_policy_attachment (provider's own guidance).
}
variable "comment" {
type = string
default = null
# Optional free-text comment. Does not affect object behavior.
}| Output | Description | Sensitive / Conditional |
|---|---|---|
fully_qualified_name |
The user's fully-qualified identifier β the safe cross-reference for sibling modules | Always present |
name |
The user's bare name, as passed to var.name |
Always present |
id |
The user's Terraform resource id | Always present |
password, rsa_public_key, rsa_public_key_2, and every identity/profile field (email,
login_name, display_name, first_name, middle_name, last_name) are never output β
deliberate, per this library's PII-adjacent data-handling convention and this module's own output-minimization design
decision (see π§ Architecture Notes).
disabled/disable_mfa/must_change_passwordareboolin this module's interface,stringon the live provider schema.main.tfperformsvar.x ? "true": "false"for each β the "default" sentinel value the provider itself supports is never surfaced as a caller-facing option, since the house default for each is always an explicittrue/false.days_to_expiry/mins_to_unlock/mins_to_bypass_mfaare known non-convergent fields. External changes are never detected by the provider (documented in the resource's own doc header) β a persistentplandiff on these three specifically, once populated, is expected behavior.terraform taintis the provider's own documented remediation path to force re-creation and pick up an external value.passwordsensitivity is both a provider marking and a house override. Even on a hypothetical future provider version that stopped markingpasswordsensitive, this module would keepsensitive = trueper this suite's secrets-bearing-resources convention.- The
show_output.*read-back exposure gap is a provider-level limitation, not something this module can close.email/login_name/first_name/middle_name/last_nameare Sensitive-marked as inputs, but theirshow_output.*equivalents are not β meaning the resource's own computed attribute can hold a plaintext copy in Terraform state regardless of the input's sensitivity marking. This module's only mitigation is not surfacingshow_outputas a module output; protect the underlying state file itself as a sensitive artifact whenever any of these fields is populated. default_roledoes not grant the role. Per the provider's own docs, setting a user'sdefault_roleconfigures a session default only β the role must still be granted viaGRANT ROLE, modeled in this library bytf-mod-snowflake-role'srole_grantsmap (keyed byuser_name= this module'sfully_qualified_nameoutput). See Example 15's end-to-end composition.network_policyis the only network-policy-assignment mechanism this module exposes. The resource's own doc header prefers it oversnowflake_network_policy_attachment(described as being reworked in a future provider version) β this module does not create or wire the attachment resource.- Output surface is deliberately minimal. No credential, credential-adjacent, or identity/profile field is ever output β a caller who needs one of those values back already has it in their own input variable; re-exposing it through a module output would only widen the PII/credential blast radius for no benefit.
- The generic session-parameter passthrough surface (~50 fields shared with warehouse/task) is
out of scope for v1.0.0, by deliberate design decision, not oversight β see
variables.tf's header comment.
| Concern | Secure default in this module | Opt-out (caller must be explicit) |
|---|---|---|
| Object comment | null (no default disclosure of intent in comment) |
Caller sets comment explicitly |
| User disabled state | disabled = false (matches Snowflake's own default) |
Caller sets disabled = true explicitly |
| Multi-factor authentication | disable_mfa = false β never weakens MFA by default |
Caller sets disable_mfa = true explicitly, with a reviewable justification |
| Forced password reset | must_change_password = true β deliberate house override of Snowflake's own false default |
Caller sets must_change_password = false explicitly |
| Password / key-pair material | Never a literal β password sourced from a secret store, rsa_public_key/rsa_public_key_2 from a key-management pipeline |
N/A β this is a sourcing discipline, not a togglable default |
| Identity/profile fields | null by default; π PII-adjacent caution on every field regardless of provider sensitivity marking |
Caller populates explicitly, ideally with non-production placeholder data outside restricted contexts |
| Secondary roles | default_secondary_roles_option = "DEFAULT" (matches the provider's own documented default) |
Caller sets "ALL" or "NONE" explicitly |
Session defaults (default_role/default_warehouse/default_namespace) |
null β no default role/warehouse/namespace configured |
Caller populates explicitly, sourced from sibling module outputs |
| Network policy | null β no network policy enforced at the user level |
Caller populates network_policy explicitly, sourced from tf-mod-snowflake-network-policy |
| Role grants | Not owned by this module at all β default_role never grants; grants live in tf-mod-snowflake-role's role_grants map |
Caller wires role_grants on the role module explicitly |
The empty call (name only) produces a PERSON user that is enabled, has MFA available (not
disabled), requires a password change at first login if a password is ever set, has no
credentials configured, has no session defaults, and enforces no network policy β every riskier
behavior requires the caller to type it explicitly, consistent with this suite's "the empty call
must produce the safe resource" convention.
cd C:\GitHubCode\newsnowflakemodules\tf-mod-snowflake-user
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module source to ?ref=v1.0.0 β never a branch β in every caller. This is a plan-only
authoring pipeline: no terraform plan or terraform apply is ever run as part of authoring this
module. A human runs apply from a governed CI pipeline against a real Snowflake account.
terraform validate and terraform fmt -check are the offline proof gate this module ships with:
- What they cover: type correctness of every variable (the
bool/string/numbershape of each field, including thedisabled/disable_mfa/must_change_passwordbool-to-string conversion path inmain.tf), thenameidentifier-character rejection, and thedefault_secondary_roles_optionvalidation {}block (DEFAULT/NONE/ALL, case-insensitive) β all of this fires atterraform validate, before any API call, with no live Snowflake account required. HCL syntax and formatting are covered byfmt -check. - What only a real
plan/applyexercises: whether the executing role actually holdsCREATE USER(privilege sufficiency, typicallySECURITYADMIN); whether a referenceddefault_role/default_warehouse/default_namespace/network_policyactually exists in the target account (this module does not validate any of these at plan time); and thedays_to_expiry/mins_to_unlock/mins_to_bypass_mfanon-convergent-plan behavior, which is only observable against a real account since the provider does not detect Snowflake-side changes to these fields between applies.
$ terraform output
fully_qualified_name = "\"JANE_ANALYST\""
name = "JANE_ANALYST"
id = "\"JANE_ANALYST\""
| Symptom | Cause | Fix |
|---|---|---|
terraform validate fails with "name must not contain the characters..." |
name contains ` |
, ., or "` |
terraform validate fails on default_secondary_roles_option |
Value is not one of DEFAULT/NONE/ALL (case-insensitive) |
Correct the value to one of the three legal options |
Apply fails with a Snowflake authorization error on CREATE USER |
Executing role lacks the account-level CREATE USER privilege |
Grant CREATE USER to the executing role (typically via SECURITYADMIN), or apply through a provider alias configured for a role that holds it |
| User can log in but immediately gets "role not granted" | default_role only sets a session default β it does not grant the role |
Add a role_grants entry on the corresponding tf-mod-snowflake-role instance, keyed with this user's fully_qualified_name as user_name (see Example 15) |
plan keeps showing a diff on days_to_expiry/mins_to_unlock/mins_to_bypass_mfa even with no .tf changes |
Documented provider limitation β external changes to these fields are never detected, and the Snowflake-side value continuously changes after being set | Expected behavior; use terraform taint on snowflake_user.this to re-create and pick up a real external change if needed |
Imported user shows a diff on password immediately after import |
Provider's own documented import gap β password is not populated by import |
Run plan+apply once after import (per the provider's own docs), or explicitly set password to the correct value sourced from a secret store |
A teammate's terraform show/state dump reveals a user's email/name in the clear despite sensitive = true on the input |
The show_output.* read-back copy of that field is not marked sensitive by the provider β a provider-level exposure gap, not a bug in this module |
Treat the Terraform state file itself as a sensitive artifact whenever any Sensitive-marked identity field is populated; restrict state access accordingly |
Apply fails trying to reference a default_role/default_warehouse/network_policy that doesn't exist |
The referenced sibling object hasn't been created yet, or the reference string is malformed | Confirm the sibling module (tf-mod-snowflake-role/tf-mod-snowflake-warehouse/tf-mod-snowflake-network-policy) applied successfully first, and that its fully_qualified_name output is what was passed in |
- Provider resource docs:
snowflake_user - Related provider resources (not built in this pass):
snowflake_service_user,snowflake_legacy_service_user - Sibling modules:
tf-mod-snowflake-role(consumesfully_qualified_nameviarole_grants'user_name; feedsdefault_role),tf-mod-snowflake-warehouse(feedsdefault_warehouse),tf-mod-snowflake-network-policy(feedsnetwork_policy),tf-mod-snowflake-resource-monitor(consumesfully_qualified_namevianotify_users),tf-mod-snowflake-database/tf-mod-snowflake-schema(feeddefault_namespace) - This module's
SCOPE.md