Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure SQL Server Security Alert Policy Terraform Module

Advanced Threat Protection for an Azure SQL logical server β€” which detections run, who hears about them, and where the records go. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ›‘οΈ Turns on Advanced Threat Protection for an Azure SQL logical server.
  • πŸ”‘ state is required by the provider, so this suite's secure-by-default rule cannot apply β€” the module compensates rather than pretending otherwise.
  • 🚫 Reads disabled_alerts for what it is β€” a deny list β€” and emits its complement positively.
  • πŸ”— Its id is what the vulnerability-assessment module consumes, not the server's, and that policy must be Enabled or the sibling refuses to configure.
  • βš–οΈ Names the differences from the managed-instance twin: five alert types, not six, and a differently-spelled admin-email argument.

πŸ’‘ Why it matters: two states here look nothing alike in configuration and identical in effect. state = "Disabled" and a deny list containing all five alert types are both fully-formed records that detect absolutely nothing, and neither produces a warning. A third β€” terraform destroy β€” leaves the first of those behind rather than removing anything.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
    SRV["terraform-azurerm-mssql-server"]
    THIS["terraform-azurerm-mssql-server-security-alert-policy"]
    VA["terraform-azurerm-mssql-server-vulnerability-assessment"]
    SA["terraform-azurerm-storage-account"]
    SC["terraform-azurerm-storage-container"]
    MIP["terraform-azurerm-mssql-managed-instance-security-alert-policy"]

    SRV -->|"name and resource_group_name"| THIS
    THIS -->|"id, and it must be Enabled"| VA
    SA -->|"primary_blob_endpoint"| THIS
    SA -->|"holds the container"| SC
    SC -->|"url"| VA
    MIP -.->|"six alert types there, five here"| THIS

    style THIS fill:#0078D4,stroke:#004578,color:#ffffff
    style SRV fill:#004578,stroke:#004578,color:#ffffff
    style VA fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style SA fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style SC fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style MIP fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading

The edge from this node to the vulnerability assessment carries this policy's id, not the server's β€” that resource's parent really is the policy, and the provider refuses to configure it unless this policy is Enabled. The dotted edge is the difference to hold onto when moving between the server and managed-instance resources: six alert types there, five here, and a deny list copied across fails on Brute_Force.


🧬 What this module builds

flowchart TB
    PARENT["server_name plus resource_group_name (force-new)"]
    STATE["state (REQUIRED, Enabled or Disabled)"]
    DA["disabled_alerts (a DENY list, FIVE types)"]
    ST["storage_endpoint plus access key (mutually required)"]

    POL["azurerm_mssql_server_security_alert_policy.this"]

    OID["id, consumed by the vulnerability assessment"]
    OON["threat_detection_is_on"]
    OEN["enabled_alert_types"]
    ONO["this_policy_detects_nothing"]

    PARENT --> POL
    STATE --> POL
    DA --> POL
    ST --> POL
    POL --> OID
    POL --> OON
    POL --> OEN
    POL --> ONO

    style POL fill:#0078D4,stroke:#004578,color:#ffffff
    style STATE fill:#004578,stroke:#004578,color:#ffffff
    style PARENT fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style DA fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style ST fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OID fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OON fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OEN fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style ONO fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading
Resource Count Notes
azurerm_mssql_server_security_alert_policy.this 1 One per logical server; there is no policy name to choose

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures the provider, its auth, and the mandatory features {} block

Schema notes that bite:

  • πŸ”΄ state is REQUIRED, and it is a case-sensitive string enum (Enabled / Disabled) rather than the managed instance's optional bool. There is no empty call to make safe.
  • πŸ”΄ There are FIVE alert types here, not six. Brute_Force is accepted by the managed-instance resource and refused by this one β€” so a deny list copied between them fails on exactly that value, loudly, at parse time.
  • πŸ”΄ storage_endpoint and storage_account_access_key are mutually RequiredWith β€” both or neither. That constraint is invisible in the binary schema, and it differs from the managed-instance resource where a key alone was merely useless.
  • πŸ”΄ There is no credential-free storage form. Unlike the SQL auditing policies, this resource has no managed-identity fallback: storage is configured with an access key or not at all.
  • πŸ”΄ terraform destroy does not delete this policy β€” it disables it. The delete sends a policy carrying only the disabled state, so the endpoint, recipients and retention it had are not preserved by that call.
  • πŸ”΄ There is no requires-import guard on create. Applying over a server that already carries an alert policy silently overwrites it.
  • ⚠️ The argument is email_account_admins, not email_account_admins_enabled as on the managed instance.
  • ⚠️ storage_endpoint carries only a non-empty check in the provider β€” no URL validation. The https and not-a-Resource-ID checks here are the module's.
  • ℹ️ resource_group_name comes from the provider's shared schema, which carries force-new and normalisation.
  • ℹ️ retention_days defaults to 0, governs storage only, and has no documented upper bound β€” so none is invented here.
  • ℹ️ No tags, no location. All four timeouts are honoured (30/5/30/30).

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Sql/servers/securityAlertPolicies/read and /write the SQL server create, update and disable the policy
SQL Security Manager (or Contributor) the SQL server Microsoft names this as the role for managing Defender for SQL settings

πŸ”΄ Disabling this policy breaks the sibling. The vulnerability-assessment resource re-reads this policy on every update and refuses unless it is Enabled β€” so write access here is effectively control over whether that resource can be changed at all.

πŸ”’ The Terraform principal needs no storage permission. It supplies an endpoint and a key; the service does the writing.


Azure Prerequisites

  • The Microsoft.Sql resource provider registered on the subscription.
  • An existing Azure SQL logical server.
  • πŸ’³ A Microsoft Defender for SQL entitlement, billed per server. state = "Enabled" is a purchase covering every database on it.
  • For storage-backed records: a storage account whose blob endpoint and access key are both supplied β€” the provider requires each with the other.
  • The caller configures provider "azurerm" { features {} }, auth and subscription.

πŸ“ Module Structure

terraform-azurerm-mssql-server-security-alert-policy/
β”œβ”€β”€ providers.tf    # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 10 inputs, deeply typed, with the required-state and storage-pair rules
β”œβ”€β”€ main.tf         # the single keystone `this`
β”œβ”€β”€ outputs.tf      # id first, then the detections in effect, then the posture facts
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"
}

There is no shorter call β€” state is required by the provider. All five detections run, nobody is emailed, and no storage copy is written. Applying it starts a per-server Defender for SQL charge.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
server_name name terraform-azurerm-mssql-server β†’ name
resource_group_name name the caller
storage_endpoint https URL terraform-azurerm-storage-account β†’ primary_blob_endpoint
storage_account_access_key sensitive string out of band β€” required with the endpoint

Emits

Output Description
id what the vulnerability-assessment module consumes
server_id derived by trimming this policy's ID
threat_detection_is_on state as a boolean
enabled_alert_types the deny list stated positively
this_policy_detects_nothing true when disabled or fully denied
alerts_reach_nobody_by_email true when no recipient is configured

πŸ“š Example Library

1 Β· The protective call
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"
}

output "on" {
  value = module.sql_threat_protection.threat_detection_is_on # true
}

πŸ”΄ state has no default here, because the provider gives none. This suite's rule is that an empty call produces the safe resource; the provider makes the argument required, so there is no empty call. The module compensates by naming the protective value inside its own error message and emitting the choice as a boolean.

πŸ’³ And Enabled is a purchase. Advanced Threat Protection is part of Microsoft Defender for SQL, priced per server.

2 Β· A disabled policy is a real record
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Disabled"
}

⚠️ This is not the same as having no policy. The record exists, appears in state and in the portal, and detects nothing.

πŸ”΄ It is also exactly what a terraform destroy leaves behind, since the provider's delete sends a disabled-state policy rather than removing anything. So "no alert policy in the configuration" and "no alert policy on the server" are different statements.

3 Β· Five alert types, not six
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  # A DENY list -- this turns Data_Exfiltration detection OFF.
  disabled_alerts = ["Data_Exfiltration"]
}

output "still_detecting" {
  # ["Access_Anomaly", "Sql_Injection", "Sql_Injection_Vulnerability", "Unsafe_Action"]
  value = module.sql_threat_protection.enabled_alert_types
}

πŸ”΄ Brute_Force is legal on a managed instance and refused here. A deny list copied from that module fails on exactly that value β€” loudly, at parse time, which is the safe direction for a mismatch.

πŸ’‘ all_alert_types emits the five so a list can be built against a checked reference rather than from memory. The values are case-sensitive.

4 Β· The configuration that detects nothing
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  disabled_alerts = [
    "Sql_Injection",
    "Sql_Injection_Vulnerability",
    "Access_Anomaly",
    "Data_Exfiltration",
    "Unsafe_Action",
  ]
}

output "detects_nothing" {
  value = module.sql_threat_protection.this_policy_detects_nothing # true
}

πŸ”΄ Legal, applies cleanly, detects absolutely nothing β€” while still costing the per-server Defender for SQL charge. state = "Disabled" reaches the same place by a different route, and the output covers both.

5 Β· Telling somebody
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  email_addresses      = ["secops@example.com"]
  email_account_admins = true # NOT email_account_admins_enabled -- see example 9
}

output "nobody_listening" {
  value = module.sql_threat_protection.alerts_reach_nobody_by_email # false
}

⚠️ The minimal call emails nobody. Detections are still recorded and visible in Microsoft Defender for Cloud, so this is a notification gap rather than a detection gap β€” but it is the gap where an alert fires and nobody hears it.

ℹ️ The provider applies no check to these addresses; this module refuses only a value with no @, and imposes no regex that could reject a legal address.

6 Β· The storage pair, which is both or neither
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  storage_endpoint           = module.threat_storage.primary_blob_endpoint
  storage_account_access_key = var.threat_storage_key
  retention_days             = 365
}

πŸ”΄ The provider declares each of these RequiredWith the other, so half a storage configuration is refused. This module mirrors that at plan so the message can name the missing half.

πŸ”΄ There is no credential-free form here. The SQL auditing policies fall back to the server's managed identity when a key is omitted; this resource does not. The only way to avoid the secret is to leave storage unconfigured.

⚠️ It wants the bare blob endpoint. The sibling vulnerability assessment wants a full container path β€” the two are opposites.

7 Β· The output the sibling actually needs
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled" # REQUIRED by the sibling below
}

module "sql_vulnerability_assessment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-vulnerability-assessment.git?ref=v1.0.0"

  # THIS POLICY'S id -- not module.sql_server.id.
  server_security_alert_policy_id = module.sql_threat_protection.id
  storage_container_path          = module.va_results.url
}

πŸ”΄ The vulnerability assessment's parent is this policy, not the server. The provider reads the referenced policy on that resource's create and update, and refuses unless the state is Enabled β€” so this is a real dependency, and passing module.sql_threat_protection.id is what makes Terraform order the two correctly.

⚠️ Setting this policy to Disabled later will break the sibling's next apply.

8 Β· Getting the server's ID back
output "server" {
  value = module.sql_threat_protection.server_id
}

πŸ’‘ This resource takes the server as a NAME plus a resource group, so a composition that needs the server's Resource ID would otherwise assemble the path by hand. It is derived here by trimming the /securityAlertPolicies/... suffix off this policy's own ID β€” exact, and needing no provider round-trip.

9 Β· Moving a configuration from the managed-instance module
output "renames_to_make" {
  # {
  #   state                = "enabled (a bool, and optional there)"
  #   email_account_admins = "email_account_admins_enabled"
  #   server_name          = "managed_instance_name"
  # }
  value = module.sql_threat_protection.the_argument_names_differ_from_the_managed_instance_resource
}

⚠️ Three argument names differ between the two resources, and one of them changes type β€” the managed instance's enabled is an optional bool, this one's state is a required string.

πŸ’‘ Every one of these mismatches fails loudly at parse time, which is the safe direction. The one that does not is Brute_Force in a deny list β€” that fails at parse time too, but only because this module mirrors the provider's closed enum.

10 Β· Adopting a server that may already have a policy
module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"
}

πŸ”΄ This apply will overwrite an existing alert policy without saying so. There is no requires-import guard on this resource, so a policy someone configured in the portal is silently replaced by whatever this module says. A clean plan is not evidence that nothing was there.

ℹ️ The managed-instance twin behaves the same way; the Entra-administrator resource in the same family does not.

11 Β· Several servers from one map
module "sql_threat_protection" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"
  for_each = module.sql_servers

  server_name         = each.value.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  email_addresses = ["secops@example.com"]
}

output "servers_detecting_nothing" {
  value = [for k, m in module.sql_threat_protection : k if m.this_policy_detects_nothing]
}

output "policy_ids_for_assessments" {
  value = { for k, m in module.sql_threat_protection : k => m.id }
}

πŸ’‘ Keying by a stable name means adding or removing a server never re-indexes the rest. The second output hands a for_each of vulnerability assessments the parent IDs they need, without anyone reaching for the server's ID by mistake.

12 Β· What a destroy leaves behind
# Removing this module block does NOT remove the policy.

πŸ”΄ The provider's delete sends a policy carrying only the disabled state. The ARM record survives, and because the payload carries nothing else, the endpoint, recipients and retention it had are not preserved by that call.

⚠️ And it breaks the sibling. A vulnerability assessment on the same server will refuse its next update, because the policy it reads is no longer Enabled.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "sql_server" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"

  name                = "sql-platform-eus2"
  resource_group_name = "rg-data-platform"
  location            = "eastus2"

  azuread_administrator = {
    login_username = "sql-admins"
    object_id      = var.sql_admin_group_object_id
  }
}

module "threat_storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stsqlsecurity0001"
  resource_group_name = "rg-data-platform"
  location            = "eastus2"
}

module "va_results" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"

  name               = "vulnerability-assessment"
  storage_account_id = module.threat_storage.id
}

module "sql_threat_protection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-security-alert-policy.git?ref=v1.0.0"

  server_name         = module.sql_server.name
  resource_group_name = "rg-data-platform"
  state               = "Enabled"

  disabled_alerts      = [] # all five detections on, stated explicitly
  email_addresses      = ["secops@example.com"]
  email_account_admins = false

  # Both or neither -- the provider requires each with the other.
  storage_endpoint           = module.threat_storage.primary_blob_endpoint
  storage_account_access_key = var.threat_storage_key
  retention_days             = 365
}

# The other half of Defender for SQL, and it depends on the policy above
# being Enabled -- which is why it takes the POLICY's id.
module "sql_vulnerability_assessment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-vulnerability-assessment.git?ref=v1.0.0"

  server_security_alert_policy_id = module.sql_threat_protection.id
  storage_container_path          = module.va_results.url

  recurring_scans = {
    enabled = true
    emails  = ["secops@example.com"]
  }
}

output "sql_security_posture" {
  value = {
    detecting       = module.sql_threat_protection.enabled_alert_types
    detects_nothing = module.sql_threat_protection.this_policy_detects_nothing
    nobody_notified = module.sql_threat_protection.alerts_reach_nobody_by_email
    records_account = module.sql_threat_protection.storage_account_name
    scan_results    = module.sql_vulnerability_assessment.container_name
  }
}

πŸ”΄ The dependency between the two modules is expressed by the argument itself β€” module.sql_threat_protection.id β€” so Terraform orders them correctly without a depends_on. That is the whole reason the vulnerability assessment takes a policy rather than a server.

πŸ’³ One per-server Defender for SQL charge covers both capabilities and every database on the server.

⚠️ Two different storage shapes in one composition: the alert policy takes primary_blob_endpoint, the assessment takes a container url. The correct value for one is the wrong value for the other.


πŸ“₯ Inputs

Identity β€” server_name, resource_group_name (both required, both force-new). Detection β€” state (required), disabled_alerts. Notification β€” email_addresses, email_account_admins. Records β€” storage_endpoint, storage_account_access_key (mutually required), retention_days. Tail β€” timeouts. (There is no tags: the resource exposes none.)

Full schemas
Name Type Default Notes
server_name string β€” Required, force-new. Lowercase, digits, hyphens; ≀63.
resource_group_name string β€” Required, force-new. Normalised by the provider.
state string β€” Required by the provider. Enabled or Disabled, case-sensitive.
disabled_alerts set(string) [] A DENY list. Closed set of five β€” no Brute_Force.
email_addresses set(string) [] Only a missing @ is refused.
email_account_admins bool false The provider's default, kept. Note the name.
retention_days number 0 Storage only. Non-negative; no documented upper bound.
storage_endpoint string null A blob endpoint. Required with the key.
storage_account_access_key string (sensitive) null Required with the endpoint. No identity fallback.
timeouts object({create, read, update, delete}) null All four are real.

🧾 Outputs

Output Description
id Resource ID of the policy β€” what the vulnerability-assessment module consumes
server_id derived by trimming this policy's ID
server_name / resource_group_name as configured
state Enabled or Disabled
threat_detection_is_on the same fact as a boolean
enabled_alert_types the deny list stated positively
disabled_alert_types what is switched off, sorted
all_alert_types the provider's closed set of five
retention_days / storage_endpoint / storage_account_name records configuration
email_addresses / email_account_admins notification configuration
this_policy_detects_nothing true when disabled or fully denied
alerts_reach_nobody_by_email true when no recipient is configured
an_access_key_is_configured presence flag β€” never the key
writes_threat_records_to_storage true when storage is configured
the_argument_names_differ_from_the_managed_instance_resource the spelling mismatches
secure_by_default_cannot_apply_because_state_is_required constant true
a_disabled_policy_is_a_real_record_that_detects_nothing constant true
there_are_five_alert_types_here_and_six_on_a_managed_instance constant true
the_storage_pair_is_mutually_required constant true
there_is_no_credential_free_storage_option_on_this_resource constant true
the_vulnerability_assessment_requires_this_policy_to_be_enabled constant true
advanced_threat_protection_is_billed_per_server constant true
destroying_this_resource_does_not_delete_the_policy constant true
creating_this_resource_overwrites_any_existing_policy constant true
retention_applies_to_storage_only constant true
no_access_key_is_emitted_by_this_module constant true
sensitive_redacts_plan_output_it_does_not_encrypt_state constant true
one_policy_per_server constant true
force_new_fields / fields_that_can_change_after_creation lifecycle
fields_azure_returns_on_read where drift is detectable β€” the key is absent
all_four_timeouts_are_honoured_here constant true
this_resource_supports_no_azure_resource_tags constant true

πŸ”’ No secret is emitted. The access key is reported as a presence flag.


🧠 Architecture Notes

The secure-by-default rule cannot apply, and saying so is better than pretending. This suite's convention is that an empty call produces the safe resource; the provider makes state required, so there is no empty call and no default for the module to pick. The three compensations available are all applied β€” validate the value set, name the protective value inside the error message, and emit the choice as a boolean so an audit across an estate compares true/false rather than enum spellings.

Three configurations detect nothing and look completely different. state = "Disabled"; a deny list containing all five types; and the record a terraform destroy leaves behind. One output, this_policy_detects_nothing, covers the first two, and a constant output covers the third β€” because a plan renders "destroy" and "removes the protection" as the same thing when only the first is true.

Its id is the load-bearing output. The sibling vulnerability assessment takes this policy's Resource ID as its parent, and the provider reads the policy on that resource's create and update. So the dependency is expressed by data flow rather than depends_on, and disabling this policy breaks the sibling's next apply β€” a coupling that appears nowhere in either plan.

The differences from the managed-instance twin are named, not smoothed over. Five alert types rather than six; state as a required string rather than an optional bool; email_account_admins rather than email_account_admins_enabled; a mutually-required storage pair rather than a merely-useless lone key. Every one of those fails loudly when a configuration is copied across, which is the safe direction β€” and the module emits the rename map rather than leaving it to be discovered.

There is no credential-free storage form here. The SQL auditing policies fall back to the server's managed identity when a key is omitted; this resource does not. If avoiding the secret matters more than the storage copy, leave storage unconfigured and read detections in Microsoft Defender for Cloud.

Sensitivity is contagious. var.storage_account_access_key != null is a sensitive bool, which Terraform refuses to emit. The presence flag is unwrapped with nonsensitive() at the point it is derived.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Threat detection no default possible β€” state is required by the provider β€”
Detection coverage disabled_alerts = [] β€” all five on name types to switch them off
Alert recipients none β€” reported, not defaulted supply email_addresses
Account-admin email false β€” the provider's default, kept set to true
Records in your storage none configured supply both storage arguments
Credential-free storage not offered by this resource β€”
Secret emission none β€” presence flag only not available
Cost not zero: Enabled starts a per-server charge state = "Disabled"

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is plan-only in this repository; a human applies from CI.


πŸ§ͺ Testing

terraform validate proves, offline and with no credentials: the server-name rule in both directions, the resource-group-not-an-ID check, the case-sensitive state enum, the five-value alert-type set (including that Brute_Force is refused), the email @ check, the non-negative whole-number retention rule, the storage endpoint's https and not-a-Resource-ID checks, and the mutual storage pairing in both directions.

Only terraform plan and apply exercise: whether the server exists, whether a Defender for SQL entitlement is present, and whether the supplied key works.

Nothing at either stage reports that an existing policy is about to be overwritten, that a destroy will disable rather than delete, or that disabling will break the sibling assessment. That is what the constant outputs are for.

Note the asymmetry: a validation failure blocks terraform destroy as well as apply, which is why this module refuses only what the provider itself would refuse, plus the two storage-shape mistakes Azure would reject.


πŸ’¬ Example Output

id                           = "/subscriptions/.../resourceGroups/rg-data-platform/providers/Microsoft.Sql/servers/sql-platform-eus2/securityAlertPolicies/Default"
server_id                    = "/subscriptions/.../providers/Microsoft.Sql/servers/sql-platform-eus2"
state                        = "Enabled"
threat_detection_is_on       = true
enabled_alert_types          = ["Access_Anomaly", "Data_Exfiltration", "Sql_Injection", "Sql_Injection_Vulnerability", "Unsafe_Action"]
disabled_alert_types         = []
this_policy_detects_nothing  = false
alerts_reach_nobody_by_email = false
storage_account_name         = "stsqlsecurity0001"
retention_days               = 365
an_access_key_is_configured  = true
force_new_fields             = ["server_name", "resource_group_name"]

πŸ” Troubleshooting

Symptom Cause Fix
state must be exactly "Enabled" or "Disabled" wrong case, or a bool copied from the managed-instance module The provider's check is case-sensitive, and this argument is a string here
a disabled_alerts entry is not a legal alert type for a LOGICAL SERVER usually Brute_Force, copied from the managed-instance module That value exists only there. Use all_alert_types as the reference
storage_endpoint and storage_account_access_key must be supplied TOGETHER half a storage configuration The provider requires each with the other
storage_endpoint must be an https URL a Resource ID, a container path, or a bare hostname Use the storage module's primary_blob_endpoint. The provider has no URL check
server_name can contain only lowercase letters uppercase, or a Resource ID was passed This resource takes a name and a resource group, not an ID
an email_addresses entry is empty or contains no '@' a malformed recipient Only that obvious mistake is refused
The policy exists but no threat is ever detected it is disabled, or all five types are in the deny list Check this_policy_detects_nothing
A threat was detected but nobody was told no recipients configured Check alerts_reach_nobody_by_email
The vulnerability assessment refuses to apply this policy's state is not Enabled Set it. The sibling reads this policy on create and update
An existing policy's settings vanished after an apply there is no requires-import guard, so it was overwritten Expected. Reconcile the portal configuration into this module's inputs
terraform destroy succeeded but the policy is still there, disabled the delete sends a disabled-state policy rather than removing the record Expected. There is no way to remove it through this resource
An unexpected Defender for SQL charge appeared state = "Enabled" is a purchase Expected; it is per server and covers every database on it

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."