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 = <