diff --git a/.terraform-docs.yml b/.terraform-docs.yml
new file mode 100644
index 0000000..2b4598e
--- /dev/null
+++ b/.terraform-docs.yml
@@ -0,0 +1,29 @@
+---
+formatter: "markdown table"
+version: "~> 0.16"
+sections:
+ hide:
+ - modules
+settings:
+ anchor: true
+ default: true
+ description: false
+ escape: true
+ hide-empty: false
+ html: true
+ indent: 2
+ lockfile: true
+ read-comments: true
+ required: true
+ sensitive: true
+ type: true
+sort:
+ enabled: true
+ by: name
+output:
+ file: README.md
+ mode: inject
+ template: |-
+
+ {{ .Content }}
+
diff --git a/.terraform-version b/.terraform-version
new file mode 100644
index 0000000..6a126f4
--- /dev/null
+++ b/.terraform-version
@@ -0,0 +1 @@
+1.7.5
diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl
new file mode 100644
index 0000000..68f046c
--- /dev/null
+++ b/.terraform.lock.hcl
@@ -0,0 +1,82 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/azure/azapi" {
+ version = "1.12.1"
+ constraints = ">= 1.12.1"
+ hashes = [
+ "h1:EaQL7pQCRm5iL2zy/dG7rOe2OZ0ZypuyVnpQAiAwJmM=",
+ "zh:1cf52e685ceb04e73e13fbf3f3036bff23a3274a4ceda8693c0612076a588166",
+ "zh:321b59c2a67c6cb4e5cf0dbe2cc978f5389d781e8b391f9b75bf4d830abd2ffe",
+ "zh:49046bd8020c3b44c6b5dc67041f181e4fff45e3bc1a9ff0646dd20c21c8ce47",
+ "zh:5784d0c326ec4825571577bc39b253019bd3b1030c19d67ca3436df2d7ba01c8",
+ "zh:5ad7e18d26f170c01888d8e65dab7aa475089aac7bf0106526fd57cdd56533bc",
+ "zh:6695854f4f655673bea85e37444bf0c070b440dba4bc269aa144d0f6b7c1cc5f",
+ "zh:7f372c897da6b9ad90869a8eb85b37dad4dff2d5d311b3eca1a2e6373e2271ed",
+ "zh:8afa1a2be1dada4e8be4ab72d9d56f36af1e486c9353d04aabf6e79db7310125",
+ "zh:90809364619238c45185bff25c7d9c4fde34253561d8183ebbe797456c44bc9c",
+ "zh:9338d44650c9e68e10a6bc2d69f7beacd5059e6ac681d2e388e80a1652d9c183",
+ "zh:c94ee6fb1df2c1d35f338107b5e73cdba86c4ecf9dcde95e2ca0132cbbd4bd7c",
+ "zh:de231d363b1a664c6b5d3af8d3b9cf542d04d4506fb9458ba6c8ebf94e0e32ae",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/azuread" {
+ version = "2.48.0"
+ constraints = ">= 2.37.1"
+ hashes = [
+ "h1:0R8yR32NSZvH93C1o4cdmhdRkjc1uBZD5UtiexTVcOk=",
+ "zh:0ec4f1ca1825f038001173c40f4b6edbdbc71d018d782b45c22d5e272ca0ec16",
+ "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7",
+ "zh:22154cd497009b5b1cb6b87131b3f31521b3de392ade1ac64dade3f29b03f8d0",
+ "zh:2723fe574d7a89242bd642b896ff7006d36f8a5d5a7c3876c7e1e2ada567d599",
+ "zh:2858abe3209fa0035419a4b2f8f155878fb6ecbc64f72c6f726dad583b1c8217",
+ "zh:3ba51d3e3ba6f12e8e12b043d7bc5f4415fc1ac08b81306ad546fe1ca2a3aa32",
+ "zh:49a39fb3713ba1a58fcb7b040bc4430ab4edb5116e8d7d33b73361f07febaead",
+ "zh:6a043d62a9cbfb805040e33e700cdcbfb5f199a74ae3867fc10c6810741ab222",
+ "zh:906c0961425d5854b22c9fed4d319248a7c88f0037547ea8472998720487ae25",
+ "zh:a1d246d8e0362afe397f0aedf0e68cf7d920fbae1adb88841f63dc98c06e5888",
+ "zh:c7df4d912c970600d9cba97a60c84b1a4ad1031feb723021c6984d99b320fd5c",
+ "zh:e8fbec893b4feb4410185126f2421ef0bdbbb102d1370ed72bb65b99d8869b98",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/azurerm" {
+ version = "3.99.0"
+ constraints = ">= 3.52.0, >= 3.99.0"
+ hashes = [
+ "h1:dawmYJUMGlL3t1mKDyaLJc08uSxPaUBoCAb/YCbVxPM=",
+ "zh:20581c1f4c586a37af45ed4c2a86ff4d868cee79139a755bd29750d804cee3ef",
+ "zh:28b3cc4e5f8bc65a595eab011d5965203a39e92aa9e26df842ffc979305ac823",
+ "zh:4cb167f8bb82f9065b7b50d012be3045fce3c699b0ea0e257ad1995441227f72",
+ "zh:6fa5c6fa430921a4e0fe8d44eaf12210fb90afdf3f83cedfde1c691ae36e953c",
+ "zh:75eff5b0ea9fca46ed5a0425c5e33fbda470e6448917817e80ae898688568665",
+ "zh:9af0aeaa74bfc764c60eec7d212d31deb70e03e970d22449f11170f75108f9cf",
+ "zh:b5055767199a2927d41b543a16e905c1e0b209f14a2144c756786194e133b41d",
+ "zh:c3e30b0eed068a148498ac78a9e013bc2eef0eb3cc3b4484f77421d64a797dc2",
+ "zh:ce87cd35cef9e5805f921978a91a7a4e139e8cbc7674a94076cb1a20a0c2feb1",
+ "zh:d87b84f144c865145bd10093ead99b653ea363fd4e7315675727659ca78544d0",
+ "zh:ee5900a50d69e046aab6581f6d888014b3f8d543e5b17c50761579d3370935f2",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/null" {
+ version = "3.2.2"
+ constraints = ">= 3.2.1"
+ hashes = [
+ "h1:IMVAUHKoydFrlPrl9OzasDnw/8ntZFerCC9iXw1rXQY=",
+ "zh:3248aae6a2198f3ec8394218d05bd5e42be59f43a3a7c0b71c66ec0df08b69e7",
+ "zh:32b1aaa1c3013d33c245493f4a65465eab9436b454d250102729321a44c8ab9a",
+ "zh:38eff7e470acb48f66380a73a5c7cdd76cc9b9c9ba9a7249c7991488abe22fe3",
+ "zh:4c2f1faee67af104f5f9e711c4574ff4d298afaa8a420680b0cb55d7bbc65606",
+ "zh:544b33b757c0b954dbb87db83a5ad921edd61f02f1dc86c6186a5ea86465b546",
+ "zh:696cf785090e1e8cf1587499516b0494f47413b43cb99877ad97f5d0de3dc539",
+ "zh:6e301f34757b5d265ae44467d95306d61bef5e41930be1365f5a8dcf80f59452",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:913a929070c819e59e94bb37a2a253c228f83921136ff4a7aa1a178c7cce5422",
+ "zh:aa9015926cd152425dbf86d1abdbc74bfe0e1ba3d26b3db35051d7b9ca9f72ae",
+ "zh:bb04798b016e1e1d49bcc76d62c53b56c88c63d6f2dfe38821afef17c416a0e1",
+ "zh:c23084e1b23577de22603cff752e59128d83cfecc2e6819edadd8cf7a10af11e",
+ ]
+}
diff --git a/Brewfile b/Brewfile
new file mode 100644
index 0000000..c5a9e04
--- /dev/null
+++ b/Brewfile
@@ -0,0 +1,4 @@
+brew "tfenv"
+brew "terraform-docs"
+brew "tfsec"
+brew "tflint"
diff --git a/README.md b/README.md
index 0a4666b..cf98ffd 100644
--- a/README.md
+++ b/README.md
@@ -78,7 +78,7 @@
| [mssql\_collation](#input\_mssql\_collation) | Set the collation for the SQL database | `string` | `"SQL_Latin1_General_CP1_CI_AS"` | no |
| [mssql\_database\_name](#input\_mssql\_database\_name) | The name of the MSSQL database to create. Must be set if `enable_mssql_database` is true | `string` | `""` | no |
| [mssql\_firewall\_ipv4\_allow\_list](#input\_mssql\_firewall\_ipv4\_allow\_list) | A list of IPv4 Addresses that require remote access to the MSSQL Server |
map(object({
start_ip_range : string,
end_ip_range : optional(string, "")
}))
| `{}` | no |
-| [mssql\_managed\_identity\_assign\_role](#input\_mssql\_managed\_identity\_assign\_role) | Assign the 'Storage Blob Data Contributor' Role to the SQL Server User-Assigned Managed Identity. Note: If you do not have 'Microsoft.Authorization/roleAssignments/write' permission, you will need to manually assign the 'Storage Blob Data Contributor' Role to the identity | `bool` | `true` | no |
+| [mssql\_managed\_identity\_assign\_role](#input\_mssql\_managed\_identity\_assign\_role) | Assign the 'Storage Blob Data Contributor' Role to the SQL Server User-Assigned Managed Identity. Note: If you do not have 'Microsoft.Authorization/roleAssignments/write' permission, you will need to manually assign the 'Storage Blob Data Contributor' Role to the identity | `bool` | `false` | no |
| [mssql\_max\_size\_gb](#input\_mssql\_max\_size\_gb) | The max size of the database in gigabytes | `number` | `2` | no |
| [mssql\_security\_storage\_firewall\_ipv4\_allow\_list](#input\_mssql\_security\_storage\_firewall\_ipv4\_allow\_list) | Additional IP addresses to add to the Storage Account that holds the Vulnerability Assessments | `list(string)` | `[]` | no |
| [mssql\_server\_admin\_password](#input\_mssql\_server\_admin\_password) | The local administrator password for the MSSQL server | `string` | `""` | no |
diff --git a/backend.tf b/backend.tf
new file mode 100644
index 0000000..3bd9544
--- /dev/null
+++ b/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "azurerm" {}
+}
\ No newline at end of file
diff --git a/backend.vars.example b/backend.vars.example
new file mode 100644
index 0000000..f84daee
--- /dev/null
+++ b/backend.vars.example
@@ -0,0 +1,5 @@
+subscription_id = ""
+resource_group_name = ""
+storage_account_name = ""
+container_name = ""
+key = "terraform.tstate"
\ No newline at end of file
diff --git a/data.tf b/data.tf
new file mode 100644
index 0000000..ad5181e
--- /dev/null
+++ b/data.tf
@@ -0,0 +1,54 @@
+data "azurerm_virtual_network" "existing_virtual_network" {
+ count = local.existing_virtual_network == "" ? 0 : 1
+
+ name = local.existing_virtual_network
+ resource_group_name = local.existing_resource_group
+}
+
+data "azurerm_resource_group" "existing_resource_group" {
+ count = local.existing_resource_group == "" ? 0 : 1
+
+ name = local.existing_resource_group
+}
+
+data "azurerm_subscription" "current" {}
+
+data "azurerm_logic_app_workflow" "existing_logic_app_workflow" {
+ count = local.existing_logic_app_workflow.name == "" ? 0 : 1
+
+ name = local.existing_logic_app_workflow.name
+ resource_group_name = local.existing_logic_app_workflow.resource_group_name
+}
+
+# There is not currently a way to get the full HTTP Trigger callback URL from a Logic App
+# so we have to use AzAPI to query the Logic App Workflow for the value instead.
+# https://github.com/hashicorp/terraform-provider-azurerm/issues/18866
+data "azapi_resource_action" "existing_logic_app_workflow_callback_url" {
+ count = local.existing_logic_app_workflow.name == "" ? 0 : 1
+
+ resource_id = "${data.azurerm_logic_app_workflow.existing_logic_app_workflow[0].id}/triggers/${data.azurerm_logic_app_workflow.existing_logic_app_workflow[0].name}-trigger"
+ action = "listCallbackUrl"
+ type = "Microsoft.Logic/workflows/triggers@2018-07-01-preview"
+
+ depends_on = [
+ data.azurerm_logic_app_workflow.existing_logic_app_workflow[0]
+ ]
+
+ response_export_values = ["value"]
+}
+
+data "azurerm_virtual_network" "private_endpoints" {
+ for_each = local.private_endpoint_configurations
+
+ name = each.value["vnet_name"]
+ resource_group_name = each.value["vnet_resource_group_name"]
+}
+
+data "azurerm_route_table" "private_endpoints" {
+ for_each = {
+ for k, v in local.private_endpoint_configurations : k => v if v["subnet_route_table_name"] != null
+ }
+
+ name = each.value["subnet_route_table_name"]
+ resource_group_name = each.value["vnet_resource_group_name"]
+}
diff --git a/identity.tf b/identity.tf
new file mode 100644
index 0000000..794ede9
--- /dev/null
+++ b/identity.tf
@@ -0,0 +1,17 @@
+resource "azurerm_user_assigned_identity" "mssql" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ location = local.resource_group.location
+ name = "${local.resource_prefix}-uami-mssql"
+ resource_group_name = local.resource_group.name
+ tags = local.tags
+}
+
+resource "azurerm_role_assignment" "mssql_storageblobdatacontributor" {
+ count = local.enable_mssql_database && local.mssql_managed_identity_assign_role ? 1 : 0
+
+ scope = azurerm_storage_account.mssql_security_storage[0].id
+ role_definition_name = "Storage Blob Data Contributor"
+ principal_id = azurerm_user_assigned_identity.mssql[0].id
+ description = "Allow SQL Auditing to write reports and findings into the MSSQL Security Storage Account"
+}
diff --git a/key-vault-tfvars.tf b/key-vault-tfvars.tf
new file mode 100644
index 0000000..fb27aa8
--- /dev/null
+++ b/key-vault-tfvars.tf
@@ -0,0 +1,15 @@
+module "azurerm_key_vault" {
+ source = "github.com/DFE-Digital/terraform-azurerm-key-vault-tfvars?ref=v0.4.1"
+
+ environment = local.environment
+ project_name = local.project_name
+ existing_resource_group = local.resource_group.name
+ azure_location = local.azure_location
+ key_vault_access_use_rbac_authorization = true
+ key_vault_access_users = []
+ key_vault_access_ipv4 = local.key_vault_access_ipv4
+ tfvars_filename = local.tfvars_filename
+ enable_diagnostic_setting = false
+ enable_diagnostic_storage_account = false
+ tags = local.tags
+}
diff --git a/locals.tf b/locals.tf
new file mode 100644
index 0000000..6fdfbe0
--- /dev/null
+++ b/locals.tf
@@ -0,0 +1,56 @@
+locals {
+ # Global options
+ environment = var.environment
+ project_name = var.project_name
+ resource_prefix = "${local.environment}${local.project_name}"
+ azure_location = var.azure_location
+ tags = var.tags
+
+ # Key Vault
+ key_vault_access_ipv4 = var.key_vault_access_ipv4
+ tfvars_filename = var.tfvars_filename
+
+ # Resource Group
+ existing_resource_group = var.existing_resource_group
+ resource_group = local.existing_resource_group == "" ? azurerm_resource_group.default[0] : data.azurerm_resource_group.existing_resource_group[0]
+
+ # Networking
+ launch_in_vnet = var.launch_in_vnet
+ existing_virtual_network = var.existing_virtual_network
+ virtual_network = local.existing_virtual_network == "" ? azurerm_virtual_network.default[0] : data.azurerm_virtual_network.existing_virtual_network[0]
+ virtual_network_address_space = var.virtual_network_address_space
+ virtual_network_address_space_mask = element(split("/", local.virtual_network_address_space), 1)
+ mssql_private_endpoint_subnet_cidr = cidrsubnet(local.virtual_network_address_space, 23 - local.virtual_network_address_space_mask, 1)
+ private_endpoint_configurations = var.private_endpoint_configurations
+
+ # SQL Server
+ enable_mssql_database = var.enable_mssql_database
+ mssql_server_admin_password = var.mssql_server_admin_password
+ mssql_sku_name = var.mssql_sku_name
+ mssql_collation = var.mssql_collation
+ mssql_max_size_gb = var.mssql_max_size_gb
+ mssql_database_name = var.mssql_database_name
+ mssql_firewall_ipv4_allow_list = var.mssql_firewall_ipv4_allow_list
+ mssql_azuread_admin_username = var.mssql_azuread_admin_username
+ mssql_azuread_admin_object_id = var.mssql_azuread_admin_object_id
+ mssql_azuread_auth_only = var.mssql_azuread_auth_only
+ mssql_version = var.mssql_version
+ mssql_server_public_access_enabled = var.mssql_server_public_access_enabled
+ enable_mssql_vulnerability_assessment = var.enable_mssql_vulnerability_assessment
+ mssql_security_storage_firewall_ipv4_allow_list = var.mssql_security_storage_firewall_ipv4_allow_list
+ mssql_managed_identity_assign_role = var.mssql_managed_identity_assign_role
+
+ # Azure Monitor
+ enable_monitoring = var.enable_monitoring
+ # Azure Monitor / Logic App Workflow
+ existing_logic_app_workflow = var.existing_logic_app_workflow
+ logic_app_workflow_name = local.existing_logic_app_workflow.name != "" ? data.azurerm_logic_app_workflow.existing_logic_app_workflow[0].name : null
+ logic_app_workflow_id = local.existing_logic_app_workflow.name != "" ? data.azurerm_logic_app_workflow.existing_logic_app_workflow[0].id : null
+ logic_app_workflow_callback_url = local.existing_logic_app_workflow.name != "" ? jsondecode(data.azapi_resource_action.existing_logic_app_workflow_callback_url[0].output).value : null
+ monitor_email_receivers = var.monitor_email_receivers
+ monitor_logic_app_receiver = {
+ name = local.logic_app_workflow_name
+ resource_id = local.logic_app_workflow_id
+ callback_url = local.logic_app_workflow_callback_url
+ }
+}
diff --git a/monitor.tf b/monitor.tf
new file mode 100644
index 0000000..1a51836
--- /dev/null
+++ b/monitor.tf
@@ -0,0 +1,289 @@
+resource "azurerm_monitor_action_group" "main" {
+ count = local.enable_monitoring ? 1 : 0
+
+ name = "${local.resource_prefix}-actiongroup"
+ resource_group_name = local.resource_group.name
+ short_name = local.project_name
+ tags = local.tags
+
+ dynamic "email_receiver" {
+ for_each = local.monitor_email_receivers
+
+ content {
+ name = "Email ${email_receiver.value}"
+ email_address = email_receiver.value
+ use_common_alert_schema = true
+ }
+ }
+
+ dynamic "logic_app_receiver" {
+ for_each = local.existing_logic_app_workflow.name != "" ? [1] : []
+
+ content {
+ name = local.monitor_logic_app_receiver.name
+ resource_id = local.monitor_logic_app_receiver.resource_id
+ callback_url = local.monitor_logic_app_receiver.callback_url
+ use_common_alert_schema = true
+ }
+ }
+}
+
+resource "azurerm_monitor_metric_alert" "sql_user_cpu" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-user-cpu"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "High user CPU usage"
+ window_size = "PT15M"
+ frequency = "PT1M"
+ severity = 2
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "cpu_percent"
+ aggregation = "Average"
+ operator = "GreaterThan"
+ threshold = 90
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_cpu" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-cpu"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "High total CPU usage"
+ window_size = "PT15M"
+ frequency = "PT1M"
+ severity = 2
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "sql_instance_cpu_percent"
+ aggregation = "Average"
+ operator = "GreaterThan"
+ threshold = 90
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_worker" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-worker"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "High worker usage"
+ window_size = "PT5M"
+ frequency = "PT1M"
+ severity = 1
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "workers_percent"
+ aggregation = "Minimum"
+ operator = "GreaterThan"
+ threshold = 60
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_dataio" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-worker"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "High data IO usage"
+ window_size = "PT15M"
+ frequency = "PT1M"
+ severity = 3
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "physical_data_read_percent"
+ aggregation = "Average"
+ operator = "GreaterThan"
+ threshold = 90
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_disk" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-disk"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Low data space"
+ window_size = "PT5M"
+ frequency = "PT1M"
+ severity = 1
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "storage_percent"
+ aggregation = "Minimum"
+ operator = "GreaterThan"
+ threshold = 95
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_tempdb" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-tempdb"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Low tempdb log space"
+ window_size = "PT5M"
+ frequency = "PT1M"
+ severity = 1
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "tempdb_log_used_percent"
+ aggregation = "Minimum"
+ operator = "GreaterThan"
+ threshold = 60
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_deadlock" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-deadlock"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Deadlocks"
+ window_size = "PT1H"
+ frequency = "PT15M"
+ severity = 3
+
+ dynamic_criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "deadlock"
+ aggregation = "Total"
+ operator = "GreaterThan"
+ alert_sensitivity = "Medium"
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_failed_user" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-failed-user"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Failed connections (user errors)"
+ window_size = "PT5M"
+ frequency = "PT15M"
+ severity = 2
+
+ dynamic_criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "connection_failed_user_error"
+ aggregation = "Total"
+ operator = "GreaterThan"
+ alert_sensitivity = "Medium"
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_failed_system" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-failed-system"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Failed connections (system errors)"
+ window_size = "PT5M"
+ frequency = "PT1M"
+ severity = 2
+
+ criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "connection_failed_system_error"
+ aggregation = "Total"
+ operator = "GreaterThan"
+ threshold = 10
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_monitor_metric_alert" "sql_rate" {
+ count = local.enable_monitoring && local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-sql-rate"
+ resource_group_name = local.resource_group.name
+ scopes = [azurerm_mssql_database.default[0].id]
+ description = "Anomalous connection rate"
+ window_size = "PT15M"
+ frequency = "PT5M"
+ severity = 2
+
+ dynamic_criteria {
+ metric_namespace = "Microsoft.Sql/servers/databases"
+ metric_name = "connection_failed_system_error"
+ aggregation = "Total"
+ operator = "GreaterOrLessThan"
+ alert_sensitivity = "Low"
+ }
+
+ action {
+ action_group_id = azurerm_monitor_action_group.main[0].id
+ }
+
+ tags = local.tags
+}
diff --git a/mssql.tf b/mssql.tf
new file mode 100644
index 0000000..b8f0ced
--- /dev/null
+++ b/mssql.tf
@@ -0,0 +1,107 @@
+resource "azurerm_mssql_server" "default" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ name = local.resource_prefix
+ resource_group_name = local.resource_group.name
+ location = local.resource_group.location
+ version = local.mssql_version
+ administrator_login = local.mssql_server_admin_password != "" ? "${local.resource_prefix}-admin" : null
+ administrator_login_password = local.mssql_server_admin_password != "" ? local.mssql_server_admin_password : null
+ public_network_access_enabled = local.mssql_server_public_access_enabled
+ minimum_tls_version = "1.2"
+
+ dynamic "azuread_administrator" {
+ for_each = local.mssql_azuread_admin_username != "" ? [1] : []
+
+ content {
+ object_id = local.mssql_azuread_admin_object_id
+ login_username = local.mssql_azuread_admin_username
+ tenant_id = data.azurerm_subscription.current.tenant_id
+ azuread_authentication_only = local.mssql_azuread_auth_only
+ }
+ }
+
+ identity {
+ type = "UserAssigned"
+ identity_ids = [azurerm_user_assigned_identity.mssql[0].id]
+ }
+
+ primary_user_assigned_identity_id = azurerm_user_assigned_identity.mssql[0].id
+
+ tags = local.tags
+}
+
+resource "azurerm_mssql_server_extended_auditing_policy" "default" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ server_id = azurerm_mssql_server.default[0].id
+ storage_endpoint = azurerm_storage_account.mssql_security_storage[0].primary_blob_endpoint
+ retention_in_days = 90
+}
+
+resource "azurerm_mssql_database" "default" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ name = local.mssql_database_name
+ server_id = azurerm_mssql_server.default[0].id
+ collation = local.mssql_collation
+ sku_name = local.mssql_sku_name
+ max_size_gb = local.mssql_max_size_gb
+
+ threat_detection_policy {
+ state = "Enabled"
+ email_account_admins = "Enabled"
+ retention_days = 90
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_mssql_database_extended_auditing_policy" "default" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ database_id = azurerm_mssql_database.default[0].id
+ storage_endpoint = azurerm_storage_account.mssql_security_storage[0].primary_blob_endpoint
+ retention_in_days = 90
+}
+
+resource "azurerm_mssql_firewall_rule" "default_mssql" {
+ for_each = local.enable_mssql_database ? local.mssql_firewall_ipv4_allow_list : {}
+
+ name = each.key
+ server_id = azurerm_mssql_server.default[0].id
+ start_ip_address = each.value.start_ip_range
+ end_ip_address = lookup(each.value, "end_ip_range", "") != "" ? each.value.end_ip_range : each.value.start_ip_range
+}
+
+# "Express Configuration" for SQL Server vulnerability assessments is not yet
+# supported in the azurerm provider. The "azurerm_mssql_server_vulnerability_assessment"
+# resource only supports the classic configuration which requires a storage account.
+# Instead, we can use AzApi to enable the "Express" (modern) option which does not rely
+# on a storage account.
+# GitHub issue: https://github.com/hashicorp/terraform-provider-azurerm/issues/19971
+resource "azapi_update_resource" "mssql_vulnerability_assessment" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ type = "Microsoft.Sql/servers/sqlVulnerabilityAssessments@2023-05-01-preview"
+ name = azurerm_mssql_server.default[0].name
+ parent_id = azurerm_mssql_server.default[0].id
+ body = jsonencode({
+ properties = {
+ state = local.enable_mssql_vulnerability_assessment ? "Enabled" : "Disabled"
+ }
+ })
+}
+
+resource "azapi_update_resource" "mssql_threat_protection" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ type = "Microsoft.Sql/servers/advancedThreatProtectionSettings@2023-05-01-preview"
+ name = azurerm_mssql_server.default[0].name
+ parent_id = azurerm_mssql_server.default[0].id
+ body = jsonencode({
+ properties = {
+ state = local.enable_mssql_vulnerability_assessment ? "Enabled" : "Disabled"
+ }
+ })
+}
diff --git a/outputs.tf b/outputs.tf
new file mode 100644
index 0000000..b611b8e
--- /dev/null
+++ b/outputs.tf
@@ -0,0 +1,9 @@
+output "azurerm_resource_group" {
+ value = local.existing_resource_group == "" ? azurerm_resource_group.default[0] : null
+ description = "Azure Resource Group"
+}
+
+output "azurerm_user_assigned_identity_principal_id" {
+ value = azurerm_user_assigned_identity.mssql[0].principal_id
+ description = "Principal ID for the UAMI assigned to the SQL Server"
+}
diff --git a/private-endpoints.tf b/private-endpoints.tf
new file mode 100644
index 0000000..fcde434
--- /dev/null
+++ b/private-endpoints.tf
@@ -0,0 +1,63 @@
+resource "azurerm_subnet" "private_endpoint" {
+ for_each = local.private_endpoint_configurations
+
+ name = "${local.resource_prefix}-${each.key}-mssqlprivateendpoint"
+ virtual_network_name = data.azurerm_virtual_network.private_endpoints[each.key].name
+ resource_group_name = data.azurerm_virtual_network.private_endpoints[each.key].resource_group_name
+ address_prefixes = [each.value["subnet_cidr"]]
+ private_endpoint_network_policies_enabled = false
+}
+
+resource "azurerm_subnet_route_table_association" "private_endpoint" {
+ for_each = {
+ for k, v in local.private_endpoint_configurations : k => v if v["subnet_route_table_name"] != null
+ }
+
+ subnet_id = azurerm_subnet.private_endpoint[each.key].id
+ route_table_id = data.azurerm_route_table.private_endpoints[each.key].id
+}
+
+resource "azurerm_private_endpoint" "mssql" {
+ for_each = local.private_endpoint_configurations
+
+ name = "${local.resource_prefix}${each.key}"
+ location = data.azurerm_virtual_network.private_endpoints[each.key].location
+ resource_group_name = local.resource_group.name
+ subnet_id = azurerm_subnet.private_endpoint[each.key].id
+
+ custom_network_interface_name = "${local.resource_prefix}${each.key}-nic"
+
+ private_service_connection {
+ name = "${local.resource_prefix}${each.key}"
+ private_connection_resource_id = azurerm_mssql_server.default[0].id
+ subresource_names = ["sqlServer"]
+ is_manual_connection = false
+ }
+
+ private_dns_zone_group {
+ name = "${local.resource_prefix}${each.key}-mssql-private-link"
+ private_dns_zone_ids = [azurerm_private_dns_zone.mssql[each.key].id]
+ }
+
+ tags = local.tags
+}
+
+resource "azurerm_private_dns_zone" "mssql" {
+ for_each = {
+ for k, v in local.private_endpoint_configurations : k => v if v["create_mssql_privatelink_dns_zone"]
+ }
+
+ name = "${azurerm_mssql_server.default[0].name}.database.windows.net"
+ resource_group_name = data.azurerm_virtual_network.private_endpoints[each.key].resource_group_name
+ tags = local.tags
+}
+
+resource "azurerm_private_dns_zone_virtual_network_link" "mssql" {
+ for_each = local.private_endpoint_configurations
+
+ name = "${local.resource_prefix}mssqlprivatelink"
+ resource_group_name = data.azurerm_virtual_network.private_endpoints[each.key].resource_group_name
+ private_dns_zone_name = each.value["create_mssql_privatelink_dns_zone"] ? azurerm_private_dns_zone.mssql[each.key].name : "privatelink.database.windows.net"
+ virtual_network_id = data.azurerm_virtual_network.private_endpoints[each.key].id
+ tags = local.tags
+}
diff --git a/providers.tf b/providers.tf
new file mode 100644
index 0000000..6b53710
--- /dev/null
+++ b/providers.tf
@@ -0,0 +1,4 @@
+provider "azurerm" {
+ features {}
+ skip_provider_registration = true
+}
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..39a2b6e
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:base"
+ ]
+}
diff --git a/resource-group.tf b/resource-group.tf
new file mode 100644
index 0000000..9742720
--- /dev/null
+++ b/resource-group.tf
@@ -0,0 +1,7 @@
+resource "azurerm_resource_group" "default" {
+ count = local.existing_resource_group == "" ? 1 : 0
+
+ name = local.resource_prefix
+ location = local.azure_location
+ tags = local.tags
+}
diff --git a/storage.tf b/storage.tf
new file mode 100644
index 0000000..34caf2c
--- /dev/null
+++ b/storage.tf
@@ -0,0 +1,35 @@
+resource "azurerm_storage_account" "mssql_security_storage" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ name = "${replace(local.resource_prefix, "-", "")}mssqlsec"
+ resource_group_name = local.resource_group.name
+ location = local.resource_group.location
+ account_tier = "Standard"
+ account_replication_type = "LRS"
+ min_tls_version = "TLS1_2"
+ tags = local.tags
+ enable_https_traffic_only = true
+ public_network_access_enabled = local.enable_mssql_vulnerability_assessment ? true : false
+ shared_access_key_enabled = true
+ allow_nested_items_to_be_public = false
+}
+
+resource "azurerm_storage_account_network_rules" "mssql_security_storage" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ storage_account_id = azurerm_storage_account.mssql_security_storage[0].id
+ # If Vulnerability Assessment is enabled, then there is not currently a way to
+ # store reports in a Storage Account that is protected by a Firewall.
+ # Inbound traffic must be permitted to the Storage Account
+ default_action = local.enable_mssql_vulnerability_assessment ? "Allow" : "Deny"
+ bypass = ["AzureServices"]
+ virtual_network_subnet_ids = []
+ ip_rules = local.mssql_security_storage_firewall_ipv4_allow_list
+}
+
+resource "azurerm_storage_container" "mssql_security_storage" {
+ count = local.enable_mssql_database ? 1 : 0
+
+ name = "${local.resource_prefix}-mssqlsec"
+ storage_account_name = azurerm_storage_account.mssql_security_storage[0].name
+}
diff --git a/variables.tf b/variables.tf
new file mode 100644
index 0000000..062c729
--- /dev/null
+++ b/variables.tf
@@ -0,0 +1,194 @@
+variable "environment" {
+ description = "Environment name. Will be used along with `project_name` as a prefix for all resources."
+ type = string
+}
+
+variable "project_name" {
+ description = "Project name. Will be used along with `environment` as a prefix for all resources."
+ type = string
+}
+
+variable "azure_location" {
+ description = "Azure location in which to launch resources."
+ type = string
+}
+
+variable "tags" {
+ description = "Tags to be applied to all resources"
+ type = map(string)
+ default = {}
+}
+
+variable "existing_resource_group" {
+ description = "Conditionally launch resources into an existing resource group. Specifying this will NOT create a resource group."
+ type = string
+ default = ""
+}
+
+variable "launch_in_vnet" {
+ description = "Conditionally launch into a VNet"
+ type = bool
+ default = true
+}
+
+variable "existing_virtual_network" {
+ description = "Conditionally use an existing virtual network. The `virtual_network_address_space` must match an existing address space in the VNet. This also requires the resource group name."
+ type = string
+ default = ""
+}
+
+variable "virtual_network_address_space" {
+ description = "Virtual Network address space CIDR"
+ type = string
+ default = "172.16.0.0/12"
+}
+
+variable "enable_mssql_database" {
+ description = "Set to true to create an Azure SQL server/database, with a private endpoint within the virtual network"
+ type = bool
+ default = false
+}
+
+variable "mssql_server_admin_password" {
+ description = "The local administrator password for the MSSQL server"
+ type = string
+ default = ""
+ sensitive = true
+}
+
+variable "mssql_azuread_admin_username" {
+ description = "Username of a User within Azure AD that you want to assign as the SQL Server Administrator"
+ type = string
+ default = ""
+}
+
+variable "mssql_azuread_admin_object_id" {
+ description = "Object ID of a User within Azure AD that you want to assign as the SQL Server Administrator"
+ type = string
+ default = ""
+}
+
+variable "mssql_azuread_auth_only" {
+ description = "Set to true to only permit SQL logins from Azure AD users"
+ type = bool
+ default = false
+}
+
+variable "mssql_sku_name" {
+ description = "Specifies the name of the SKU used by the database"
+ type = string
+ default = "Basic"
+}
+
+variable "mssql_collation" {
+ description = "Set the collation for the SQL database"
+ type = string
+ default = "SQL_Latin1_General_CP1_CI_AS"
+}
+
+variable "mssql_max_size_gb" {
+ description = "The max size of the database in gigabytes"
+ type = number
+ default = 2
+}
+
+variable "mssql_database_name" {
+ description = "The name of the MSSQL database to create. Must be set if `enable_mssql_database` is true"
+ type = string
+ default = ""
+}
+
+variable "mssql_firewall_ipv4_allow_list" {
+ description = "A list of IPv4 Addresses that require remote access to the MSSQL Server"
+ type = map(object({
+ start_ip_range : string,
+ end_ip_range : optional(string, "")
+ }))
+ default = {}
+}
+
+variable "mssql_server_public_access_enabled" {
+ description = "Enable public internet access to your MSSQL instance. Be sure to specify 'mssql_firewall_ipv4_allow_list' to restrict inbound connections"
+ type = bool
+ default = false
+}
+
+variable "mssql_version" {
+ description = "Specify the version of Microsoft SQL Server you want to run"
+ type = string
+ default = "12.0"
+}
+
+variable "enable_mssql_vulnerability_assessment" {
+ description = "Vulnerability assessment can discover, track, and help you remediate potential database vulnerabilities"
+ type = bool
+ default = true
+}
+
+variable "mssql_security_storage_firewall_ipv4_allow_list" {
+ description = "Additional IP addresses to add to the Storage Account that holds the Vulnerability Assessments"
+ type = list(string)
+ default = []
+}
+
+variable "mssql_managed_identity_assign_role" {
+ description = "Assign the 'Storage Blob Data Contributor' Role to the SQL Server User-Assigned Managed Identity. Note: If you do not have 'Microsoft.Authorization/roleAssignments/write' permission, you will need to manually assign the 'Storage Blob Data Contributor' Role to the identity"
+ type = bool
+ default = false
+}
+
+variable "enable_monitoring" {
+ description = "Create an App Insights instance and notification group for the Container App"
+ type = bool
+ default = false
+}
+
+variable "monitor_email_receivers" {
+ description = "A list of email addresses that should be notified by monitoring alerts"
+ type = list(string)
+ default = []
+}
+
+variable "existing_logic_app_workflow" {
+ description = "Name, Resource Group and HTTP Trigger URL of an existing Logic App Workflow. Leave empty to create a new Resource"
+ type = object({
+ name : string
+ resource_group_name : string
+ })
+ default = {
+ name = ""
+ resource_group_name = ""
+ }
+}
+
+variable "key_vault_access_ipv4" {
+ description = "List of IPv4 Addresses that are permitted to access the Key Vault"
+ type = list(string)
+}
+
+variable "tfvars_filename" {
+ description = "tfvars filename. This file is uploaded and stored encrypted within Key Vault, to ensure that the latest tfvars are stored in a shared place."
+ type = string
+}
+
+variable "private_endpoint_configurations" {
+ description = <