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.
- π‘οΈ Turns on Advanced Threat Protection for an Azure SQL logical server.
- π
stateis required by the provider, so this suite's secure-by-default rule cannot apply β the module compensates rather than pretending otherwise. - π« Reads
disabled_alertsfor what it is β a deny list β and emits its complement positively. - π Its
idis what the vulnerability-assessment module consumes, not the server's, and that policy must beEnabledor 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.
If this module saved you time:
- β Star the repository
- πΌ Connect on LinkedIn
- β Buy me a coffee
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
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.
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
| Resource | Count | Notes |
|---|---|---|
azurerm_mssql_server_security_alert_policy.this |
1 | One per logical server; there is no policy name to choose |
| 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:
- π΄
stateis 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_Forceis 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_endpointandstorage_account_access_keyare mutuallyRequiredWithβ 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 destroydoes 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 isemail_account_admins, notemail_account_admins_enabledas on the managed instance.β οΈ storage_endpointcarries 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_namecomes from the provider's shared schema, which carries force-new and normalisation. - βΉοΈ
retention_daysdefaults to 0, governs storage only, and has no documented upper bound β so none is invented here. - βΉοΈ No
tags, nolocation. All four timeouts are honoured (30/5/30/30).
| 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.
- The
Microsoft.Sqlresource 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.
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
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.
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 |
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
}π΄
statehas 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
Enabledis 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 destroyleaves 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_Forceis 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_typesemits 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
RequiredWiththe 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 passingmodule.sql_threat_protection.idis what makes Terraform order the two correctly.
β οΈ Setting this policy toDisabledlater 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'senabledis an optional bool, this one'sstateis 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_Forcein 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_eachof 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 longerEnabled.
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 adepends_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 takesprimary_blob_endpoint, the assessment takes a containerurl. The correct value for one is the wrong value for the other.
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. |
| 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.
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.
| 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" |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 β never a branch. This module is plan-only in this repository; a human
applies from CI.
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.
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"]
| 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 |
azurerm_mssql_server_security_alert_policy- Configure Advanced Threat Protection for Azure SQL Database
- Microsoft Defender for SQL
- Sibling modules:
terraform-azurerm-mssql-server,terraform-azurerm-mssql-server-vulnerability-assessment,terraform-azurerm-mssql-managed-instance-security-alert-policy,terraform-azurerm-storage-account,terraform-azurerm-storage-container - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."