Skip to content

microsoftexpert/tf-mod-snowflake-user

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

❄️ Snowflake User Terraform Module

Manages a single Snowflake PERSON user (snowflake_user) β€” identity/profile metadata, credential-reference fields, login-behavior toggles, and session-default references β€” against the snowflakedb/snowflake provider, pinned ~> 2.17.

Terraform Snowflake Provider Module Version Module Type Resources Posture


🧩 Overview

  • ❄️ Manages a single snowflake_user object β€” one keystone resource, no children. Models PERSON (human) users only; snowflake_service_user/snowflake_legacy_service_user are separate, not-yet-built modules for CI/service-account identity.
  • πŸ”’ Treats password as sensitive = true (matching the provider's own marking, plus a house override that applies regardless), and documents rsa_public_key/rsa_public_key_2 as 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_name is not, but may still carry personal data.
  • 🧱 Defaults must_change_password = true (a deliberate house override of Snowflake's own false default) and disable_mfa = false/disabled = false (matching Snowflake's own defaults) β€” see 🧱 Design Principles.
  • 🧾 Emits fully_qualified_name as the safe cross-reference for tf-mod-snowflake-role's role_grants map (as a user_name target) and tf-mod-snowflake-resource-monitor's notify_users set.

πŸ’‘ 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.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


πŸ—ΊοΈ Where this fits

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
Loading

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's fully_qualified_name output feeds this module's default_warehouse input.
  • Inbound β€” tf-mod-snowflake-role's fully_qualified_name output feeds this module's default_role input.
  • Inbound β€” tf-mod-snowflake-network-policy's fully_qualified_name output feeds this module's network_policy input.
  • Outbound, back to the same role sibling, via a different field β€” this module's own fully_qualified_name output feeds tf-mod-snowflake-role's role_grants map as a user_name target (an actual GRANT ROLE... TO USER..., distinct from the default_role relationship above, which only sets a session default and grants nothing).
  • Outbound β€” this module's fully_qualified_name output feeds tf-mod-snowflake-resource-monitor's notify_users input.

🧬 What this builds

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
Loading

Resource inventory:

Resource Count Notes
snowflake_user.this 1 (keystone) No for_each children β€” standalone module

βœ… Provider / Versions

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_mfa are STRING fields, not Boolean. The live schema accepts "true"/"false"/a "default" sentinel for each β€” this module models the caller-facing variable as a real bool and converts in main.tf, identical to tf-mod-snowflake-warehouse's auto_resume/enable_query_acceleration precedent.
  • default_secondary_roles_option has 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_name are provider-marked Sensitive; display_name is 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 emitting show_output as a module output; the underlying Terraform state still contains it in the clear.
  • days_to_expiry/mins_to_unlock/mins_to_bypass_mfa external changes are never detected. Expect a persistent, non-convergent plan diff on these three fields once populated β€” documented provider behavior, not a module defect. Remediate via terraform taint if a real external change must be picked up.
  • Use network_policy, never snowflake_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 a plan+apply may be needed after import to fill in fields like password.
  • Case sensitivity. name is passed through as-is; Snowflake case-folds an unquoted identifier to upper case.
  • fully_qualified_name is the safe cross-reference, not name β€” consume it from sibling modules, never the bare name.

πŸ”‘ Required Snowflake Privileges

  • CREATE USER β€” the global, account-level privilege required to create the keystone snowflake_user.this object. Note: the Terraform provider's own snowflake_user resource 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 USER is held by SECURITYADMIN (and by ACCOUNTADMIN via 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 at terraform 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_role to the user (GRANT ROLE) is a separate privileged operation this module does not perform β€” see tf-mod-snowflake-role's role_grants map.

Snowflake Prerequisites

  • 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_enabled flag 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_policy should reference objects that already exist (typically sibling tf-mod-snowflake-role, tf-mod-snowflake-warehouse, tf-mod-snowflake-database/tf-mod-snowflake-schema, and tf-mod-snowflake-network-policy instances, 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, for default_role/default_warehouse/default_namespace) or fails at apply time (network_policy), never at terraform validate time.
  • If password is populated, the value must be sourced from a secret store by the caller β€” this module accepts a sensitive = true string variable; it does not perform secret-store retrieval itself.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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).


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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. password must 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_key is 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 = true is a deliberate house override of Snowflake's own false default.

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

⚠️ Setting must_change_password = false skips the forced first-login password reset; setting disable_mfa = true weakens 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 plan diff on these three fields. The provider does not detect external changes to days_to_expiry/mins_to_unlock/mins_to_bypass_mfa because each value continuously changes on the Snowflake side after being set (e.g. mins_to_unlock counts 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_role does not grant the role to the user β€” see Example 15's end-to-end composition for the actual GRANT ROLE step via tf-mod-snowflake-role's role_grants map.

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. email/first_name/middle_name/last_name/login_name are marked Sensitive by the provider itself; display_name is 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_each at the module block 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_each key 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_grants references jane_analyst_user.fully_qualified_name and jane_analyst_user.default_role references analyst_role.fully_qualified_name in the same file. Terraform's dependency graph operates at the resource level, not the whole-module level: default_role only depends on analyst_role's keystone snowflake_account_role.this, while role_grants creates a separate snowflake_grant_account_role child resource that depends on jane_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: setting default_role only configures the user's session default and grants nothing by itself β€” the role_grants entry above is the step that actually runs GRANT ROLE ANALYST_RO TO USER JANE_ANALYST.


πŸ“₯ Inputs

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.
}

🧾 Outputs

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).


🧠 Architecture Notes

  • disabled/disable_mfa/must_change_password are bool in this module's interface, string on the live provider schema. main.tf performs var.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 explicit true/false.
  • days_to_expiry/mins_to_unlock/mins_to_bypass_mfa are known non-convergent fields. External changes are never detected by the provider (documented in the resource's own doc header) β€” a persistent plan diff on these three specifically, once populated, is expected behavior. terraform taint is the provider's own documented remediation path to force re-creation and pick up an external value.
  • password sensitivity is both a provider marking and a house override. Even on a hypothetical future provider version that stopped marking password sensitive, this module would keep sensitive = true per 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_name are Sensitive-marked as inputs, but their show_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 surfacing show_output as a module output; protect the underlying state file itself as a sensitive artifact whenever any of these fields is populated.
  • default_role does not grant the role. Per the provider's own docs, setting a user's default_role configures a session default only β€” the role must still be granted via GRANT ROLE, modeled in this library by tf-mod-snowflake-role's role_grants map (keyed by user_name = this module's fully_qualified_name output). See Example 15's end-to-end composition.
  • network_policy is the only network-policy-assignment mechanism this module exposes. The resource's own doc header prefers it over snowflake_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.

🧱 Design Principles

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.


πŸš€ Runbook

cd C:\GitHubCode\newsnowflakemodules\tf-mod-snowflake-user
terraform init -backend=false
terraform validate
terraform fmt -check

Pin 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.


πŸ§ͺ Testing

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/number shape of each field, including the disabled/disable_mfa/must_change_password bool-to-string conversion path in main.tf), the name identifier-character rejection, and the default_secondary_roles_option validation {} block (DEFAULT/NONE/ALL, case-insensitive) β€” all of this fires at terraform validate, before any API call, with no live Snowflake account required. HCL syntax and formatting are covered by fmt -check.
  • What only a real plan/apply exercises: whether the executing role actually holds CREATE USER (privilege sufficiency, typically SECURITYADMIN); whether a referenced default_role/default_warehouse/default_namespace/network_policy actually exists in the target account (this module does not validate any of these at plan time); and the days_to_expiry/mins_to_unlock/mins_to_bypass_mfa non-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.

πŸ’¬ Example Output

$ terraform output

fully_qualified_name = "\"JANE_ANALYST\""
name = "JANE_ANALYST"
id = "\"JANE_ANALYST\""

πŸ” Troubleshooting

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

πŸ”— Related Docs

  • 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 (consumes fully_qualified_name via role_grants' user_name; feeds default_role), tf-mod-snowflake-warehouse (feeds default_warehouse), tf-mod-snowflake-network-policy (feeds network_policy), tf-mod-snowflake-resource-monitor (consumes fully_qualified_name via notify_users), tf-mod-snowflake-database/ tf-mod-snowflake-schema (feed default_namespace)
  • This module's SCOPE.md

Packages

 
 
 

Contributors

Languages